diff --git a/bun.lock b/bun.lock index 0492ab0..8b042b8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,13 @@ "": { "name": "flicky", "dependencies": { + "@nut-tree-fork/nut-js": "^4.2.6", "posthog-node": "^4.0.0", - "ws": "^8.18.0", }, "devDependencies": { "@electron/notarize": "^2.5.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@types/ws": "^8.5.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitejs/plugin-react": "^4.3.0", @@ -165,6 +164,68 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@jimp/bmp": ["@jimp/bmp@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "bmp-js": "^0.1.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g=="], + + "@jimp/core": ["@jimp/core@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "any-base": "^1.1.0", "buffer": "^5.2.0", "exif-parser": "^0.1.12", "file-type": "^16.5.4", "isomorphic-fetch": "^3.0.0", "pixelmatch": "^4.0.2", "tinycolor2": "^1.6.0" } }, "sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA=="], + + "@jimp/custom": ["@jimp/custom@0.22.12", "", { "dependencies": { "@jimp/core": "^0.22.12" } }, "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q=="], + + "@jimp/gif": ["@jimp/gif@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "gifwrap": "^0.10.1", "omggif": "^1.0.9" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg=="], + + "@jimp/jpeg": ["@jimp/jpeg@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "jpeg-js": "^0.4.4" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-SWVXx1yiuj5jZtMijqUfvVOJBwOifFn0918ou4ftoHgegc5aHWW5dZbYPjvC9fLpvz7oSlptNl2Sxr1zwofjTg=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blit": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5", "@jimp/plugin-scale": ">=0.3.5" } }, "sha512-Eo3DmfixJw3N79lWk8q/0SDYbqmKt1xSTJ69yy8XLYQj9svoBbyRpSnHR+n9hOw5pKXytHwUW6nU4u1wegHNoQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-crop": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5", "@jimp/plugin-scale": ">=0.3.5" } }, "sha512-z0w/1xH/v/knZkpTNx+E8a7fnasQ2wHG5ze6y5oL2dhH1UufNua8gLQXlv8/W56+4nJ1brhSd233HBJCo01BXA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-qpRM8JRicxfK6aPPqKZA6+GzBwUIitiHaZw0QrJ64Ygd3+AsTc7BXr+37k2x7QcyCvmKXY4haUrSIsBug4S3CA=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-jYgGdSdSKl1UUEanX8A85v4+QUm+PE8vHFwlamaKk89s+PXQe7eVE3eNeSZX4inCq63EHL7cX580dMqkoC3ZLw=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-LGuUTsFg+fOp6KBKrmLkX4LfyCy8IIsROwoUvsUPKzutSqMJnsm3JGDW2eOmWIS/jJpPaeaishjlxvczjgII+Q=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-rotate": ">=0.3.5" } }, "sha512-m251Rop7GN8W0Yo/rF9LWk6kNclngyjIJs/VXHToGQ6EGveOSTSQaX2Isi9f9lCDLxt+inBIb7nlaLLxnvHX8Q=="], + + "@jimp/plugin-gaussian": ["@jimp/plugin-gaussian@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-sBfbzoOmJ6FczfG2PquiK84NtVGeScw97JsCC3rpQv1PHVWyW+uqWFF53+n3c8Y0P2HWlUjflEla2h/vWShvhg=="], + + "@jimp/plugin-invert": ["@jimp/plugin-invert@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-N+6rwxdB+7OCR6PYijaA/iizXXodpxOGvT/smd/lxeXsZ/empHmFFFJ/FaXcYh19Tm04dGDaXcNF/dN5nm6+xQ=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-4AWZg+DomtpUA099jRV8IEZUfn1wLv6+nem4NRJC7L/82vxzLCgXKTxvNvBcNmJjT9yS1LAAmiJGdWKXG63/NA=="], + + "@jimp/plugin-normalize": ["@jimp/plugin-normalize@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-0So0rexQivnWgnhacX4cfkM2223YdExnJTTy6d06WbkfZk5alHUx8MM3yEzwoCN0ErO7oyqEWRnEkGC+As1FtA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "load-bmfont": "^1.4.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blit": ">=0.3.5" } }, "sha512-c7TnhHlxm87DJeSnwr/XOLjJU/whoiKYY7r21SbuJ5nuH+7a78EW1teOaj5gEr2wYEd7QtkFqGlmyGXY/YclyQ=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blit": ">=0.3.5", "@jimp/plugin-crop": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5" } }, "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA=="], + + "@jimp/plugin-scale": ["@jimp/plugin-scale@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5" } }, "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw=="], + + "@jimp/plugin-shadow": ["@jimp/plugin-shadow@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-blur": ">=0.3.5", "@jimp/plugin-resize": ">=0.3.5" } }, "sha512-FX8mTJuCt7/3zXVoeD/qHlm4YH2bVqBuWQHXSuBK054e7wFRnRnbSLPUqAwSeYP3lWqpuQzJtgiiBxV3+WWwTg=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5", "@jimp/plugin-color": ">=0.8.0", "@jimp/plugin-resize": ">=0.8.0" } }, "sha512-4x5GrQr1a/9L0paBC/MZZJjjgjxLYrqSmWd+e+QfAEPvmRxdRoQ5uKEuNgXnm9/weHQBTnQBQsOY2iFja+XGAw=="], + + "@jimp/plugins": ["@jimp/plugins@0.22.12", "", { "dependencies": { "@jimp/plugin-blit": "^0.22.12", "@jimp/plugin-blur": "^0.22.12", "@jimp/plugin-circle": "^0.22.12", "@jimp/plugin-color": "^0.22.12", "@jimp/plugin-contain": "^0.22.12", "@jimp/plugin-cover": "^0.22.12", "@jimp/plugin-crop": "^0.22.12", "@jimp/plugin-displace": "^0.22.12", "@jimp/plugin-dither": "^0.22.12", "@jimp/plugin-fisheye": "^0.22.12", "@jimp/plugin-flip": "^0.22.12", "@jimp/plugin-gaussian": "^0.22.12", "@jimp/plugin-invert": "^0.22.12", "@jimp/plugin-mask": "^0.22.12", "@jimp/plugin-normalize": "^0.22.12", "@jimp/plugin-print": "^0.22.12", "@jimp/plugin-resize": "^0.22.12", "@jimp/plugin-rotate": "^0.22.12", "@jimp/plugin-scale": "^0.22.12", "@jimp/plugin-shadow": "^0.22.12", "@jimp/plugin-threshold": "^0.22.12", "timm": "^1.6.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-yBJ8vQrDkBbTgQZLty9k4+KtUQdRjsIDJSPjuI21YdVeqZxYywifHl4/XWILoTZsjTUASQcGoH0TuC0N7xm3ww=="], + + "@jimp/png": ["@jimp/png@0.22.12", "", { "dependencies": { "@jimp/utils": "^0.22.12", "pngjs": "^6.0.0" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg=="], + + "@jimp/tiff": ["@jimp/tiff@0.22.12", "", { "dependencies": { "utif2": "^4.0.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg=="], + + "@jimp/types": ["@jimp/types@0.22.12", "", { "dependencies": { "@jimp/bmp": "^0.22.12", "@jimp/gif": "^0.22.12", "@jimp/jpeg": "^0.22.12", "@jimp/png": "^0.22.12", "@jimp/tiff": "^0.22.12", "timm": "^1.6.1" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, "sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA=="], + + "@jimp/utils": ["@jimp/utils@0.22.12", "", { "dependencies": { "regenerator-runtime": "^0.13.3" } }, "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -183,6 +244,24 @@ "@npmcli/move-file": ["@npmcli/move-file@2.0.1", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ=="], + "@nut-tree-fork/default-clipboard-provider": ["@nut-tree-fork/default-clipboard-provider@4.2.6", "", { "dependencies": { "clipboardy": "2.3.0" } }, "sha512-Hzqj57rheIMGtsS4zK4//kOhaX5FxMluOiz+4TVaHXx+idZS/bPhZwd8e6o1w1GT0PVJOUIP+4CdUe//k5VRig=="], + + "@nut-tree-fork/libnut": ["@nut-tree-fork/libnut@4.2.6", "", { "dependencies": { "@nut-tree-fork/libnut-darwin": "2.7.5", "@nut-tree-fork/libnut-linux": "2.7.5", "@nut-tree-fork/libnut-win32": "2.7.5" } }, "sha512-2FCiTBokMGrMl4eL/trEIO+mtpkXpdPHoVKdTBmW8UBIbhCbrCKmnXb2skWGfVs+U3q7o5EYDjVTNUYaUWbaxQ=="], + + "@nut-tree-fork/libnut-darwin": ["@nut-tree-fork/libnut-darwin@2.7.5", "", { "dependencies": { "bindings": "1.5.0" }, "optionalDependencies": { "@nut-tree-fork/node-mac-permissions": "2.2.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-LbqtPtMPTJUcg4XoPP2jsU1wc8flBcGyKTerKsIfK9cD7nBHROnO0QksbrsbSWEpLym8T8fRtuU7XEY83l6Z2Q=="], + + "@nut-tree-fork/libnut-linux": ["@nut-tree-fork/libnut-linux@2.7.5", "", { "dependencies": { "bindings": "1.5.0" }, "optionalDependencies": { "@nut-tree-fork/node-mac-permissions": "2.2.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-uxaXEcRKnFObAljsoR6tLOBUU1dJ2sctloG6gFgCBGN7+k6Jdv6jZfOuNjd/fpdq2C5WPMm0rtn9EE7h5J3Jcg=="], + + "@nut-tree-fork/libnut-win32": ["@nut-tree-fork/libnut-win32@2.7.5", "", { "dependencies": { "bindings": "1.5.0" }, "optionalDependencies": { "@nut-tree-fork/node-mac-permissions": "2.2.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-yqC87zvmFcDPwFrRU40DYhN0xmEVM3aSkOuyF0IX+y1x+HWSu/i0PNklATpPBhGid3QVb/TOHuVoaraMrUFCNw=="], + + "@nut-tree-fork/node-mac-permissions": ["@nut-tree-fork/node-mac-permissions@2.2.1", "", { "dependencies": { "bindings": "1.5.0", "node-addon-api": "5.0.0" }, "os": "darwin" }, "sha512-iSfOTDiBZ7VDa17PoQje5rUaZSvSAaq+XEyXCmhPuQwV5XuNU02Grv6oFhsdpz89w7+UvB/8KX/cX5IYQ5o2Bw=="], + + "@nut-tree-fork/nut-js": ["@nut-tree-fork/nut-js@4.2.6", "", { "dependencies": { "@nut-tree-fork/default-clipboard-provider": "4.2.6", "@nut-tree-fork/libnut": "4.2.6", "@nut-tree-fork/provider-interfaces": "4.2.6", "@nut-tree-fork/shared": "4.2.6", "jimp": "0.22.10", "node-abort-controller": "3.1.1" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-aI/WCX7gE1HFGPH3EZP/UWqpNMM1NMoM/EkXqp7pKMgXFCi8e5+o5p+jd/QOYpmALv9bQg7+s69nI7FONbMqDg=="], + + "@nut-tree-fork/provider-interfaces": ["@nut-tree-fork/provider-interfaces@4.2.6", "", { "dependencies": { "@nut-tree-fork/shared": "4.2.6" } }, "sha512-brtRegDkLSV0sa5DUAigjWf6hCoamBNPb/hKK9AQlW+j3BxQ/8djaEdEB2cihqUh1ZjEtgPyXRqpCWSdKCX68A=="], + + "@nut-tree-fork/shared": ["@nut-tree-fork/shared@4.2.6", "", { "dependencies": { "jimp": "0.22.10", "node-abort-controller": "3.1.1" } }, "sha512-xZaa0YtJt/DDDq/i1vZkabjq8HOWzfhXieMai61cMbYD11J6VhAfhV23ZtQEM02WG7nc2LKjl4UwRnQCteikwA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -241,6 +320,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -279,8 +360,6 @@ "@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/type-utils": "8.58.1", "@typescript-eslint/utils": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ=="], @@ -309,6 +388,8 @@ "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -327,12 +408,16 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + "app-builder-bin": ["app-builder-bin@5.0.0-alpha.10", "", {}, "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw=="], "app-builder-lib": ["app-builder-lib@25.1.8", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.1", "@electron/rebuild": "3.6.1", "@electron/universal": "2.0.1", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "bluebird-lst": "^1.0.9", "builder-util": "25.1.7", "builder-util-runtime": "9.2.10", "chromium-pickle-js": "^0.2.0", "config-file-ts": "0.2.8-rc1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "25.1.7", "form-data": "^4.0.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "is-ci": "^3.0.0", "isbinaryfile": "^5.0.0", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.0", "resedit": "^1.7.0", "sanitize-filename": "^1.6.3", "semver": "^7.3.8", "tar": "^6.1.12", "temp-file": "^3.4.0" }, "peerDependencies": { "dmg-builder": "25.1.8", "electron-builder-squirrel-windows": "25.1.8" } }, "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg=="], "aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="], + "arch": ["arch@2.2.0", "", {}, "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ=="], + "archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="], "archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="], @@ -361,12 +446,16 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], "bluebird-lst": ["bluebird-lst@1.0.9", "", { "dependencies": { "bluebird": "^3.5.5" } }, "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw=="], + "bmp-js": ["bmp-js@0.1.0", "", {}, "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], @@ -377,6 +466,8 @@ "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "buffer-equal": ["buffer-equal@0.0.1", "", {}, "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "builder-util": ["builder-util@25.1.7", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.10", "bluebird-lst": "^1.0.9", "builder-util-runtime": "9.2.10", "chalk": "^4.1.2", "cross-spawn": "^7.0.3", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "is-ci": "^3.0.0", "js-yaml": "^4.1.0", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0" } }, "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww=="], @@ -395,6 +486,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], + "centra": ["centra@2.7.0", "", { "dependencies": { "follow-redirects": "^1.15.6" } }, "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], @@ -411,6 +504,8 @@ "cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], + "clipboardy": ["clipboardy@2.3.0", "", { "dependencies": { "arch": "^2.1.1", "execa": "^1.0.0", "is-wsl": "^2.1.1" } }, "sha512-mKhiIL2DrQIsuXMgBgnfEHOZOryC7kY7YO//TN6c63wlEm3NG5tz+YgY5rVi29KCmq/QQjKYvM7a19+MDOTHOQ=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], @@ -481,6 +576,8 @@ "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], + "dom-walk": ["dom-walk@0.1.2", "", {}, "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], @@ -543,6 +640,14 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "execa": ["execa@1.0.0", "", { "dependencies": { "cross-spawn": "^6.0.0", "get-stream": "^4.0.0", "is-stream": "^1.1.0", "npm-run-path": "^2.0.0", "p-finally": "^1.0.0", "signal-exit": "^3.0.0", "strip-eof": "^1.0.0" } }, "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], @@ -561,6 +666,10 @@ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -599,10 +708,14 @@ "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -647,6 +760,8 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -663,28 +778,42 @@ "is-ci": ["is-ci@3.0.1", "", { "dependencies": { "ci-info": "^3.2.0" }, "bin": { "is-ci": "bin.js" } }, "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ=="], + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-function": ["is-function@1.0.2", "", {}, "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], + "is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic-fetch": ["isomorphic-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jimp": ["jimp@0.22.10", "", { "dependencies": { "@jimp/custom": "^0.22.10", "@jimp/plugins": "^0.22.10", "@jimp/types": "^0.22.10", "regenerator-runtime": "^0.13.3" } }, "sha512-lCaHIJAgTOsplyJzC1w/laxSxrbSsEBw4byKwXgUdMmh+ayPsnidTblenQm+IvhIs44Gcuvlb6pd2LQ0wcKaKg=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -711,6 +840,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "load-bmfont": ["load-bmfont@1.4.2", "", { "dependencies": { "buffer-equal": "0.0.1", "mime": "^1.3.4", "parse-bmfont-ascii": "^1.0.3", "parse-bmfont-binary": "^1.0.5", "parse-bmfont-xml": "^1.1.4", "phin": "^3.7.1", "xhr": "^2.0.1", "xtend": "^4.0.0" } }, "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -749,6 +880,8 @@ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "min-document": ["min-document@2.19.2", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -777,12 +910,18 @@ "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + "nice-try": ["nice-try@1.0.5", "", {}, "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="], + "node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="], + "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="], + "node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-gyp": ["node-gyp@9.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.0.3", "nopt": "^6.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ=="], "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], @@ -793,10 +932,14 @@ "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], + "npm-run-path": ["npm-run-path@2.0.2", "", { "dependencies": { "path-key": "^2.0.0" } }, "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw=="], + "npmlog": ["npmlog@6.0.2", "", { "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", "gauge": "^4.0.3", "set-blocking": "^2.0.0" } }, "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg=="], "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -807,6 +950,8 @@ "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -815,8 +960,18 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + + "parse-headers": ["parse-headers@2.0.6", "", {}, "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], @@ -827,20 +982,30 @@ "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "phin": ["phin@3.7.1", "", { "dependencies": { "centra": "^2.7.0" } }, "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pixelmatch": ["pixelmatch@4.0.2", "", { "dependencies": { "pngjs": "^3.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], "posthog-node": ["posthog-node@4.18.0", "", { "dependencies": { "axios": "^1.8.2" } }, "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -867,8 +1032,12 @@ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], @@ -915,7 +1084,7 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], @@ -949,8 +1118,12 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-eof": ["strip-eof@1.0.0", "", {}, "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -961,12 +1134,20 @@ "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + "timm": ["timm@1.7.1", "", {}, "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], @@ -995,6 +1176,8 @@ "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], @@ -1005,6 +1188,12 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], @@ -1017,10 +1206,16 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "xhr": ["xhr@2.6.0", "", { "dependencies": { "global": "~4.4.0", "is-function": "^1.0.1", "parse-headers": "^2.0.0", "xtend": "^4.0.0" } }, "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA=="], + + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -1065,6 +1260,8 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@nut-tree-fork/node-mac-permissions/node-addon-api": ["node-addon-api@5.0.0", "", {}, "sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA=="], + "@types/fs-extra/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -1101,11 +1298,15 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "execa/cross-spawn": ["cross-spawn@6.0.6", "", { "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw=="], + + "execa/get-stream": ["get-stream@4.1.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w=="], + "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "gauge/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1113,8 +1314,12 @@ "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "load-bmfont/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], @@ -1139,13 +1344,17 @@ "node-gyp/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "npm-run-path/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "pixelmatch/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], + + "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -1157,6 +1366,8 @@ "temp-file/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], @@ -1181,6 +1392,14 @@ "cacache/glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], + + "execa/cross-spawn/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + + "execa/cross-spawn/shebang-command": ["shebang-command@1.2.0", "", { "dependencies": { "shebang-regex": "^1.0.0" } }, "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg=="], + + "execa/cross-spawn/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], @@ -1191,6 +1410,8 @@ "make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], "zip-stream/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -1200,5 +1421,7 @@ "app-builder-lib/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], + + "execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], } } diff --git a/package.json b/package.json index 42b1004..9311bc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flicky", - "version": "1.2.0", + "version": "1.2.1", "description": "Cross-platform AI companion — voice-driven, screen-aware assistant with a pointing cursor overlay", "main": "dist/main/main/index.js", "author": { @@ -26,11 +26,10 @@ "postinstall": "electron-builder install-app-deps" }, "dependencies": { - "posthog-node": "^4.0.0", - "ws": "^8.18.0" + "@nut-tree-fork/nut-js": "^4.2.6", + "posthog-node": "^4.0.0" }, "devDependencies": { - "@types/ws": "^8.5.0", "@electron/notarize": "^2.5.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -62,8 +61,14 @@ "category": "public.app-category.productivity", "icon": "assets/icon.icns", "target": [ - { "target": "dmg", "arch": ["x64", "arm64"] }, - { "target": "zip", "arch": ["x64", "arm64"] } + { + "target": "dmg", + "arch": ["x64", "arm64"] + }, + { + "target": "zip", + "arch": ["x64", "arm64"] + } ], "hardenedRuntime": true, "gatekeeperAssess": false, @@ -78,7 +83,10 @@ "win": { "icon": "assets/icon.ico", "target": [ - { "target": "nsis", "arch": ["x64", "arm64"] } + { + "target": "nsis", + "arch": ["x64", "arm64"] + } ] }, "nsis": { diff --git a/src/main/companion-manager.ts b/src/main/companion-manager.ts index 9139c8e..f75b4b5 100644 --- a/src/main/companion-manager.ts +++ b/src/main/companion-manager.ts @@ -1,11 +1,12 @@ -import { app, systemPreferences, shell, desktopCapturer } from 'electron'; +import { app, systemPreferences, shell, desktopCapturer, clipboard } from 'electron'; import { ClaudeAPI } from './services/claude-api'; import { OpenAIAPI } from './services/openai-api'; import { OllamaAPI } from './services/ollama-api'; import { ElevenLabsTTS } from './services/elevenlabs-tts'; import { createTranscriptionProvider, type TranscriptionProvider } from './services/transcription'; import { captureAllDisplays } from './services/screen-capture'; -import { parsePointTags } from './services/element-detector'; +import { parseAllPointTags, parseTypeTags, TAG_STRIP_REGEX } from './services/element-detector'; +import { typeText, isAccessibilityGranted, promptAccessibility } from './services/auto-typer'; import { ContextManager } from './services/context-manager'; import * as settingsStore from './services/settings-store'; import * as keyStore from './services/key-store'; @@ -19,7 +20,9 @@ import type { MindProvider, GroqTranscriptionModel, TranscriptionResult, - DetectedElement, + Walkthrough, + PttMode, + TypeRequest, ScreenCapture, ApiKeyName, ReasoningDepth, @@ -28,6 +31,7 @@ import type { ChatEntry, StreamVisibility, StreamWindowBounds, + PermissionStatus, } from '../shared/types'; export interface CompanionCallbacks { @@ -35,7 +39,17 @@ export interface CompanionCallbacks { onTranscriptUpdate: (result: TranscriptionResult) => void; onAiResponseChunk: (chunk: string) => void; onAiResponseComplete: (fullText: string) => void; - onElementDetected: (element: DetectedElement | null) => void; + /** + * A turn failed somewhere in mic → transcription → model → TTS. + * Previously these only went to the console, which on a packaged + * Windows build means nobody ever saw them — the app just went + * quiet. Surfaced to the panel / stream so the user learns *why*. + */ + onError: (message: string) => void; + onWalkthrough: (walkthrough: Walkthrough | null) => void; + /** Active step index (0-based) inside the current walkthrough, or null when idle. */ + onWalkthroughStep: (index: number | null) => void; + onTypeFulfilled: (request: TypeRequest) => void; onSettingsChanged: (settings: FlickySettings) => void; onMemoryStatsChanged: (stats: MemoryStats) => void; onChatEntryAdded: (entry: ChatEntry) => void; @@ -59,6 +73,12 @@ export class CompanionManager { private voiceState: VoiceState = 'idle'; private lastScreenshots: ScreenCapture[] = []; private isRecording = false; + + /** Public read-only view used by main's PTT handler to keep its + * toggle state in sync after a failed start. */ + get recording(): boolean { + return this.isRecording; + } private reRegisterShortcut: ((accel: string) => boolean) | null = null; /** * Monotonic turn counter. A new PTT press bumps this; any still-running @@ -67,6 +87,8 @@ export class CompanionManager { */ private turnId = 0; private currentAbort: AbortController | null = null; + /** Pending walkthrough step timers, cleared on new turn or end-of-walkthrough. */ + private walkthroughTimers: ReturnType[] = []; /** * If startRecording is in flight, other callers (typically a quick-release * stopPushToTalk) await this before deciding whether to stop. Without it, @@ -74,6 +96,12 @@ export class CompanionManager { * leave the mic running forever. */ private pendingStart: Promise | null = null; + /** + * Setup's mic check runs capture without a transcription provider. + * While true, audio chunks are dropped here; the overlay still emits + * level events that the panel visualises. + */ + private micTestActive = false; constructor(callbacks: CompanionCallbacks) { this.callbacks = callbacks; @@ -186,6 +214,22 @@ export class CompanionManager { this.emitSettings(); } + setPttMode(mode: PttMode): void { + settingsStore.set('pttMode', mode); + this.emitSettings(); + } + + setAutoTypeEnabled(enabled: boolean): void { + settingsStore.set('autoTypeEnabled', enabled); + // Flipping the toggle on is the right moment to nudge the user + // through the macOS Accessibility prompt — they just expressed + // intent to grant. No-op on other platforms / when already trusted. + if (enabled && !isAccessibilityGranted()) { + promptAccessibility(); + } + this.emitSettings(); + } + setLaunchAtLogin(enabled: boolean): void { settingsStore.set('launchAtLogin', enabled); try { @@ -281,19 +325,45 @@ export class CompanionManager { // ── Permissions ────────────────────────────────────────────────────── - async getPermissions(): Promise> { - const perms: Record = { microphone: false, screen: false }; + async getPermissions(): Promise { + const perms: PermissionStatus = { + microphone: true, + screen: true, + accessibility: true, + microphoneStatus: 'unknown', + }; if (process.platform === 'darwin') { - perms.microphone = systemPreferences.getMediaAccessStatus('microphone') === 'granted'; + const mic = systemPreferences.getMediaAccessStatus('microphone'); + perms.microphoneStatus = mic; + perms.microphone = mic === 'granted'; perms.screen = systemPreferences.getMediaAccessStatus('screen') === 'granted'; - } else { - perms.microphone = true; - perms.screen = true; + perms.accessibility = isAccessibilityGranted(); + } else if (process.platform === 'win32') { + // Windows 10/11 gate desktop-app microphone access under + // Settings → Privacy → Microphone. When it's off, getUserMedia in + // the overlay fails and Flicky silently hears nothing — the #1 + // "it doesn't work" report. Electron exposes the same status + // query on Windows, so surface it. + try { + const mic = systemPreferences.getMediaAccessStatus('microphone'); + perms.microphoneStatus = mic; + perms.microphone = mic === 'granted' || mic === 'not-determined'; + } catch (err) { + console.error('[Flicky] mic status probe failed:', err); + } } return perms; } async requestPermission(kind: string): Promise { + if (process.platform === 'win32') { + if (kind === 'microphone') { + // Deeplink straight to the privacy pane; the toggles there are + // "Microphone access" and "Let desktop apps access your microphone". + void shell.openExternal('ms-settings:privacy-microphone'); + } + return; + } if (process.platform !== 'darwin') return; if (kind === 'microphone') { @@ -310,6 +380,18 @@ export class CompanionManager { return; } + if (kind === 'accessibility') { + // Calling with `true` adds Flicky to the Accessibility list and + // surfaces the OS dialog. The user still has to flip the checkbox + // themselves; we deeplink to the right pane in case the dialog + // got dismissed. + promptAccessibility(); + shell.openExternal( + 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', + ); + return; + } + if (kind === 'screen') { const status = systemPreferences.getMediaAccessStatus('screen'); if (status === 'not-determined') { @@ -360,6 +442,62 @@ export class CompanionManager { await this.stopRecordingAndProcess(); } + // ── Mic check (setup) ──────────────────────────────────────────────── + + startMicTest(): void { + if (this.isRecording || this.micTestActive) return; + this.micTestActive = true; + this.callbacks.onStartAudioCapture(); + } + + stopMicTest(): void { + if (!this.micTestActive) return; + this.micTestActive = false; + // Don't yank the mic out from under a real PTT turn that started + // while the test was running. + if (!this.isRecording) this.callbacks.onStopAudioCapture(); + } + + private clearWalkthroughTimers(): void { + for (const t of this.walkthroughTimers) clearTimeout(t); + this.walkthroughTimers = []; + } + + /** + * Schedule a walkthrough so overlay + stream + any other surface stay + * in lockstep. Emits the full step list once, then a step index per + * step at computed times, then clears with `null` after the last step. + * + * Per-step dwell scales with caption length so longer instructions + * stay on screen long enough to read; floor of 2.6s, ceiling of 5.5s. + */ + private startWalkthrough(walkthrough: Walkthrough, isCurrent: () => boolean): void { + this.clearWalkthroughTimers(); + this.callbacks.onWalkthrough(walkthrough); + + const dwellFor = (label: string): number => + Math.max(2600, Math.min(5500, 1800 + label.length * 80)); + + let cursor = 0; + walkthrough.steps.forEach((step, i) => { + const start = cursor; + const t = setTimeout(() => { + if (!isCurrent()) return; + this.callbacks.onWalkthroughStep(i); + }, start); + this.walkthroughTimers.push(t); + cursor += dwellFor(step.label); + }); + + // After the last step has had its dwell, clear the walkthrough. + const endTimer = setTimeout(() => { + if (!isCurrent()) return; + this.callbacks.onWalkthroughStep(null); + this.callbacks.onWalkthrough(null); + }, cursor); + this.walkthroughTimers.push(endTimer); + } + private async startRecording(): Promise { // Bump the turn and abort any in-flight work from the previous one // so the user's new message supersedes whatever Flicky was doing. @@ -368,6 +506,9 @@ export class CompanionManager { this.currentAbort.abort(); this.currentAbort = null; } + this.clearWalkthroughTimers(); + this.callbacks.onWalkthrough(null); + this.callbacks.onWalkthroughStep(null); this.isRecording = true; this.setVoiceState('listening'); @@ -382,11 +523,16 @@ export class CompanionManager { try { await this.transcriptionProvider.start(); + // A setup mic check may already hold the capture open; starting + // again is harmless (the overlay just re-opens the gate). + this.micTestActive = false; this.callbacks.onStartAudioCapture(); } catch (err) { console.error('Failed to start transcription:', err); this.setVoiceState('idle'); this.isRecording = false; + this.transcriptionProvider = null; + this.callbacks.onError(err instanceof Error ? err.message : String(err)); } } @@ -400,7 +546,21 @@ export class CompanionManager { return; } - const result = await this.transcriptionProvider.stop(); + // A failed upload (bad Groq key, offline, 4xx) used to throw straight + // out of here — nothing caught it, so the voice state stayed stuck on + // 'listening' and the next PTT press did nothing. Contain it. + let result: TranscriptionResult; + try { + result = await this.transcriptionProvider.stop(); + } catch (err) { + console.error('Transcription failed:', err); + this.transcriptionProvider = null; + this.setVoiceState('idle'); + this.callbacks.onError( + `couldn't transcribe that — ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } this.transcriptionProvider = null; if (!result.text.trim()) { @@ -418,6 +578,35 @@ export class CompanionManager { console.error('Screen capture failed:', err); this.lastScreenshots = []; } + if (this.lastScreenshots.length === 0) { + // Almost always means Screen Recording permission is missing on + // macOS — desktopCapturer returns empty thumbnails in that case. + // Surface a friendly response instead of letting an empty image + // 400 the upstream LLM call. Synthesize TTS too so the user + // hears the error even if their attention is on a different + // window than the panel. + const settings = settingsStore.getAll(); + const msg = process.platform === 'darwin' + ? "i can't see your screen right now — give flicky screen recording permission in system settings, then quit and reopen the app." + : "i can't see your screen right now — screen capture failed."; + this.callbacks.onAiResponseChunk(msg); + this.callbacks.onAiResponseComplete(msg); + if (settings.speakReplies && keyStore.getKeyStatus().elevenlabs) { + this.setVoiceState('responding'); + try { + const audioBuffer = await this.tts.synthesize(msg, { + voiceId: settings.voiceId, + speed: settings.voiceSpeed, + stability: settings.voiceStability, + }); + this.callbacks.onPlayAudio(audioBuffer); + } catch (err) { + console.error('TTS error on screen-capture failure path:', err); + } + } + this.setVoiceState('idle'); + return; + } const settings = settingsStore.getAll(); const myTurnId = this.turnId; @@ -447,7 +636,7 @@ export class CompanionManager { if (!isCurrent()) return; analytics.trackAiResponseReceived(fullText); - const cleanText = fullText.replace(/\[POINT:[^\]]+\]/g, '').trim(); + const cleanText = fullText.replace(TAG_STRIP_REGEX, '').trim(); this.callbacks.onAiResponseComplete(cleanText); await this.context.recordExchange(result.text, cleanText, { @@ -463,10 +652,40 @@ export class CompanionManager { }); this.callbacks.onChatEntryAdded(entry); - const element = parsePointTags(fullText, this.lastScreenshots); - if (element) { - this.callbacks.onElementDetected(element); - analytics.trackElementPointed(element.label); + const walkthrough = parseAllPointTags(fullText, this.lastScreenshots); + if (walkthrough) { + console.log( + `[Flicky] Walkthrough: ${walkthrough.steps.length} step(s) →`, + walkthrough.steps.map((s) => `${s.step}/${s.total} "${s.label}"`).join(', '), + ); + this.startWalkthrough(walkthrough, isCurrent); + analytics.trackElementPointed( + walkthrough.steps.length > 1 + ? `${walkthrough.steps[0].label} (+${walkthrough.steps.length - 1} more)` + : walkthrough.steps[0].label, + ); + } + + // [TYPE:...] tags. If the user has opted into auto-typing AND + // the OS permission is in place, we send the keys directly via + // the native typer; otherwise we fall back to clipboard handoff. + // typeText() returns false on any failure so the user is never + // left with no way to act on the request. + const typeTexts = parseTypeTags(fullText); + for (const text of typeTexts) { + if (!text) continue; + const preview = text.length > 50 ? `${text.slice(0, 50)}…` : text; + let autoTyped = false; + if (settings.autoTypeEnabled) { + autoTyped = await typeText(text); + } + if (!autoTyped) { + clipboard.writeText(text); + } + console.log( + `[Flicky] Type request → ${autoTyped ? 'auto-typed' : 'clipboard'}: "${preview}"`, + ); + this.callbacks.onTypeFulfilled({ text, preview, autoTyped }); } if (settings.speakReplies && keyStore.getKeyStatus().elevenlabs) { @@ -489,15 +708,13 @@ export class CompanionManager { if (!isCurrent()) return; this.setVoiceState('idle'); - setTimeout(() => { - if (isCurrent()) this.callbacks.onElementDetected(null); - }, 6000); }, onError: (err: Error) => { if (!isCurrent()) return; console.error('Mind provider error:', err); analytics.trackResponseError(err.message); this.setVoiceState('idle'); + this.callbacks.onError(err.message); }, }; @@ -559,6 +776,7 @@ export class CompanionManager { } handleAudioChunk(buffer: Buffer): void { + if (!this.isRecording) return; this.transcriptionProvider?.sendAudio(buffer); } diff --git a/src/main/index.ts b/src/main/index.ts index 6516d57..e907e35 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,12 +1,18 @@ import { app, BrowserWindow, Tray, Menu, globalShortcut, screen, ipcMain, shell, nativeImage } from 'electron'; import path from 'path'; import { CompanionManager } from './companion-manager'; -import { createPanelWindow, createOverlayWindow, createStreamWindow } from './windows'; +import { + createPanelWindow, + createOverlayWindow, + createStreamWindow, + overlayDisplayByWebContents, +} from './windows'; import { IPC, type StreamVisibility, type StreamWindowBounds, type LocalConnection } from '../shared/types'; import { AUDIO_IPC } from './services/audio-capture'; import * as chatHistory from './services/chat-history-store'; import * as settingsStore from './services/settings-store'; import { setApiKey, getApiKey, deleteApiKey } from './services/key-store'; +import { validateApiKey, validateStoredApiKey } from './services/key-validation'; import { OllamaAPI } from './services/ollama-api'; import { initGpuGuard, confirmGpuHealthy } from './services/gpu-guard'; import { randomUUID } from 'crypto'; @@ -16,6 +22,20 @@ const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.quit(); } +// Flicky lives in the tray, so on Windows the natural thing to do when +// you can't find it is to double-click the shortcut again. Without this +// handler that second launch just exited and nothing visible happened — +// which reads as "the app is broken". Surface the panel instead. +app.on('second-instance', () => { + if (!companion) return; + if (panelWindow && !panelWindow.isDestroyed()) { + if (panelWindow.isMinimized()) panelWindow.restore(); + panelWindow.show(); + panelWindow.focus(); + } else { + togglePanel(); + } +}); // Must run before the app is ready: the switches it may set only apply // pre-ready, and the GPU failure it counts happens during startup, so @@ -32,6 +52,27 @@ let streamWindow: BrowserWindow | null = null; let companion: CompanionManager; let isAppQuitting = false; let lastVoiceState = 'idle'; +/** Whether a walkthrough is currently playing (steps 1..N animating). */ +let walkthroughActive = false; +/** webContents.id of the overlay receiving the active walkthrough. */ +let currentWalkthroughTargetWcId: number | null = null; +/** Perms-poll lifecycle, controlled by panel visibility. */ +let permsTimer: ReturnType | null = null; +const startPermsPoll = (): void => { + if (permsTimer) return; + const tick = async (): Promise => { + if (!companion) return; + const perms = await companion.getPermissions(); + sendToPanel(IPC.PERMISSION_STATUS, perms); + }; + void tick(); + permsTimer = setInterval(() => { void tick(); }, 5000); +}; +const stopPermsPoll = (): void => { + if (!permsTimer) return; + clearInterval(permsTimer); + permsTimer = null; +}; app.on('before-quit', () => { isAppQuitting = true; }); @@ -45,9 +86,15 @@ function createTrayIcon(): Electron.NativeImage { const size32 = path.join(assetRoot, 'icons', '32x32.png'); const size16 = path.join(assetRoot, 'icons', '16x16.png'); - const primary = process.platform === 'darwin' ? size32 : size16; - try { + // Windows renders tray icons from a multi-size .ico crisply at any + // DPI; a 16px PNG gets upscaled and blurry at 125%/150% scaling. + if (process.platform === 'win32') { + const ico = nativeImage.createFromPath(path.join(assetRoot, 'icon.ico')); + if (!ico.isEmpty()) return ico; + } + + const primary = process.platform === 'darwin' ? size32 : size16; const img = nativeImage.createFromPath(primary); if (img.isEmpty()) throw new Error('empty tray icon image'); @@ -98,6 +145,35 @@ function sendToOverlays(channel: string, ...args: unknown[]): void { } } +/** + * Pick a single overlay to receive an event. Used for things that must + * not duplicate across displays — TTS audio playback being the canonical + * case (broadcasting to all overlays plays the buffer once per display + * and audibly doubles). + */ +function sendToOneOverlay(channel: string, ...args: unknown[]): void { + const target = overlayWindows.find((w) => !w.isDestroyed()); + if (target) target.webContents.send(channel, ...args); +} + +function sendToOverlayById(wcId: number, channel: string, ...args: unknown[]): void { + const target = overlayWindows.find((w) => !w.isDestroyed() && w.webContents.id === wcId); + if (target) target.webContents.send(channel, ...args); +} + +function findOverlayContainingPoint(pos: { x: number; y: number }): BrowserWindow | undefined { + return overlayWindows.find((w) => { + if (w.isDestroyed()) return false; + const display = overlayDisplayByWebContents.get(w.webContents.id); + if (!display) return false; + const b = display.bounds; + return ( + pos.x >= b.x && pos.x < b.x + b.width && + pos.y >= b.y && pos.y < b.y + b.height + ); + }); +} + function sendToStream(channel: string, ...args: unknown[]): void { if (streamWindow && !streamWindow.isDestroyed()) { streamWindow.webContents.send(channel, ...args); @@ -131,13 +207,67 @@ app.whenReady().then(() => { sendToPanel(IPC.AI_RESPONSE_COMPLETE, text); sendToStream(IPC.AI_RESPONSE_COMPLETE, text); }, - onElementDetected: (el) => sendToOverlays(IPC.ELEMENT_DETECTED, el), + onError: (message) => { + sendToPanel(IPC.AI_ERROR, message); + sendToStream(IPC.AI_ERROR, message); + }, + onWalkthrough: (w) => { + walkthroughActive = !!w; + // The walkthrough plays on exactly one display — the cursor + // display at the time of capture. Compute that target overlay + // up front and only send WALKTHROUGH / WALKTHROUGH_STEP to it. + // The other overlays would have ignored the events anyway via + // their `isStepOnThisDisplay` check; skipping the IPC saves + // wakeups on idle screens. + if (w && w.steps.length > 0) { + const first = w.steps[0]; + const target = findOverlayContainingPoint({ x: first.x, y: first.y }); + currentWalkthroughTargetWcId = target?.webContents.id ?? null; + } else { + currentWalkthroughTargetWcId = null; + } + if (currentWalkthroughTargetWcId !== null) { + sendToOverlayById(currentWalkthroughTargetWcId, IPC.WALKTHROUGH, w); + } else if (!w) { + // On clear with no known target, broadcast so any overlay holding + // stale walkthrough state resets cleanly. + sendToOverlays(IPC.WALKTHROUGH, w); + } + sendToStream(IPC.WALKTHROUGH, w); + // Keep the stream visible across the walkthrough in 'responses' mode, + // even after voice state has returned to idle. When the walkthrough + // ends we re-evaluate based on the current voice state. + if (w) applyStreamVisibility(companion.getSettings().streamVisibility); + else updateStreamForVoiceState(lastVoiceState); + }, + onWalkthroughStep: (i) => { + if (currentWalkthroughTargetWcId !== null) { + sendToOverlayById(currentWalkthroughTargetWcId, IPC.WALKTHROUGH_STEP, i); + } + sendToStream(IPC.WALKTHROUGH_STEP, i); + // The walkthrough scheduler emits step `null` after the last step + // dwell, just before emitting walkthrough(null). Clear the + // active flag here too so a status reader doesn't briefly observe + // walkthroughActive=true with no current step. + if (i === null) walkthroughActive = false; + }, + onTypeFulfilled: (req) => { + // Toast goes on a single overlay (cursor display) so the user + // sees one notification, not one per monitor. + sendToOneOverlay(IPC.TYPE_FULFILLED, req); + sendToStream(IPC.TYPE_FULFILLED, req); + }, onSettingsChanged: (s) => sendToPanel(IPC.SETTINGS_CHANGED, s), onMemoryStatsChanged: (stats) => sendToPanel(IPC.MEMORY_STATS, stats), onChatEntryAdded: (entry) => sendToPanel(IPC.CHAT_ENTRY_ADDED, entry), - onStartAudioCapture: () => sendToOverlays(AUDIO_IPC.START_CAPTURE), - onStopAudioCapture: () => sendToOverlays(AUDIO_IPC.STOP_CAPTURE), - onPlayAudio: (buf) => sendToOverlays('play-audio', buf), + // Mic capture must NEVER fan out across overlays — each overlay + // would open its own getUserMedia + AudioContext and stream chunks + // back, which on a multi-monitor setup made companion-manager append + // the same audio N times into one buffer. Whisper then transcribed + // an interleaved mess. Single overlay only. + onStartAudioCapture: () => sendToOneOverlay(AUDIO_IPC.START_CAPTURE), + onStopAudioCapture: () => sendToOneOverlay(AUDIO_IPC.STOP_CAPTURE), + onPlayAudio: (buf) => sendToOneOverlay('play-audio', buf), onCursorVisibilityChanged: (enabled) => applyOverlayVisibility(enabled), onStreamVisibilityChanged: (v) => applyStreamVisibility(v), }); @@ -159,80 +289,123 @@ app.whenReady().then(() => { ]), ); - // Create overlay windows for each display + // Create overlay windows for each display. Topology changes diff + // against the existing set so plugging in one new monitor doesn't + // tear down and rebuild every overlay (each rebuild has to reload + // the renderer bundle from scratch). rebuildOverlays(); - screen.on('display-added', rebuildOverlays); - screen.on('display-removed', rebuildOverlays); + screen.on('display-added', () => syncOverlaysToDisplays()); + screen.on('display-removed', () => syncOverlaysToDisplays()); + // Resolution / DPI / arrangement changes (docking a laptop, changing + // scaling in Settings) keep the same display ids but move the bounds. + // Without this the overlay stayed at the stale rect, so the blue + // cursor pointed at the wrong spot or lived off-screen entirely. + screen.on('display-metrics-changed', (_e, display, changed) => { + if (!changed.some((c) => c === 'bounds' || c === 'scaleFactor' || c === 'workArea')) return; + syncOverlayBounds(display); + }); - // Create the transparent stream window (hidden until the user opts in). - { - const settings = companion.getSettings(); - streamWindow = createStreamWindow(settings.streamWindowBounds); - streamWindow.on('close', (e) => { - // Don't let the user actually close the stream — just hide it and - // flip the setting off so the toggle in General reflects reality. - if (!isAppQuitting) { - e.preventDefault(); - streamWindow?.hide(); - companion.setStreamVisibility('off'); - } - }); - streamWindow.on('moved', persistStreamBounds); - streamWindow.on('resized', persistStreamBounds); - applyStreamVisibility(settings.streamVisibility); - } + // Stream window is created lazily — the default `streamVisibility:'off'` + // means a fresh-install user used to have an entire Chromium renderer + // running in the background just to receive IPC nobody would ever see. + applyStreamVisibility(companion.getSettings().streamVisibility); // Sync the OS login-item state with our stored preference. Handles // the case where the user disables the login item externally (e.g. // via System Settings) — next launch reconciles the two. - try { - app.setLoginItemSettings({ openAtLogin: companion.getSettings().launchAtLogin }); - } catch (err) { - console.error('[Flicky] initial setLoginItemSettings failed:', err); + // Skipped in dev: unpackaged it would register the bare electron + // binary as a startup item. + if (app.isPackaged) { + try { + app.setLoginItemSettings({ openAtLogin: companion.getSettings().launchAtLogin }); + } catch (err) { + console.error('[Flicky] initial setLoginItemSettings failed:', err); + } } // Register global push-to-talk shortcut. // - // Windows/Linux: globalShortcut fires repeatedly on OS key-repeat while - // the accelerator is held, so we debounce — first press starts recording, - // subsequent repeats are ignored, and we stop after no fires for 250 ms - // (meaning the key was released). + // Two modes, chosen by the `pttMode` setting: + // 'hold' — Windows/Linux only. globalShortcut fires repeatedly on + // OS key-repeat while the accelerator is held; we start on + // the first fire and stop after 250 ms of silence (release). + // 'toggle' — first tap starts, second tap stops. Required on macOS, + // where globalShortcut fires exactly once per press and + // Electron exposes no key-up event. // - // macOS: globalShortcut fires exactly once on press; the OS does not - // repeat global accelerators and Electron exposes no key-up event. We - // can't implement true press-and-hold without a native key-event hook, - // so fall back to toggle: first tap starts, second tap stops. + // On macOS we always behave as 'toggle' regardless of the stored setting, + // so a user who set 'hold' on another platform doesn't get a stuck mic. + const isMac = process.platform === 'darwin'; let pttDebounceTimer: ReturnType | null = null; let pttActive = false; + /** Number of accelerator fires seen during the current hold. */ + let pttFireCount = 0; let currentShortcut = ''; - const isMac = process.platform === 'darwin'; + /** Setup's "press your shortcut" check — see IPC.PTT_TEST_START. */ + let pttTestMode = false; + + // 'hold' timing. The OS doesn't start auto-repeating a held key until + // its repeat-delay elapses — on Windows that's 250 ms at the fastest + // setting and 1 s at the slowest (default ≈ 500 ms). The old fixed + // 250 ms silence window therefore expired *before the first repeat + // ever arrived*: recording stopped after a quarter second, a useless + // sliver of audio went to Whisper, then the repeat kicked in and + // started a brand-new turn — over and over while the key was held. + // Give the first repeat a generous window; once repeats are flowing + // (~30 Hz) a tight window is plenty. + const PTT_HOLD_INITIAL_GRACE_MS = 1100; + const PTT_HOLD_REPEAT_GRACE_MS = 250; const pttHandler = () => { - if (isMac) { + // Setup verification: prove the binding reaches us without + // actually opening the mic. + sendToPanel(IPC.PTT_SHORTCUT_FIRED); + if (pttTestMode) return; + + const mode = isMac ? 'toggle' : companion.getSettings().pttMode; + + if (mode === 'toggle') { if (!pttActive) { pttActive = true; - companion.startPushToTalk(); + // If startPushToTalk's underlying transcription provider fails + // to initialise, companion silently flips isRecording back to + // false. Reconcile the local toggle so the next tap retries + // the start path instead of issuing a stop on nothing. + void companion.startPushToTalk().then(() => { + pttActive = companion.recording; + }); } else { pttActive = false; - companion.stopPushToTalk(); + void companion.stopPushToTalk().then(() => { + pttActive = companion.recording; + }); } return; } + + // 'hold' mode (Windows/Linux): rely on key-repeat, debounce on silence. if (pttDebounceTimer) { clearTimeout(pttDebounceTimer); pttDebounceTimer = null; } if (!pttActive) { pttActive = true; - companion.startPushToTalk(); + pttFireCount = 0; + void companion.startPushToTalk(); } + pttFireCount += 1; + const grace = pttFireCount === 1 ? PTT_HOLD_INITIAL_GRACE_MS : PTT_HOLD_REPEAT_GRACE_MS; pttDebounceTimer = setTimeout(() => { pttActive = false; + pttFireCount = 0; pttDebounceTimer = null; - companion.stopPushToTalk(); - }, 250); + void companion.stopPushToTalk(); + }, grace); }; + ipcMain.on(IPC.PTT_TEST_START, () => { pttTestMode = true; }); + ipcMain.on(IPC.PTT_TEST_STOP, () => { pttTestMode = false; }); + function registerPttShortcut(accelerator: string): boolean { const previous = currentShortcut; try { @@ -279,6 +452,20 @@ app.whenReady().then(() => { ipcMain.handle(IPC.GET_SETTINGS, () => companion.getSettings()); ipcMain.handle(IPC.GET_PERMISSIONS, () => companion.getPermissions()); + ipcMain.handle(IPC.GET_APP_VERSION, () => app.getVersion()); + ipcMain.handle(IPC.VALIDATE_API_KEY, (_e, name, key: string) => validateApiKey(name, key)); + ipcMain.handle(IPC.VALIDATE_STORED_API_KEY, (_e, name) => validateStoredApiKey(name)); + + // Setup mic check: capture runs, levels flow to the panel, nothing is + // transcribed. The overlay owns the mic; relay its telemetry here. + ipcMain.on(IPC.MIC_TEST_START, () => companion.startMicTest()); + ipcMain.on(IPC.MIC_TEST_STOP, () => companion.stopMicTest()); + ipcMain.on(IPC.MIC_LEVEL, (_e, level: number) => sendToPanel(IPC.MIC_LEVEL, level)); + ipcMain.on(IPC.MIC_ERROR, (_e, message: string) => { + console.error('[Flicky] mic capture error from overlay:', message); + sendToPanel(IPC.MIC_ERROR, message); + sendToPanel(IPC.AI_ERROR, `microphone unavailable — ${message}`); + }); ipcMain.on(IPC.SET_MODEL, (_e, model) => companion.setModel(model)); ipcMain.on(IPC.SET_OPENAI_MODEL, (_e, model) => companion.setOpenAIModel(model)); @@ -292,9 +479,13 @@ app.whenReady().then(() => { ipcMain.on(IPC.TOGGLE_CURSOR, (_e, enabled) => companion.toggleCursor(enabled)); ipcMain.on(IPC.SET_LAUNCH_AT_LOGIN, (_e, enabled) => companion.setLaunchAtLogin(enabled)); ipcMain.on(IPC.SET_PUSH_TO_TALK_SHORTCUT, (_e, accel: string) => companion.setPushToTalkShortcut(accel)); + ipcMain.on(IPC.SET_PTT_MODE, (_e, mode) => companion.setPttMode(mode)); + ipcMain.on(IPC.SET_AUTO_TYPE_ENABLED, (_e, enabled: boolean) => companion.setAutoTypeEnabled(enabled)); ipcMain.on(IPC.SET_STREAM_VISIBILITY, (_e, v: StreamVisibility) => companion.setStreamVisibility(v)); ipcMain.on(IPC.SET_STREAM_WINDOW_BOUNDS, (_e, b: StreamWindowBounds) => companion.setStreamWindowBounds(b)); - ipcMain.on(IPC.CLEAR_STREAM, () => sendToStream(IPC.CLEAR_STREAM)); + // (clearStream used to be a needless renderer→main→same-renderer + // round trip — the stream's "clear" button now updates its own + // state directly, no IPC.) ipcMain.on(IPC.REQUEST_PERMISSION, (_e, kind) => companion.requestPermission(kind)); ipcMain.on(IPC.OPEN_EXTERNAL, (_e, url) => shell.openExternal(url)); ipcMain.on(IPC.QUIT_APP, () => app.quit()); @@ -394,17 +585,40 @@ app.whenReady().then(() => { companion.handleAudioChunk(buffer); }); - // Track cursor position for overlay rendering + // Track cursor position for overlay rendering. Three optimisations vs + // the naive 60-fps fanout: + // 1. Skip the entire poll when "Show cursor" is off. + // 2. 30 fps is plenty — the cursor companion has its own 50 ms CSS + // transition, so doubling the rate just doubled the IPC traffic. + // 3. Only send to the overlay whose display the cursor is on. When + // the cursor leaves a display we send one "off" pulse to the old + // overlay so its `isCursorOnThisDisplay` state flips false; we + // stop sending updates to it until the cursor re-enters. + let lastCursorTargetWcId: number | null = null; setInterval(() => { + if (!companion.getSettings().isClickyCursorEnabled) { + // If we previously had a target, tell it to clear so a stale + // companion cursor doesn't linger on the last screen. + if (lastCursorTargetWcId !== null) { + sendToOverlayById(lastCursorTargetWcId, IPC.CURSOR_POSITION, { x: -9999, y: -9999, off: true }); + lastCursorTargetWcId = null; + } + return; + } const pos = screen.getCursorScreenPoint(); - sendToOverlays(IPC.CURSOR_POSITION, pos); - }, 16); // ~60fps + const targetWin = findOverlayContainingPoint(pos); + const targetId = targetWin?.webContents.id ?? null; + if (targetId !== lastCursorTargetWcId && lastCursorTargetWcId !== null) { + sendToOverlayById(lastCursorTargetWcId, IPC.CURSOR_POSITION, { x: -9999, y: -9999, off: true }); + } + if (targetWin) { + targetWin.webContents.send(IPC.CURSOR_POSITION, pos); + } + lastCursorTargetWcId = targetId; + }, 33); - // Poll permissions - setInterval(async () => { - const perms = await companion.getPermissions(); - sendToPanel(IPC.PERMISSION_STATUS, perms); - }, 1500); + // Perms poll lifecycle is hoisted to module scope above; togglePanel() + // wires it to the panel window's show/hide events on first creation. // Open the main window on first launch. togglePanel(); @@ -423,18 +637,28 @@ app.on('window-all-closed', () => { // ── Window Management ────────────────────────────────────────────────── +/** When the panel last lost focus — see togglePanel. */ +let panelBlurredAt = 0; + function togglePanel(): void { if (panelWindow && !panelWindow.isDestroyed()) { - if (panelWindow.isVisible() && panelWindow.isFocused()) { + // On Windows, clicking the tray icon blurs the panel *before* our + // click handler runs, so `isFocused()` was always false and the tray + // could only ever show the panel, never hide it. Treat a blur in the + // last few hundred ms as "was focused when you clicked". + const recentlyFocused = Date.now() - panelBlurredAt < 400; + if (panelWindow.isVisible() && !panelWindow.isMinimized() && (panelWindow.isFocused() || recentlyFocused)) { panelWindow.hide(); return; } + if (panelWindow.isMinimized()) panelWindow.restore(); panelWindow.show(); panelWindow.focus(); return; } panelWindow = createPanelWindow(); + panelWindow.on('blur', () => { panelBlurredAt = Date.now(); }); panelWindow.webContents.on('did-fail-load', (_e, code, desc, url) => { console.error('[Flicky] Panel FAILED to load:', code, desc, url); }); @@ -445,6 +669,9 @@ function togglePanel(): void { panelWindow?.hide(); } }); + // Permissions polling is only useful while the banner can render. + panelWindow.on('show', startPermsPoll); + panelWindow.on('hide', stopPermsPoll); panelWindow.show(); panelWindow.focus(); @@ -464,6 +691,61 @@ function rebuildOverlays(): void { applyOverlayVisibility(companion.getSettings().isClickyCursorEnabled); } +/** + * Reconcile overlay windows with the current display topology. Only + * creates overlays for newly-added displays and destroys overlays for + * displays that are gone — leaves untouched overlays running so we + * don't reload all renderer bundles on every monitor change. + */ +function syncOverlaysToDisplays(): void { + const currentDisplays = screen.getAllDisplays(); + const currentIds = new Set(currentDisplays.map((d) => d.id)); + + // Drop overlays whose display is gone. + const survivors: BrowserWindow[] = []; + for (const win of overlayWindows) { + const display = overlayDisplayByWebContents.get(win.webContents.id); + if (!display || !currentIds.has(display.id) || win.isDestroyed()) { + if (!win.isDestroyed()) win.destroy(); + continue; + } + survivors.push(win); + } + + // Add overlays for newly-attached displays. + const survivorDisplayIds = new Set( + survivors.map((w) => overlayDisplayByWebContents.get(w.webContents.id)?.id).filter((id): id is number => id !== undefined), + ); + for (const display of currentDisplays) { + if (!survivorDisplayIds.has(display.id)) { + survivors.push(createOverlayWindow(display)); + } + } + + overlayWindows = survivors; + // Respect the persisted "Show cursor" setting — if the user has it + // turned off, the overlays are created but hidden so we can still + // route voice-state / element-detected events into their renderers + // without a visible window on screen. + applyOverlayVisibility(companion.getSettings().isClickyCursorEnabled); +} + +/** Move an existing overlay to its display's new bounds after a metrics change. */ +function syncOverlayBounds(display: Electron.Display): void { + for (const win of overlayWindows) { + if (win.isDestroyed()) continue; + const tracked = overlayDisplayByWebContents.get(win.webContents.id); + if (!tracked || tracked.id !== display.id) continue; + win.setBounds(display.bounds); + overlayDisplayByWebContents.set(win.webContents.id, display); + win.webContents.send('display-info', { + id: display.id, + bounds: display.bounds, + scaleFactor: display.scaleFactor, + }); + } +} + function applyOverlayVisibility(enabled: boolean): void { for (const win of overlayWindows) { if (win.isDestroyed()) continue; @@ -475,35 +757,70 @@ function applyOverlayVisibility(enabled: boolean): void { } } +/** + * Lazily create the stream window. Returns the live BrowserWindow. + * The window is destroyed (not hidden) when the user sets visibility + * back to 'off', so calling this again will spin up a fresh instance. + */ +function ensureStreamWindow(): BrowserWindow { + if (streamWindow && !streamWindow.isDestroyed()) return streamWindow; + const bounds = companion.getSettings().streamWindowBounds; + streamWindow = createStreamWindow(bounds); + streamWindow.on('close', (e) => { + if (!isAppQuitting) { + e.preventDefault(); + streamWindow?.hide(); + companion.setStreamVisibility('off'); + } + }); + streamWindow.on('moved', persistStreamBounds); + streamWindow.on('resized', persistStreamBounds); + return streamWindow; +} + +function destroyStreamWindow(): void { + if (!streamWindow) return; + if (!streamWindow.isDestroyed()) { + // The 'close' handler intercepts user closes and re-shows + flips the + // visibility setting; we want a real teardown here, so destroy directly. + streamWindow.destroy(); + } + streamWindow = null; +} + /** * Show or hide the stream window based on the current visibility * setting. 'responses' mode is refined further by updateStreamForVoiceState * which flicks it on when Flicky is thinking / speaking. */ function applyStreamVisibility(v: StreamVisibility): void { - if (!streamWindow || streamWindow.isDestroyed()) return; + if (v === 'off') { + destroyStreamWindow(); + return; + } if (v === 'always') { - streamWindow.showInactive(); - } else if (v === 'off') { - streamWindow.hide(); - } else { - // 'responses' — reconcile with whatever Flicky is currently doing - // so switching *into* this mode immediately reflects the real state - // (hide if idle, show if mid-turn) instead of waiting for the next - // voice state transition. - updateStreamForVoiceState(lastVoiceState); + ensureStreamWindow().showInactive(); + return; } + // 'responses' — reconcile with whatever Flicky is currently doing + // so switching *into* this mode immediately reflects the real state. + // We don't pre-create the window here; updateStreamForVoiceState will + // spin it up the first time something happens worth showing. + updateStreamForVoiceState(lastVoiceState); } function updateStreamForVoiceState(state: string): void { - if (!streamWindow || streamWindow.isDestroyed()) return; const v = companion.getSettings().streamVisibility; if (v !== 'responses') return; - const active = state === 'listening' || state === 'processing' || state === 'responding'; + const active = + state === 'listening' || + state === 'processing' || + state === 'responding' || + walkthroughActive; if (active) { - streamWindow.showInactive(); + ensureStreamWindow().showInactive(); } else if (state === 'idle') { - streamWindow.hide(); + if (streamWindow && !streamWindow.isDestroyed()) streamWindow.hide(); } } diff --git a/src/main/services/analytics.ts b/src/main/services/analytics.ts index 9be7c4e..5cd7340 100644 --- a/src/main/services/analytics.ts +++ b/src/main/services/analytics.ts @@ -1,13 +1,23 @@ -import { PostHog } from 'posthog-node'; import { app } from 'electron'; +import type { PostHog } from 'posthog-node'; + +// posthog-node pulls a chunky transitive tree (axios / undici-ish bits). +// Importing it eagerly added measurable cold-start latency in packaged +// builds even though the default key is empty and analytics are off. +// Lazy-import inside initAnalytics so the module only hits the JIT when +// a real key is supplied. let client: PostHog | null = null; let distinctId = 'anonymous'; export function initAnalytics(apiKey: string, host: string): void { if (!apiKey) return; - client = new PostHog(apiKey, { host }); - distinctId = `flicky-${Date.now()}`; + void import('posthog-node').then(({ PostHog }) => { + client = new PostHog(apiKey, { host }); + distinctId = `flicky-${Date.now()}`; + }).catch((err) => { + console.error('[Flicky] analytics init failed:', err); + }); } function capture(event: string, properties?: Record): void { diff --git a/src/main/services/auto-typer.ts b/src/main/services/auto-typer.ts new file mode 100644 index 0000000..2df2629 --- /dev/null +++ b/src/main/services/auto-typer.ts @@ -0,0 +1,73 @@ +import { systemPreferences } from 'electron'; + +/** + * Native auto-typer wrapper. The underlying module (`@nut-tree-fork/nut-js`) + * ships native bindings for libnut and emits global keyboard events through + * the OS. We load it lazily so a failed install doesn't crash the main + * process — every consumer goes through `typeText`, which returns `false` + * if the module or the required permission is unavailable, and the caller + * falls back to clipboard handoff. + */ + +type NutJs = typeof import('@nut-tree-fork/nut-js'); + +let nutJs: NutJs | null = null; +let loadAttempted = false; + +async function load(): Promise { + if (loadAttempted) return nutJs; + loadAttempted = true; + try { + nutJs = await import('@nut-tree-fork/nut-js'); + } catch (err) { + console.error('[Flicky] auto-typer native module unavailable:', err); + nutJs = null; + } + return nutJs; +} + +/** + * Whether the OS permission required for auto-typing is currently granted. + * On macOS this is Accessibility (Input Monitoring is not enough — typing + * keystrokes globally requires the Accessibility trust list). On other + * platforms there is no equivalent gate. + */ +export function isAccessibilityGranted(): boolean { + if (process.platform !== 'darwin') return true; + return systemPreferences.isTrustedAccessibilityClient(false); +} + +/** + * Surface the macOS Accessibility prompt and add Flicky to the trust + * list. The user still has to enable the checkbox themselves; the OS + * does not return a granted state until they do, but the dialog gives + * them the discovery path. + */ +export function promptAccessibility(): boolean { + if (process.platform !== 'darwin') return true; + return systemPreferences.isTrustedAccessibilityClient(true); +} + +/** + * Type `text` into whatever the OS considers the focused element. + * Returns true when the keys were sent successfully, false when the + * caller should fall back to clipboard handoff (module missing, + * permission missing, or libnut threw). + */ +export async function typeText(text: string): Promise { + if (!text) return false; + const lib = await load(); + if (!lib) return false; + if (!isAccessibilityGranted()) return false; + try { + // Default delay is fine for native apps; web inputs sometimes drop + // characters at zero delay, but raising this hurts the "magical" + // feel. If we see drops in practice we can bump to ~5–10ms. + lib.keyboard.config.autoDelayMs = 0; + await lib.keyboard.type(text); + return true; + } catch (err) { + console.error('[Flicky] auto-type failed:', err); + return false; + } +} diff --git a/src/main/services/element-detector.ts b/src/main/services/element-detector.ts index 9525629..e50238f 100644 --- a/src/main/services/element-detector.ts +++ b/src/main/services/element-detector.ts @@ -1,22 +1,45 @@ -import type { ScreenCapture, DetectedElement } from '../../shared/types'; +import type { ScreenCapture, DetectedElement, Walkthrough, WalkthroughStep } from '../../shared/types'; + +/** Regex used to find every [TYPE:...] tag in a model response. */ +const TYPE_TAG_REGEX = /\[TYPE:((?:[^\]\\]|\\.)+)\]/g; + +/** Regex that matches both POINT and TYPE tags, used to strip them + * from text we feed to TTS / chat history / display. */ +export const TAG_STRIP_REGEX = /\[(?:POINT|TYPE):[^\]]+\]/g; /** - * Detect UI element locations using Claude's Computer Use API. - * Equivalent to ElementLocationDetector.swift. + * Parse [TYPE:text] tags. Backslashes inside the text escape the next + * character (so the model can include a literal `]` if it must), e.g. + * [TYPE:hello\] world] → "hello] world". + */ +export function parseTypeTags(responseText: string): string[] { + TYPE_TAG_REGEX.lastIndex = 0; + const out: string[] = []; + let m: RegExpExecArray | null; + while ((m = TYPE_TAG_REGEX.exec(responseText)) !== null) { + out.push(m[1].replace(/\\(.)/g, '$1')); + } + return out; +} + +/** + * Parse [POINT:x,y:label:screenN] tags from an LLM response. + * + * - parsePointTags: returns the first match (legacy single-point flow). + * - parseAllPointTags: returns every match in the order it appeared, so + * multi-step walkthroughs can be reconstructed from the response. * - * Parses [POINT:x,y:label:screenN] tags from Claude's response text - * and maps pixel coordinates back to display coordinates. + * Pixel coordinates inside the screenshot are mapped back to the + * corresponding display's logical coordinate space using each capture's + * recorded bounds, so points line up regardless of display scaling. */ -const POINT_TAG_REGEX = /\[POINT:(\d+),(\d+):([^:]+):screen(\d+)\]/; +const POINT_TAG_REGEX = /\[POINT:(\d+),(\d+):([^:]+):screen(\d+)\]/g; -export function parsePointTags( - responseText: string, +function pointToElement( + match: RegExpExecArray, screenshots: ScreenCapture[], ): DetectedElement | null { - const match = POINT_TAG_REGEX.exec(responseText); - if (!match) return null; - const pixelX = parseInt(match[1], 10); const pixelY = parseInt(match[2], 10); const label = match[3]; @@ -25,17 +48,45 @@ export function parsePointTags( const screenshot = screenshots[screenIndex]; if (!screenshot) return null; - // Convert from screenshot pixel space to display coordinate space const scaleX = screenshot.displayBounds.width / screenshot.imageWidth; const scaleY = screenshot.displayBounds.height / screenshot.imageHeight; - const displayX = screenshot.displayBounds.x + pixelX * scaleX; - const displayY = screenshot.displayBounds.y + pixelY * scaleY; - return { - x: displayX, - y: displayY, + x: screenshot.displayBounds.x + pixelX * scaleX, + y: screenshot.displayBounds.y + pixelY * scaleY, label, screenIndex, }; } + +export function parsePointTags( + responseText: string, + screenshots: ScreenCapture[], +): DetectedElement | null { + POINT_TAG_REGEX.lastIndex = 0; + const match = POINT_TAG_REGEX.exec(responseText); + if (!match) return null; + return pointToElement(match, screenshots); +} + +export function parseAllPointTags( + responseText: string, + screenshots: ScreenCapture[], +): Walkthrough | null { + POINT_TAG_REGEX.lastIndex = 0; + const elements: DetectedElement[] = []; + let match: RegExpExecArray | null; + while ((match = POINT_TAG_REGEX.exec(responseText)) !== null) { + const el = pointToElement(match, screenshots); + if (el) elements.push(el); + } + if (elements.length === 0) return null; + + const total = elements.length; + const steps: WalkthroughStep[] = elements.map((el, i) => ({ + ...el, + step: i + 1, + total, + })); + return { steps }; +} diff --git a/src/main/services/key-validation.ts b/src/main/services/key-validation.ts new file mode 100644 index 0000000..a5dd327 --- /dev/null +++ b/src/main/services/key-validation.ts @@ -0,0 +1,123 @@ +import type { ApiKeyName, ApiKeyValidation } from '../../shared/types'; +import { getApiKey } from './key-store'; + +/** + * Prove a key is accepted by its provider before we rely on it. + * + * For the two reasoning providers we make a *real* one-token completion + * rather than listing models: a list call authenticates fine on an + * account with no credit, and the user then hits "credit balance too + * low" on their first actual question. The completion costs a fraction + * of a cent and catches billing, quota and model-access problems too. + * Transcription / TTS use free whoami-style endpoints. + */ + +const TIMEOUT_MS = 15_000; + +interface Probe { + url: string; + method: 'GET' | 'POST'; + headers: Record; + body?: string; +} + +const PROBES: Record Probe> = { + anthropic: (key) => ({ + url: 'https://api.anthropic.com/v1/messages', + method: 'POST', + headers: { + 'x-api-key': key, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + }), + openai: (key) => ({ + url: 'https://api.openai.com/v1/chat/completions', + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + }), + elevenlabs: (key) => ({ + url: 'https://api.elevenlabs.io/v1/user', + method: 'GET', + headers: { 'xi-api-key': key }, + }), + groq: (key) => ({ + url: 'https://api.groq.com/openai/v1/models', + method: 'GET', + headers: { Authorization: `Bearer ${key}` }, + }), +}; + +/** Pull the human-readable message out of a provider error body. */ +function extractMessage(text: string): string { + try { + const j = JSON.parse(text) as { error?: { message?: string } | string; message?: string; detail?: { message?: string } | string }; + if (typeof j.error === 'string') return j.error; + if (j.error?.message) return j.error.message; + if (typeof j.detail === 'string') return j.detail; + if (j.detail && typeof j.detail === 'object' && j.detail.message) return j.detail.message; + if (j.message) return j.message; + } catch { /* not JSON */ } + return text.slice(0, 200); +} + +export async function validateApiKey(name: ApiKeyName, key: string): Promise { + const trimmed = key.trim(); + if (!trimmed) return { ok: false, error: 'Key is empty.' }; + const build = PROBES[name]; + if (!build) return { ok: false, error: `Unknown provider "${name}".` }; + + const probe = build(trimmed); + try { + const res = await fetch(probe.url, { + method: probe.method, + headers: probe.headers, + body: probe.body, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (res.ok) return { ok: true }; + + let detail = ''; + try { detail = extractMessage(await res.text()); } catch { /* ignore */ } + + if (res.status === 401 || res.status === 403) { + return { ok: false, error: 'The provider rejected this key. Double-check it was copied in full.' }; + } + if (res.status === 429) { + // Rate-limit vs. out-of-quota both come back as 429 from OpenAI; + // the body tells them apart. + if (/quota|billing|credit/i.test(detail)) { + return { ok: false, error: `Key works but the account can't be billed: ${detail}` }; + } + return { ok: true }; + } + return { ok: false, error: `Provider returned HTTP ${res.status}${detail ? `: ${detail}` : ''}` }; + } catch (err) { + const e = err as Error & { name?: string; cause?: { code?: string } }; + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + return { ok: false, error: 'Timed out reaching the provider. Check your internet connection or firewall.' }; + } + const code = e.cause?.code; + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { + return { ok: false, error: 'Could not resolve the provider host — are you offline?' }; + } + return { ok: false, error: `Network error: ${e.message}` }; + } +} + +/** Validate whatever key is currently saved for `name`. */ +export async function validateStoredApiKey(name: ApiKeyName): Promise { + const key = getApiKey(name); + if (!key) return { ok: false, error: 'No key saved.' }; + return validateApiKey(name, key); +} diff --git a/src/main/services/prompts.ts b/src/main/services/prompts.ts index 840ba22..e31a68d 100644 --- a/src/main/services/prompts.ts +++ b/src/main/services/prompts.ts @@ -13,11 +13,32 @@ you can see the user's screen — reference specific things you see. if the user POINTING AT ELEMENTS: when you want to show the user something on screen, use the tag: [POINT:x,y:label:screenN] - x,y are pixel coordinates within the screenshot image (origin is top-left corner, x goes right, y goes down) -- label is a short description of the element you're pointing at +- label is a short description of the element you're pointing at — keep it under 6 words; this is shown verbatim as a caption next to the cursor - screenN is which screenshot (screen0 = first image shown, which is the screen the cursor is on) -- be precise: aim for the center of the UI element, button, or text you want to highlight +- be precise: aim for the visual *center* of the UI element (button, icon, link, input). do not pick the corner, the label next to it, or whitespace beside it. if the element is small, take an extra moment to estimate the center accurately — the user is going to click exactly where you point - always point when showing the user where something is or telling them to click/interact with something +WALKTHROUGHS (multi-step instructions): +- if the answer is a sequence of actions ("how do I X?", "guide me through Y"), emit one [POINT:...] tag per step, in the exact order the user should perform them +- each label is the user-facing instruction for that step (e.g. "click File", "choose Export", "hit Save") +- keep labels under 6 words and action-oriented (start with a verb) +- do not number the steps in the label — the UI numbers them automatically based on tag order +- 2–6 steps is the sweet spot; for longer flows, summarize into the most important hops +- only include points the user must actually look at; don't pad with filler steps +- example for "how do I export this as PDF?": your spoken text is a normal short sentence, and you append the step tags at the end: + "sure, just walk through these. [POINT:412,38:click File:screen0] [POINT:430,112:choose Export:screen0] [POINT:520,260:pick PDF:screen0]" +- if the answer is a single location ("where's X?"), still use one [POINT:...] tag — the UI handles 1-step the same way + +TYPING FOR THE USER: +when the user asks you to type, fill in, draft, paste, or write something into a field on screen, use the tag: [TYPE:exact text to type] +- emit ONE [TYPE:...] tag per text the user wants typed; only use this when the user explicitly asks for text to be entered +- the text inside the tag is exactly what gets copied to the user's clipboard for them to paste +- include only the literal text — no quotes around it, no "type this:" preamble +- if you also want to point at the field, emit a [POINT:...] tag for the field, then a [TYPE:...] tag with the content. order matters; users will see the cursor land on the field and then a paste prompt +- example for "draft a quick reply that I'm running late": + "here's a quick one — paste it in. [POINT:520,640:reply field:screen0] [TYPE:Hey, running about 10 minutes late, see you soon!]" +- never use [TYPE:...] for something the user did not ask you to type. don't volunteer text for fields they didn't mention + never use markdown formatting. speak naturally like a friend.`; /** Appended only for providers that actually have web search wired. */ diff --git a/src/main/services/screen-capture.ts b/src/main/services/screen-capture.ts index 756a850..78a62f3 100644 --- a/src/main/services/screen-capture.ts +++ b/src/main/services/screen-capture.ts @@ -1,16 +1,68 @@ import { desktopCapturer, screen } from 'electron'; import type { ScreenCapture } from '../../shared/types'; -const MAX_DIMENSION = 1280; -const JPEG_QUALITY = 80; +// Bumped from 1280 → 1600. Higher res = more pixel precision when the +// model reports POINT coordinates, at the cost of ~50% more image tokens +// per turn. With cursor-only capture this stays well under the per-image +// limits for both Anthropic and OpenAI vision models. +const MAX_DIMENSION = 1600; +const JPEG_QUALITY = 82; /** - * Capture all displays, returning JPEG screenshots with metadata. - * Equivalent to CompanionScreenCaptureUtility.swift. + * Capture displays as JPEG screenshots with metadata. + * + * @param opts.cursorOnly - if true, return only the display the user's + * cursor is currently on. Saves tokens and avoids confusing the model + * with screens the user isn't actively looking at. Default true. */ -export async function captureAllDisplays(): Promise { - const displays = screen.getAllDisplays(); +export async function captureDisplays( + opts: { cursorOnly?: boolean } = {}, +): Promise { + const first = await captureOnce(opts); + if (first.length > 0) return first; + // On macOS, desktopCapturer's source thumbnails can be empty on the + // very first call shortly after app launch — the capture pipeline + // hasn't warmed up yet. A single short-delayed retry reliably hands + // back populated thumbnails without bothering the user. + console.warn('[Flicky] capture returned zero on first try; retrying after 300ms'); + await new Promise((r) => setTimeout(r, 300)); + return captureOnce(opts); +} + +async function captureOnce( + opts: { cursorOnly?: boolean } = {}, +): Promise { + const { cursorOnly = true } = opts; + const allDisplays = screen.getAllDisplays(); const cursorPoint = screen.getCursorScreenPoint(); + let displays = allDisplays; + if (cursorOnly) { + const onCursor = allDisplays.filter((d) => { + const b = d.bounds; + return ( + cursorPoint.x >= b.x && + cursorPoint.x < b.x + b.width && + cursorPoint.y >= b.y && + cursorPoint.y < b.y + b.height + ); + }); + // Fall back to all displays if the cursor-only filter excludes + // everything (which can happen if the cursor is mid-transition, + // the OS returned a stale point right after a resolution change, + // or there's a multi-monitor topology we don't recognize). Better + // to send the model a screenshot of *some* screen than to bail + // and tell the user we can't see their screen at all. + if (onCursor.length > 0) { + displays = onCursor; + } else { + console.warn( + '[Flicky] cursor-only filter excluded every display ' + + `(cursor at ${cursorPoint.x},${cursorPoint.y}, ` + + `displays: ${allDisplays.map((d) => `${d.id}@${JSON.stringify(d.bounds)}`).join(' ')}). ` + + 'Falling back to all displays.', + ); + } + } // Get all screen sources const sources = await desktopCapturer.getSources({ @@ -27,7 +79,10 @@ export async function captureAllDisplays(): Promise { return s.display_id === String(display.id); }) ?? sources[captures.length]; // Fallback to index-based matching - if (!source) continue; + if (!source) { + console.warn(`[Flicky] no source matched display ${display.id}`); + continue; + } const thumbnail = source.thumbnail; const size = thumbnail.getSize(); @@ -52,6 +107,17 @@ export async function captureAllDisplays(): Promise { const resized = thumbnail.resize({ width: targetWidth, height: targetHeight }); const jpegBuffer = resized.toJPEG(JPEG_QUALITY); + // Guard only against the case that actually causes Anthropic 400s: + // a zero-byte JPEG. Earlier broader checks (`isEmpty()`, `size===0`) + // were rejecting valid thumbnails on some macOS configurations. + if (jpegBuffer.length === 0) { + console.warn( + `[Flicky] display ${display.id}: JPEG encoded to 0 bytes ` + + `(thumbSize=${size.width}x${size.height}, target=${targetWidth}x${targetHeight}, ` + + `scaleFactor=${scaleFactor}); skipping`, + ); + continue; + } // Determine if cursor is on this display const bounds = display.bounds; @@ -74,5 +140,21 @@ export async function captureAllDisplays(): Promise { // Sort so cursor screen is first (primary focus) captures.sort((a, b) => (b.isCursorScreen ? 1 : 0) - (a.isCursorScreen ? 1 : 0)); + if (captures.length === 0) { + console.warn( + '[Flicky] captureDisplays produced zero screenshots. ' + + `displays=${displays.length}, sources=${sources.length}, ` + + `sourceIds=[${sources.map((s) => s.display_id || '""').join(',')}], ` + + `displayIds=[${displays.map((d) => d.id).join(',')}]`, + ); + } + return captures; } + +/** + * Backwards-compatible alias for code that still imports the old name. + * New callers should use captureDisplays() with explicit options. + */ +export const captureAllDisplays = (): Promise => + captureDisplays({ cursorOnly: true }); diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index 8acbf97..68d8e79 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -13,6 +13,7 @@ import type { StreamVisibility, StreamWindowBounds, LocalConnection, + PttMode, } from '../../shared/types'; /** @@ -38,6 +39,8 @@ export interface StoredSettings { isClickyCursorEnabled: boolean; launchAtLogin: boolean; pushToTalkShortcut: string; + pttMode: PttMode; + autoTypeEnabled: boolean; streamVisibility: StreamVisibility; streamWindowBounds: StreamWindowBounds | null; @@ -64,6 +67,10 @@ const DEFAULTS: StoredSettings = { isClickyCursorEnabled: true, launchAtLogin: false, pushToTalkShortcut: 'Ctrl+Alt+X', + // Default to 'toggle' on macOS because Electron's globalShortcut on + // darwin can't detect key-up; 'hold' would record forever there. + pttMode: process.platform === 'darwin' ? 'toggle' : 'hold', + autoTypeEnabled: false, streamVisibility: 'off', streamWindowBounds: null, @@ -76,7 +83,15 @@ function getFilePath(): string { return path.join(app.getPath('userData'), 'flicky-settings.json'); } -function read(): StoredSettings { +/** + * In-memory cache. The settings file is the single source of truth across + * runs, but within a run we own it — no external writers — so re-reading + * disk on every `get`/`set` is wasted I/O. We hydrate once on first access + * and keep the cache in sync with every `set`. + */ +let cache: StoredSettings | null = null; + +function readDisk(): StoredSettings { try { const raw = fs.readFileSync(getFilePath(), 'utf-8'); return { ...DEFAULTS, ...JSON.parse(raw) }; @@ -85,20 +100,29 @@ function read(): StoredSettings { } } +function ensureLoaded(): StoredSettings { + if (cache === null) cache = readDisk(); + return cache; +} + function write(data: StoredSettings): void { writeFileAtomic(getFilePath(), JSON.stringify(data, null, 2)); } export function get(key: K): StoredSettings[K] { - return read()[key]; + return ensureLoaded()[key]; } export function set(key: K, value: StoredSettings[K]): void { - const data = read(); + const data = ensureLoaded(); data[key] = value; + // Persist after mutating the cache. If the disk write fails we still + // have the new value in memory for the rest of the session — the next + // launch will revert, which matches the previous behavior. write(data); } export function getAll(): StoredSettings { - return read(); + // Shallow copy so callers can't mutate the cache through the returned ref. + return { ...ensureLoaded() }; } diff --git a/src/main/services/transcription.ts b/src/main/services/transcription.ts index 90c1652..dd899dd 100644 --- a/src/main/services/transcription.ts +++ b/src/main/services/transcription.ts @@ -44,6 +44,25 @@ export class GroqWhisperProvider implements TranscriptionProvider { const arrayBuf = wavBuffer.buffer.slice(wavBuffer.byteOffset, wavBuffer.byteOffset + wavBuffer.byteLength) as ArrayBuffer; formData.append('file', new Blob([arrayBuf], { type: 'audio/wav' }), 'recording.wav'); formData.append('model', model); + // English-only locks Whisper out of language detection (which is the + // single biggest source of garbled output on short voice clips). If + // we ever want multilingual, lift this from the user's locale. + formData.append('language', 'en'); + // Temperature 0 = deterministic decoding. Higher temps invent words + // when the audio is unclear; for short commands we want fewer halluc- + // inations, even if it means cutting an unintelligible word. + formData.append('temperature', '0'); + // The "prompt" biases the model's vocab. Loading it with the kind + // of words a user actually says to a screen-aware assistant fixes a + // lot of the weirdness — proper-noun apps ("Slack", "Notion"), + // pointing verbs ("click", "highlight"), and generic UI nouns get + // far higher prior probability and stop being mis-transcribed as + // homophones (e.g. "click" → "clique"). Whisper accepts up to 224 + // tokens here; keep it short and dense. + formData.append( + 'prompt', + "Flicky, click, tap, open, close, switch, highlight, select, search, paste, file, folder, window, tab, button, link, screen, cursor, Slack, Chrome, Notion, VS Code, Figma, Gmail.", + ); const res = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', { method: 'POST', @@ -67,8 +86,8 @@ export class OpenAIWhisperProvider implements TranscriptionProvider { onPartialTranscript?: (text: string) => void; async start(): Promise { - const apiKey = getApiKey('anthropic'); // Uses OpenAI-compatible key — user may supply separately - if (!apiKey) throw new Error('API key not configured for transcription.'); + const apiKey = getApiKey('openai'); + if (!apiKey) throw new Error('OpenAI API key not configured. Add it in the Flicky panel.'); this.audioChunks = []; } @@ -89,7 +108,7 @@ export class OpenAIWhisperProvider implements TranscriptionProvider { const res = await fetch('https://api.openai.com/v1/audio/transcriptions', { method: 'POST', headers: { - Authorization: `Bearer ${getApiKey('anthropic')}`, // Would need a separate OpenAI key in production + Authorization: `Bearer ${getApiKey('openai')}`, }, body: formData, }); diff --git a/src/main/windows.ts b/src/main/windows.ts index b416cf8..0419f70 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -39,10 +39,17 @@ export function createPanelWindow(): BrowserWindow { transparent: false, backgroundColor: '#0f0f11', title: 'Flicky', + // Windows/Linux otherwise show Electron's stock "File Edit View + // Window Help" bar above the panel. Alt still reveals it. + autoHideMenuBar: true, webPreferences: { preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, + // sandbox: true caused renderers to fail to render (blank screen) + // — likely a require-resolution issue with our relative preload + // path. Reverted; we'd need to bundle the preload as a single + // self-contained file (esbuild) before flipping this on safely. sandbox: false, }, }); @@ -51,6 +58,15 @@ export function createPanelWindow(): BrowserWindow { return win; } +/** + * Maps each overlay's webContents id back to the Display it covers, so + * `ipcMain.handle('get-display-info')` in main/index.ts can answer the + * renderer's request based on which window the IPC came from. Solved + * the race where the renderer's display-info listener attaches after + * the one-shot push has already fired. + */ +export const overlayDisplayByWebContents = new Map(); + function toDisplayInfo(display: Display): DisplayInfo { return { id: display.id, @@ -109,6 +125,16 @@ export function createOverlayWindow(display: Display): BrowserWindow { loadPage(win, 'overlay'); + // Track which display this overlay covers so main can route cursor / + // walkthrough events to the right window and answer bounds changes. + // Capture the webContents id up front — by the time `closed` fires, + // the webContents has been destroyed and accessing `.id` throws. + const wcId = win.webContents.id; + overlayDisplayByWebContents.set(wcId, display); + win.on('closed', () => { + overlayDisplayByWebContents.delete(wcId); + }); + // Belt-and-braces: the preload already reads this same snapshot out of // argv on every load, including reloads, so this send is redundant // rather than an update path. It stays as a cheap safety net in case diff --git a/src/preload/index.ts b/src/preload/index.ts index d0420e0..9a8ddd0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,9 +9,11 @@ import type { FlickySettings, VoiceState, TranscriptionResult, - DetectedElement, + Walkthrough, + TypeRequest, ReasoningDepth, ReplyTone, + PttMode, MemoryStats, ChatEntry, StreamVisibility, @@ -19,6 +21,8 @@ import type { LocalConnection, OllamaModelInfo, OllamaPullProgress, + ApiKeyValidation, + PermissionStatus, DisplayInfo, } from '../shared/types'; import type { OllamaTestResult } from '../main/services/ollama-api'; @@ -42,6 +46,12 @@ const initialDisplayInfo: DisplayInfo | null = (() => { })(); const api = { + // The host platform, resolved at runtime in the main process so it's + // correct even when the renderer was cross-compiled (a CI build of a + // macOS dmg on Linux would otherwise leak the build host's platform + // through Vite's compile-time `define`). + platform: process.platform as NodeJS.Platform, + // ── Settings ─────────────────────────────────────────────────────── getSettings: (): Promise => ipcRenderer.invoke(IPC.GET_SETTINGS), @@ -64,15 +74,16 @@ const api = { ipcRenderer.send(IPC.SET_STREAM_VISIBILITY, v), setStreamWindowBounds: (b: StreamWindowBounds): void => ipcRenderer.send(IPC.SET_STREAM_WINDOW_BOUNDS, b), - clearStream: (): void => ipcRenderer.send(IPC.CLEAR_STREAM), setPushToTalkShortcut: (accel: string): void => ipcRenderer.send(IPC.SET_PUSH_TO_TALK_SHORTCUT, accel), + setPttMode: (mode: PttMode): void => ipcRenderer.send(IPC.SET_PTT_MODE, mode), + setAutoTypeEnabled: (enabled: boolean): void => ipcRenderer.send(IPC.SET_AUTO_TYPE_ENABLED, enabled), suspendPushToTalkShortcut: (): void => ipcRenderer.send(IPC.SUSPEND_PUSH_TO_TALK_SHORTCUT), resumePushToTalkShortcut: (): void => ipcRenderer.send(IPC.RESUME_PUSH_TO_TALK_SHORTCUT), playVoicePreview: (voiceId: string): void => ipcRenderer.send(IPC.PLAY_VOICE_PREVIEW, voiceId), // ── Permissions ──────────────────────────────────────────────────── - getPermissions: (): Promise> => ipcRenderer.invoke(IPC.GET_PERMISSIONS), + getPermissions: (): Promise => ipcRenderer.invoke(IPC.GET_PERMISSIONS), requestPermission: (kind: string): void => ipcRenderer.send(IPC.REQUEST_PERMISSION, kind), // ── API Keys ─────────────────────────────────────────────────────── @@ -80,6 +91,41 @@ const api = { deleteApiKey: (name: ApiKeyName): void => ipcRenderer.send(IPC.DELETE_API_KEY, name), getApiKeyStatus: (): Promise> => ipcRenderer.invoke(IPC.GET_API_KEY_STATUS), + validateApiKey: (name: ApiKeyName, value: string): Promise => + ipcRenderer.invoke(IPC.VALIDATE_API_KEY, name, value), + validateStoredApiKey: (name: ApiKeyName): Promise => + ipcRenderer.invoke(IPC.VALIDATE_STORED_API_KEY, name), + + // ── Setup verification ───────────────────────────────────────────── + getAppVersion: (): Promise => ipcRenderer.invoke(IPC.GET_APP_VERSION), + startPttTest: (): void => ipcRenderer.send(IPC.PTT_TEST_START), + stopPttTest: (): void => ipcRenderer.send(IPC.PTT_TEST_STOP), + startMicTest: (): void => ipcRenderer.send(IPC.MIC_TEST_START), + stopMicTest: (): void => ipcRenderer.send(IPC.MIC_TEST_STOP), + /** Overlay → main: current input level (0..1). */ + reportMicLevel: (level: number): void => ipcRenderer.send(IPC.MIC_LEVEL, level), + /** Overlay → main: getUserMedia / AudioContext failure. */ + reportMicError: (message: string): void => ipcRenderer.send(IPC.MIC_ERROR, message), + onPttShortcutFired: (cb: () => void) => { + const handler = () => cb(); + ipcRenderer.on(IPC.PTT_SHORTCUT_FIRED, handler); + return () => ipcRenderer.removeListener(IPC.PTT_SHORTCUT_FIRED, handler); + }, + onMicLevel: (cb: (level: number) => void) => { + const handler = (_e: Electron.IpcRendererEvent, level: number) => cb(level); + ipcRenderer.on(IPC.MIC_LEVEL, handler); + return () => ipcRenderer.removeListener(IPC.MIC_LEVEL, handler); + }, + onMicError: (cb: (message: string) => void) => { + const handler = (_e: Electron.IpcRendererEvent, message: string) => cb(message); + ipcRenderer.on(IPC.MIC_ERROR, handler); + return () => ipcRenderer.removeListener(IPC.MIC_ERROR, handler); + }, + onAiError: (cb: (message: string) => void) => { + const handler = (_e: Electron.IpcRendererEvent, message: string) => cb(message); + ipcRenderer.on(IPC.AI_ERROR, handler); + return () => ipcRenderer.removeListener(IPC.AI_ERROR, handler); + }, // ── Memory / context ─────────────────────────────────────────────── getMemoryStats: (): Promise => ipcRenderer.invoke(IPC.GET_MEMORY_STATS), @@ -163,10 +209,22 @@ const api = { return () => ipcRenderer.removeListener(IPC.AI_RESPONSE_COMPLETE, handler); }, - onElementDetected: (cb: (element: DetectedElement | null) => void) => { - const handler = (_e: Electron.IpcRendererEvent, el: DetectedElement | null) => cb(el); - ipcRenderer.on(IPC.ELEMENT_DETECTED, handler); - return () => ipcRenderer.removeListener(IPC.ELEMENT_DETECTED, handler); + onWalkthrough: (cb: (walkthrough: Walkthrough | null) => void) => { + const handler = (_e: Electron.IpcRendererEvent, w: Walkthrough | null) => cb(w); + ipcRenderer.on(IPC.WALKTHROUGH, handler); + return () => ipcRenderer.removeListener(IPC.WALKTHROUGH, handler); + }, + + onWalkthroughStep: (cb: (index: number | null) => void) => { + const handler = (_e: Electron.IpcRendererEvent, i: number | null) => cb(i); + ipcRenderer.on(IPC.WALKTHROUGH_STEP, handler); + return () => ipcRenderer.removeListener(IPC.WALKTHROUGH_STEP, handler); + }, + + onTypeFulfilled: (cb: (req: TypeRequest) => void) => { + const handler = (_e: Electron.IpcRendererEvent, req: TypeRequest) => cb(req); + ipcRenderer.on(IPC.TYPE_FULFILLED, handler); + return () => ipcRenderer.removeListener(IPC.TYPE_FULFILLED, handler); }, onCursorPosition: (cb: (pos: { x: number; y: number }) => void) => { @@ -181,8 +239,8 @@ const api = { return () => ipcRenderer.removeListener(IPC.SETTINGS_CHANGED, handler); }, - onPermissionStatus: (cb: (perms: Record) => void) => { - const handler = (_e: Electron.IpcRendererEvent, perms: Record) => cb(perms); + onPermissionStatus: (cb: (perms: PermissionStatus) => void) => { + const handler = (_e: Electron.IpcRendererEvent, perms: PermissionStatus) => cb(perms); ipcRenderer.on(IPC.PERMISSION_STATUS, handler); return () => ipcRenderer.removeListener(IPC.PERMISSION_STATUS, handler); }, @@ -199,12 +257,6 @@ const api = { return () => ipcRenderer.removeListener(IPC.CHAT_ENTRY_ADDED, handler); }, - onClearStream: (cb: () => void) => { - const handler = () => cb(); - ipcRenderer.on(IPC.CLEAR_STREAM, handler); - return () => ipcRenderer.removeListener(IPC.CLEAR_STREAM, handler); - }, - // ── Audio Capture (overlay ↔ main) ────────────────────────────────── // ── Overlay / display info ──────────────────────────────────────── /** diff --git a/src/renderer/audio-capture-worklet.js b/src/renderer/audio-capture-worklet.js new file mode 100644 index 0000000..2d8884f --- /dev/null +++ b/src/renderer/audio-capture-worklet.js @@ -0,0 +1,43 @@ +// AudioWorkletProcessor for mic capture. +// +// Runs on the audio rendering thread (not the renderer's main JS thread), +// which is what the deprecated ScriptProcessorNode it replaces failed to +// do — that one ran on the UI thread and was a documented source of +// crackle / dropouts / stutter under load. +// +// The host renderer toggles capture by posting 'start' / 'stop' messages +// to this processor's port; while 'started', every render quantum's +// Float32 input is converted to Int16 PCM and posted back via the port. +// While 'stopped', process() returns immediately so the audio thread +// stays cheap. The graph itself is left intact across PTT turns so we +// don't pay getUserMedia / AudioContext startup latency every press. + +class CaptureProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this._enabled = false; + this.port.onmessage = (e) => { + if (e.data === 'start') this._enabled = true; + else if (e.data === 'stop') this._enabled = false; + }; + } + + process(inputs) { + if (!this._enabled) return true; + const input = inputs[0]; + if (!input || !input[0]) return true; + const float32 = input[0]; + const pcm16 = new Int16Array(float32.length); + for (let i = 0; i < float32.length; i++) { + const s = Math.max(-1, Math.min(1, float32[i])); + pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff; + } + // Transfer the buffer rather than copy — the renderer's onmessage + // handler ships it straight to main via IPC and never touches it + // again, so transfer ownership is safe and zero-copy. + this.port.postMessage(pcm16.buffer, [pcm16.buffer]); + return true; + } +} + +registerProcessor('capture-processor', CaptureProcessor); diff --git a/src/renderer/components/OverlayApp.tsx b/src/renderer/components/OverlayApp.tsx index 50c7cfe..8509497 100644 --- a/src/renderer/components/OverlayApp.tsx +++ b/src/renderer/components/OverlayApp.tsx @@ -1,7 +1,13 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import type { VoiceState, DetectedElement, DisplayInfo } from '../../shared/types'; +import type { VoiceState, WalkthroughStep, TypeRequest, DisplayInfo } from '../../shared/types'; import { Waveform } from './Waveform'; +// Vite resolves `new URL(..., import.meta.url)` at build time and emits +// the worklet as a static asset. The `.js` file is hand-written plain +// JS (worklets must be), so it isn't part of the TS compilation unit; +// we only need its URL to feed `audioWorklet.addModule()`. +const captureWorkletUrl = new URL('../audio-capture-worklet.js', import.meta.url).href; + // Offset the companion cursor ~1/5 inch (≈19px at 96dpi) down-right // of the real mouse so the tip doesn't sit directly on top of it. const FOLLOW_OFFSET_X = 14; @@ -27,27 +33,37 @@ type CursorMode = 'following' | 'navigating' | 'holding' | 'returning'; export function OverlayApp() { const [voiceState, setVoiceState] = useState('idle'); const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); - const [detectedElement, setDetectedElement] = useState(null); + const [currentStep, setCurrentStep] = useState(null); const [pointingPhrase, setPointingPhrase] = useState(''); const [cursorMode, setCursorMode] = useState('following'); const [companionPos, setCompanionPos] = useState({ x: 0, y: 0 }); const [isCursorOnThisDisplay, setIsCursorOnThisDisplay] = useState(false); + const [typeToast, setTypeToast] = useState(null); + const typeToastTimerRef = useRef | null>(null); // Seeded synchronously from the window's launch arguments so the first // cursor-position message already has a coordinate space to map into. const displayRef = useRef(window.flicky.getDisplayInfo()); const holdTimerRef = useRef | null>(null); + const stepsRef = useRef([]); const returnAnimRef = useRef(null); const cursorPosRef = useRef({ x: 0, y: 0 }); const companionPosRef = useRef({ x: 0, y: 0 }); // ── Mic capture ────────────────────────────────────────────────────── + // The audio graph (stream → AudioContext → AudioWorkletNode → destination) + // is built once on first PTT and kept warm across turns. Start/stop just + // toggles a flag inside the worklet so we don't pay getUserMedia or + // worklet-module-load latency on every press. const mediaStreamRef = useRef(null); - const scriptNodeRef = useRef(null); + const workletNodeRef = useRef(null); const audioCtxRef = useRef(null); - /** true while getUserMedia hasn't resolved yet. */ + /** true while getUserMedia / addModule are in flight. */ const micStartingRef = useRef(false); - /** set by stopMic so a pending start can abort before attaching. */ + /** set by stopMic so a pending start can bail before attaching. */ const micStopRequestedRef = useRef(false); + /** Peak RMS since the last level report; reported ~20×/s to main. */ + const micPeakRef = useRef(0); + const micLevelSentAtRef = useRef(0); // ── TTS playback (cancelable) ─────────────────────────────────────── const ttsRef = useRef<{ audio: HTMLAudioElement; url: string } | null>(null); @@ -63,60 +79,99 @@ export function OverlayApp() { }, []); useEffect(() => { - const startMic = async () => { - // Ignore overlapping starts. - if (micStartingRef.current || mediaStreamRef.current) return; + const ensureGraph = async (): Promise => { + if (workletNodeRef.current) return workletNodeRef.current; + if (micStartingRef.current) return null; micStartingRef.current = true; micStopRequestedRef.current = false; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true }, }); - - // A stop may have arrived before getUserMedia resolved; if so, - // release the stream immediately instead of attaching it. + // If a stop arrived while we were waiting on getUserMedia, the + // user has already released the key. Don't bother building the + // graph; the next press will re-enter and rebuild. if (micStopRequestedRef.current) { stream.getTracks().forEach((t) => t.stop()); - return; + return null; } - - mediaStreamRef.current = stream; - const ctx = new AudioContext({ sampleRate: 16000 }); - audioCtxRef.current = ctx; + await ctx.audioWorklet.addModule(captureWorkletUrl); const source = ctx.createMediaStreamSource(stream); - - const processor = ctx.createScriptProcessor(4096, 1, 1); - scriptNodeRef.current = processor; - - processor.onaudioprocess = (e) => { - const float32 = e.inputBuffer.getChannelData(0); - const pcm16 = new Int16Array(float32.length); - for (let i = 0; i < float32.length; i++) { - const s = Math.max(-1, Math.min(1, float32[i])); - pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff; + const node = new AudioWorkletNode(ctx, 'capture-processor'); + node.port.onmessage = (e: MessageEvent) => { + // Cheap RMS so the panel's mic check (and any future meter) + // can show that sound is actually arriving. Throttled to + // ~20 Hz; the chunk itself is forwarded untouched. + const pcm = new Int16Array(e.data); + let sum = 0; + for (let i = 0; i < pcm.length; i++) { + const s = pcm[i] / 32768; + sum += s * s; } - window.flicky.sendAudioChunk(pcm16.buffer); + const rms = pcm.length ? Math.sqrt(sum / pcm.length) : 0; + if (rms > micPeakRef.current) micPeakRef.current = rms; + const now = performance.now(); + if (now - micLevelSentAtRef.current > 50) { + micLevelSentAtRef.current = now; + // Speech RMS sits around 0.05–0.3; scale so normal talking + // fills most of the meter. + window.flicky.reportMicLevel(Math.min(1, micPeakRef.current * 4)); + micPeakRef.current = 0; + } + window.flicky.sendAudioChunk(e.data); }; - - source.connect(processor); - processor.connect(ctx.destination); + // Pull-graph: source → worklet → destination. The worklet + // leaves its outputs zeroed when 'enabled', so connecting to + // destination is silent — we only need it so the audio engine + // schedules `process()`. + source.connect(node); + node.connect(ctx.destination); + mediaStreamRef.current = stream; + audioCtxRef.current = ctx; + workletNodeRef.current = node; + return node; } catch (err) { - console.error('[Flicky] Mic capture failed:', err); + console.error('[Flicky] Mic capture init failed:', err); + // Nobody reads the overlay's devtools console on a packaged + // build. Translate the DOMException into something a person can + // act on and hand it to main, which shows it in the panel. + const e = err as { name?: string; message?: string }; + const friendly = + e.name === 'NotAllowedError' || e.name === 'SecurityError' + ? 'microphone access is blocked for Flicky. Allow it in your OS privacy settings.' + : e.name === 'NotFoundError' || e.name === 'OverconstrainedError' + ? 'no microphone was found. Plug one in or pick a default input device in your sound settings.' + : e.name === 'NotReadableError' + ? 'the microphone is busy or unreadable — another app may be holding it.' + : `${e.name ?? 'Error'}: ${e.message ?? String(err)}`; + window.flicky.reportMicError(friendly); + return null; } finally { micStartingRef.current = false; } }; + const startMic = async () => { + // Reset the stop flag at the top so subsequent presses always + // get a fresh start signal. On the very first press, ensureGraph + // also resets this; on press 2+, ensureGraph short-circuits with + // the existing node and would never clear the flag — leaving it + // stuck `true` and silently bailing every subsequent call. + micStopRequestedRef.current = false; + const node = await ensureGraph(); + // If a stop landed between ensureGraph resolving and now, don't + // open the gate — the worklet stays muted. + if (!node || micStopRequestedRef.current) return; + node.port.postMessage('start'); + }; + const stopMic = () => { - // Flag for any in-flight startMic to bail before it attaches. + // Flag for any in-flight ensureGraph to bail before opening the + // gate. If the graph already exists, just mute the worklet — + // tearing down would force a fresh getUserMedia next turn. micStopRequestedRef.current = true; - scriptNodeRef.current?.disconnect(); - scriptNodeRef.current = null; - mediaStreamRef.current?.getTracks().forEach((t) => t.stop()); - mediaStreamRef.current = null; - audioCtxRef.current?.close(); - audioCtxRef.current = null; + workletNodeRef.current?.port.postMessage('stop'); }; const unsubStart = window.flicky.onStartCapture(() => startMic()); @@ -146,7 +201,18 @@ export function OverlayApp() { unsubStart(); unsubStop(); unsubPlayAudio(); - stopMic(); + // Real teardown on unmount — stopMic only mutes the worklet so + // back-to-back PTT turns stay warm. When the overlay actually + // goes away (display unplug, app quit) we release the mic and + // close the AudioContext. + micStopRequestedRef.current = true; + workletNodeRef.current?.port.postMessage('stop'); + workletNodeRef.current?.disconnect(); + workletNodeRef.current = null; + mediaStreamRef.current?.getTracks().forEach((t) => t.stop()); + mediaStreamRef.current = null; + void audioCtxRef.current?.close(); + audioCtxRef.current = null; }; }, []); @@ -196,6 +262,8 @@ export function OverlayApp() { }, [cursorPos, cursorMode, setCompanionPosSync]); useEffect(() => { + // displayRef is seeded synchronously from launch args above; this + // push also carries bounds updates after a DPI / resolution change. const unsubDisplayInfo = window.flicky.onDisplayInfo((info) => { displayRef.current = info; }); @@ -203,6 +271,14 @@ export function OverlayApp() { const unsubs = [ window.flicky.onVoiceStateChanged(setVoiceState), window.flicky.onCursorPosition((pos) => { + // Main now sends a `{ off: true }` pulse to whichever overlay + // previously owned the cursor when it leaves that display. + // We stop receiving regular updates entirely once the cursor + // is off-display, which is why this signal is needed at all. + if ((pos as { off?: boolean }).off) { + setIsCursorOnThisDisplay(false); + return; + } const bounds = displayRef.current?.bounds; if (bounds) { const onThis = @@ -211,40 +287,60 @@ export function OverlayApp() { setIsCursorOnThisDisplay(onThis); setCursorPos({ x: pos.x - bounds.x, y: pos.y - bounds.y }); } else { - setIsCursorOnThisDisplay(true); + // Bounds unknown — hide the companion rather than risk + // showing it on every display. Will flip on as soon as + // display-info arrives. + setIsCursorOnThisDisplay(false); setCursorPos(pos); } }), - window.flicky.onElementDetected((el) => { - if (el) { - if (returnAnimRef.current) { - cancelAnimationFrame(returnAnimRef.current); - returnAnimRef.current = null; - } - if (holdTimerRef.current) { - clearTimeout(holdTimerRef.current); - holdTimerRef.current = null; - } - - setPointingPhrase(randomPhrase()); - setDetectedElement(el); - - const bounds = displayRef.current?.bounds; - const localTarget = { - x: el.x - (bounds?.x ?? 0), - y: el.y - (bounds?.y ?? 0), - }; - setCompanionPosSync(localTarget); - setCursorModeSync('navigating'); - - setTimeout(() => setCursorModeSync('holding'), 650); - } else { - setDetectedElement(null); + window.flicky.onWalkthrough((w) => { + // Cache the steps. Main drives advancement via WALKTHROUGH_STEP. + if (returnAnimRef.current) { + cancelAnimationFrame(returnAnimRef.current); + returnAnimRef.current = null; + } + if (holdTimerRef.current) { + clearTimeout(holdTimerRef.current); + holdTimerRef.current = null; + } + if (!w) { + stepsRef.current = []; + setCurrentStep(null); holdTimerRef.current = setTimeout(() => { holdTimerRef.current = null; startReturnAnimation(); - }, 3000); + }, 1500); + return; + } + stepsRef.current = w.steps; + }), + window.flicky.onTypeFulfilled((req) => { + if (typeToastTimerRef.current) clearTimeout(typeToastTimerRef.current); + setTypeToast(req); + typeToastTimerRef.current = setTimeout(() => { + setTypeToast(null); + typeToastTimerRef.current = null; + }, 5000); + }), + window.flicky.onWalkthroughStep((i) => { + if (i === null) { + // Walkthrough ending — the WALKTHROUGH(null) event will + // schedule the return animation. Just clear current step UI. + setCurrentStep(null); + return; } + const step = stepsRef.current[i]; + if (!step) return; + setPointingPhrase(randomPhrase()); + setCurrentStep(step); + const bounds = displayRef.current?.bounds; + setCompanionPosSync({ + x: step.x - (bounds?.x ?? 0), + y: step.y - (bounds?.y ?? 0), + }); + setCursorModeSync('navigating'); + setTimeout(() => setCursorModeSync('holding'), 650); }), ]; @@ -252,6 +348,7 @@ export function OverlayApp() { unsubDisplayInfo(); unsubs.forEach((u) => u()); if (holdTimerRef.current) clearTimeout(holdTimerRef.current); + if (typeToastTimerRef.current) clearTimeout(typeToastTimerRef.current); if (returnAnimRef.current) cancelAnimationFrame(returnAnimRef.current); }; }, [setCursorModeSync, setCompanionPosSync, startReturnAnimation]); @@ -260,7 +357,8 @@ export function OverlayApp() { if (voiceState === 'listening') { // User started a new turn — interrupt anything Flicky was saying. stopCurrentTts(); - setDetectedElement(null); + setCurrentStep(null); + stepsRef.current = []; if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; @@ -275,7 +373,23 @@ export function OverlayApp() { const isNavigating = cursorMode === 'navigating'; const isHolding = cursorMode === 'holding'; - const showOnThisDisplay = isCursorOnThisDisplay || isNavigating || isHolding; + + // Walkthrough steps are always *on* one specific display (the one the + // cursor was on when the screenshot was taken). Render the annotated + // cursor only on that display so users with multiple monitors don't + // see the blue cursor flying around on a screen the step isn't on. + const isStepOnThisDisplay = (() => { + if (!currentStep) return false; + const b = displayRef.current?.bounds; + if (!b) return false; + return ( + currentStep.x >= b.x && currentStep.x < b.x + b.width && + currentStep.y >= b.y && currentStep.y < b.y + b.height + ); + })(); + + const showOnThisDisplay = + isNavigating || isHolding ? isStepOnThisDisplay : isCursorOnThisDisplay; const cursorTransition = isNavigating ? 'left 0.6s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.6s cubic-bezier(0.34, 1.56, 0.64, 1)' @@ -283,12 +397,20 @@ export function OverlayApp() { ? 'left 0.05s linear, top 0.05s linear' : 'none'; - void detectedElement; + const showAnnotation = (isNavigating || isHolding) && isStepOnThisDisplay; + const isMultiStep = (currentStep?.total ?? 0) > 1; return (
{showOnThisDisplay && ( <> + {showAnnotation && ( +
+ )} +
)} - {(isNavigating || isHolding) && pointingPhrase && ( + {showAnnotation && (
- {pointingPhrase} + {isMultiStep && ( + + {currentStep!.step} + /{currentStep!.total} + + )} + + {isMultiStep ? currentStep!.label : pointingPhrase} +
)} )} + + {typeToast && isCursorOnThisDisplay && ( +
+
+ + {typeToast.autoTyped ? '⌨️' : '📋'} + +
+
+ {typeToast.autoTyped ? ( + 'Typed for you' + ) : ( + <> + Copied — press{' '} + {window.flicky.platform === 'darwin' ? '⌘V' : 'Ctrl+V'} to paste + + )} +
+
“{typeToast.preview}”
+
+
+
+ )}
); } diff --git a/src/renderer/components/PanelApp.tsx b/src/renderer/components/PanelApp.tsx index 0ecd21b..5e34ba3 100644 --- a/src/renderer/components/PanelApp.tsx +++ b/src/renderer/components/PanelApp.tsx @@ -7,6 +7,7 @@ import { VoiceTab } from './panel/VoiceTab'; import { EarTab } from './panel/EarTab'; import { GeneralTab } from './panel/GeneralTab'; import { PermissionsBanner } from './panel/PermissionsBanner'; +import { Onboarding } from './panel/Onboarding'; import { CursorIcon } from './CursorIcon'; type Tab = 'home' | 'chats' | 'mind' | 'voice' | 'ear' | 'general'; @@ -16,22 +17,47 @@ export function PanelApp() { const [settings, setSettings] = useState(null); const [memory, setMemory] = useState(null); const [tab, setTab] = useState('home'); + const [version, setVersion] = useState(''); + const [lastError, setLastError] = useState(null); useEffect(() => { window.flicky.getSettings().then(setSettings); window.flicky.getMemoryStats().then(setMemory); + window.flicky.getAppVersion().then(setVersion).catch(() => {}); + let errTimer: ReturnType | null = null; const unsubs = [ window.flicky.onVoiceStateChanged(setVoiceState), window.flicky.onSettingsChanged(setSettings), window.flicky.onMemoryStats(setMemory), + // Turn failures used to vanish into the main-process console. Show + // them in a dismissable strip so "nothing happened" becomes "here's + // why nothing happened". + window.flicky.onAiError((m) => { + setLastError(m); + if (errTimer) clearTimeout(errTimer); + errTimer = setTimeout(() => setLastError(null), 12_000); + }), ]; - return () => unsubs.forEach((u) => u()); + return () => { + unsubs.forEach((u) => u()); + if (errTimer) clearTimeout(errTimer); + }; }, []); if (!settings) return null; + if (!settings.onboardingComplete) { + return ; + } + const { apiKeyStatus } = settings; + const mindNeeds = + settings.mindProvider === 'openai' + ? !apiKeyStatus.openai + : settings.mindProvider === 'ollama' + ? !(settings.localConnections ?? []).some((c) => c.enabled) + : !apiKeyStatus.anthropic; const navItem = ( id: Tab, @@ -62,13 +88,8 @@ export function PanelApp() { {navItem('chats', 'Chats')}
Providers
- {navItem('mind', 'Mind', { - needs: - settings.mindProvider === 'openai' - ? !apiKeyStatus.openai - : !apiKeyStatus.anthropic, - })} - {navItem('voice', 'Voice', { needs: !apiKeyStatus.elevenlabs })} + {navItem('mind', 'Mind', { needs: mindNeeds })} + {navItem('voice', 'Voice', { needs: settings.speakReplies && !apiKeyStatus.elevenlabs })} {navItem('ear', 'Ear', { needs: !apiKeyStatus.groq })}
System
@@ -79,12 +100,18 @@ export function PanelApp() { -
v0.1.0
+
{version ? `v${version}` : ''}
+ {lastError && ( +
+ {lastError} + +
+ )} {!settings.encryptionAvailable && (
diff --git a/src/renderer/components/StreamApp.tsx b/src/renderer/components/StreamApp.tsx index 26667d5..f66f948 100644 --- a/src/renderer/components/StreamApp.tsx +++ b/src/renderer/components/StreamApp.tsx @@ -1,5 +1,10 @@ import { useEffect, useRef, useState } from 'react'; -import type { ChatEntry, TranscriptionResult, VoiceState } from '../../shared/types'; +import type { + ChatEntry, + TranscriptionResult, + VoiceState, + Walkthrough, +} from '../../shared/types'; interface Turn { id: string; @@ -23,6 +28,8 @@ function makeId(): string { export function StreamApp() { const [turns, setTurns] = useState([]); const [voiceState, setVoiceState] = useState('idle'); + const [walkthrough, setWalkthrough] = useState(null); + const [activeStep, setActiveStep] = useState(null); const bodyRef = useRef(null); /** Tracks the in-progress turn so chunks can append to it. */ const currentIdRef = useRef(null); @@ -85,9 +92,13 @@ export function StreamApp() { currentIdRef.current = null; }); - const unsubClear = window.flicky.onClearStream(() => { - setTurns([]); - currentIdRef.current = null; + const unsubWalkthrough = window.flicky.onWalkthrough((w) => { + setWalkthrough(w); + if (!w) setActiveStep(null); + }); + + const unsubWalkthroughStep = window.flicky.onWalkthroughStep((i) => { + setActiveStep(i); }); return () => { @@ -95,15 +106,26 @@ export function StreamApp() { unsubTranscript(); unsubChunk(); unsubComplete(); - unsubClear(); + unsubWalkthrough(); + unsubWalkthroughStep(); }; }, []); - // Auto-scroll to bottom whenever content grows. + // Auto-scroll to bottom while content grows — but only if the user + // is already pinned at the bottom. If they've scrolled up to read an + // earlier turn, we leave their viewport alone instead of yanking them + // back. The scroll write itself is deferred to rAF so a long token + // stream doesn't force synchronous layout on every chunk. useEffect(() => { const el = bodyRef.current; if (!el) return; - el.scrollTop = el.scrollHeight; + const PIN_THRESHOLD_PX = 24; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceFromBottom > PIN_THRESHOLD_PX) return; + const raf = requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); + return () => cancelAnimationFrame(raf); }, [turns]); const statusLabel = @@ -122,17 +144,52 @@ export function StreamApp() {
- {turns.length === 0 ? ( + {walkthrough && walkthrough.steps.length > 0 && ( +
+
+ Walkthrough + + {activeStep === null + ? `${walkthrough.steps.length} step${walkthrough.steps.length === 1 ? '' : 's'}` + : `step ${activeStep + 1} of ${walkthrough.steps.length}`} + +
+
    + {walkthrough.steps.map((s, i) => { + const state = + activeStep === null + ? 'pending' + : i < activeStep + ? 'done' + : i === activeStep + ? 'active' + : 'pending'; + return ( +
  1. + + {state === 'done' ? '✓' : i + 1} + + {s.label} +
  2. + ); + })} +
+
+ )} + {turns.length === 0 && !walkthrough ? (
Hold the push-to-talk shortcut and start talking. The live Q/A will appear here.
- ) : ( + ) : turns.length === 0 ? null : ( turns.map((t) => (
You
diff --git a/src/renderer/components/panel/GeneralTab.tsx b/src/renderer/components/panel/GeneralTab.tsx index 73dd425..14979ee 100644 --- a/src/renderer/components/panel/GeneralTab.tsx +++ b/src/renderer/components/panel/GeneralTab.tsx @@ -60,6 +60,7 @@ export function GeneralTab({ settings, memory }: GeneralTabProps) { pct < 60 ? 'var(--fl-ok)' : pct < 85 ? 'var(--fl-warn)' : 'var(--fl-danger)'; const shortcutKeys = settings.pushToTalkShortcut.split('+').filter(Boolean); + const isMac = window.flicky.platform === 'darwin'; return ( <> @@ -73,7 +74,11 @@ export function GeneralTab({ settings, memory }: GeneralTabProps) {
Push to talk
-
hold to speak from anywhere on your machine
+
+ {settings.pttMode === 'toggle' + ? 'tap once to start, tap again to stop' + : 'hold to speak, release to send'} +
{editingShortcut ? ( )}
+
+
+
Trigger style
+
+ {isMac + ? 'macOS only supports tap-toggle — Electron can’t see the key release for hold-to-talk.' + : 'pick how the shortcut behaves'} +
+
+
+ + +
+
@@ -154,6 +189,21 @@ export function GeneralTab({ settings, memory }: GeneralTabProps) { aria-label="Toggle cursor" />
+
+
+
Allow Flicky to type for you
+
+ when off (default), Flicky copies text to your clipboard and you press paste. + when on, Flicky types directly into the focused field + {isMac && <> — requires Accessibility permission on macOS}. +
+
+
Launch at login
@@ -165,6 +215,15 @@ export function GeneralTab({ settings, memory }: GeneralTabProps) { aria-label="Toggle launch at login" />
+
+
+
Setup
+
re-check permissions, keys, the shortcut and your mic step by step
+
+ +
Stream window
diff --git a/src/renderer/components/panel/HomeTab.tsx b/src/renderer/components/panel/HomeTab.tsx index 9fd935a..078d1d5 100644 --- a/src/renderer/components/panel/HomeTab.tsx +++ b/src/renderer/components/panel/HomeTab.tsx @@ -17,12 +17,19 @@ function formatTokens(n: number): string { export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabProps) { const { apiKeyStatus, mindProvider } = settings; + const localConn = (settings.localConnections ?? []).find((c) => c.enabled); const mindReady = - mindProvider === 'openai' ? apiKeyStatus.openai : apiKeyStatus.anthropic; - const connectedCount = [mindReady, apiKeyStatus.elevenlabs, apiKeyStatus.groq].filter( - Boolean, - ).length; - const ready = connectedCount === 3; + mindProvider === 'openai' + ? apiKeyStatus.openai + : mindProvider === 'ollama' + ? !!localConn + : apiKeyStatus.anthropic; + // Voice is only a requirement when the user wants spoken replies. + const voiceRequired = settings.speakReplies; + const required = [mindReady, apiKeyStatus.groq, ...(voiceRequired ? [apiKeyStatus.elevenlabs] : [])]; + const connectedCount = required.filter(Boolean).length; + const ready = connectedCount === required.length; + const total = required.length; const modelLabel = mindProvider === 'openai' @@ -31,9 +38,11 @@ export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabPro : settings.selectedOpenAIModel === 'gpt-5-mini' ? 'GPT-5 mini' : 'GPT-4o' - : settings.selectedModel === 'claude-sonnet-4-6' - ? 'Claude Sonnet 4.6' - : 'Claude Opus 4.6'; + : mindProvider === 'ollama' + ? (localConn?.activeModelId ?? localConn?.modelIds[0] ?? 'Local model') + : settings.selectedModel === 'claude-sonnet-4-6' + ? 'Claude Sonnet 4.6' + : 'Claude Opus 4.6'; const pct = memory ? Math.round((memory.tokens / memory.tokenBudget) * 100) : 0; @@ -47,7 +56,7 @@ export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabPro

{ready ? 'Hold the push-to-talk shortcut from anywhere and Flicky will listen, think, and reply.' - : `${connectedCount} of 3 providers connected. Add the remaining keys to start talking.`} + : `${connectedCount} of ${total} providers connected. Add the remaining keys to start talking.`}

@@ -88,7 +97,7 @@ export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabPro ? 'Ready' : 'Setup needed'}
-
{ready ? 'all providers connected' : `${connectedCount} of 3 connected`}
+
{ready ? 'all providers connected' : `${connectedCount} of ${total} connected`}
@@ -119,11 +128,11 @@ export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabPro

Connected providers

-
- {mindProvider === 'openai' ? 'Ai' : 'A'} +
+ {mindProvider === 'openai' ? 'Ai' : mindProvider === 'ollama' ? '⬡' : 'A'}
- {mindProvider === 'openai' ? 'OpenAI' : 'Anthropic'}{' '} + {mindProvider === 'openai' ? 'OpenAI' : mindProvider === 'ollama' ? 'Local' : 'Anthropic'}{' '} · reasoning
{mindReady ? ( @@ -139,6 +148,8 @@ export function HomeTab({ voiceState, settings, memory, onNavigate }: HomeTabPro
{apiKeyStatus.elevenlabs ? ( Connected + ) : !voiceRequired ? ( + off — text only ) : ( )} diff --git a/src/renderer/components/panel/KeyEntry.tsx b/src/renderer/components/panel/KeyEntry.tsx new file mode 100644 index 0000000..69b7a5f --- /dev/null +++ b/src/renderer/components/panel/KeyEntry.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import type { ApiKeyName } from '../../../shared/types'; + +interface KeyEntryProps { + name: ApiKeyName; + placeholder: string; + /** Fires after the key was stored (validated or force-saved). */ + onSaved?: () => void; + onCancel?: () => void; + autoFocus?: boolean; + /** Label for the primary button. */ + saveLabel?: string; +} + +type Phase = + | { kind: 'idle' } + | { kind: 'testing' } + | { kind: 'failed'; error: string }; + +/** + * Password field + "Test & save". Round-trips the key against the + * provider before persisting so a mistyped key is caught here instead + * of surfacing as a silent failure on the first push-to-talk. If the + * check fails the user can still force-save (offline, proxy, etc.). + */ +export function KeyEntry({ + name, + placeholder, + onSaved, + onCancel, + autoFocus = true, + saveLabel = 'Test & save', +}: KeyEntryProps) { + const [value, setValue] = useState(''); + const [phase, setPhase] = useState({ kind: 'idle' }); + + const commit = () => { + window.flicky.setApiKey(name, value.trim()); + setValue(''); + setPhase({ kind: 'idle' }); + onSaved?.(); + }; + + const testAndSave = async () => { + const v = value.trim(); + if (!v || phase.kind === 'testing') return; + setPhase({ kind: 'testing' }); + const res = await window.flicky.validateApiKey(name, v); + if (res.ok) { + commit(); + } else { + setPhase({ kind: 'failed', error: res.error ?? 'Validation failed.' }); + } + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') void testAndSave(); + else if (e.key === 'Escape') { + setValue(''); + setPhase({ kind: 'idle' }); + onCancel?.(); + } + }; + + const busy = phase.kind === 'testing'; + + return ( +
+
+ { + setValue(e.target.value); + if (phase.kind === 'failed') setPhase({ kind: 'idle' }); + }} + onKeyDown={onKeyDown} + spellCheck={false} + autoComplete="off" + /> + + {onCancel && ( + + )} +
+ {phase.kind === 'failed' && ( +
+ {phase.error} + +
+ )} +
+ ); +} diff --git a/src/renderer/components/panel/Onboarding.tsx b/src/renderer/components/panel/Onboarding.tsx new file mode 100644 index 0000000..ba64a48 --- /dev/null +++ b/src/renderer/components/panel/Onboarding.tsx @@ -0,0 +1,835 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + ApiKeyName, + FlickySettings, + MindProvider, + PermissionStatus, + VoiceState, +} from '../../../shared/types'; +import { CursorIcon } from '../CursorIcon'; +import { KeyEntry } from './KeyEntry'; +import { ShortcutCapture } from './ShortcutCapture'; + +/** + * First-run setup. Each step verifies the thing it configures — a key + * is round-tripped against its provider, the shortcut has to actually + * fire, the mic has to actually produce level, and the final step runs + * a real end-to-end turn — so a user who reaches "Done" has a working + * Flicky, not just a filled-in form. + */ + +type StepId = + | 'welcome' + | 'permissions' + | 'mind' + | 'ear' + | 'voice' + | 'shortcut' + | 'mic' + | 'try' + | 'done'; + +interface StepMeta { + id: StepId; + title: string; +} + +const platform = window.flicky.platform; +const isMac = platform === 'darwin'; +const isWin = platform === 'win32'; + +const ALL_STEPS: StepMeta[] = [ + { id: 'welcome', title: 'Welcome' }, + { id: 'permissions', title: 'Permissions' }, + { id: 'mind', title: 'Mind' }, + { id: 'ear', title: 'Ear' }, + { id: 'voice', title: 'Voice' }, + { id: 'shortcut', title: 'Shortcut' }, + { id: 'mic', title: 'Mic check' }, + { id: 'try', title: 'Try it' }, + { id: 'done', title: 'Done' }, +]; + +// Linux has no OS-level mic/screen gate we can query; skip the step. +const STEPS = ALL_STEPS.filter((s) => s.id !== 'permissions' || isMac || isWin); + +interface OnboardingProps { + settings: FlickySettings; + voiceState: VoiceState; +} + +export function Onboarding({ settings, voiceState }: OnboardingProps) { + const [index, setIndex] = useState(0); + const step = STEPS[index]; + const next = useCallback(() => setIndex((i) => Math.min(STEPS.length - 1, i + 1)), []); + const back = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []); + + return ( +
+ + +
+ {step.id === 'welcome' && } + {step.id === 'permissions' && } + {step.id === 'mind' && } + {step.id === 'ear' && } + {step.id === 'voice' && } + {step.id === 'shortcut' && } + {step.id === 'mic' && } + {step.id === 'try' && ( + + )} + {step.id === 'done' && } +
+
+ ); +} + +// ── Shared bits ──────────────────────────────────────────────────────── + +function Keys({ shortcut }: { shortcut: string }) { + const keys = shortcut.split('+').filter(Boolean); + return ( + + {keys.map((k, i) => ( + + {k} + {i < keys.length - 1 && +} + + ))} + + ); +} + +interface FooterProps { + onBack?: () => void; + onNext?: () => void; + nextLabel?: string; + nextDisabled?: boolean; + nextHint?: string; + secondary?: React.ReactNode; +} + +function Footer({ onBack, onNext, nextLabel = 'Continue', nextDisabled, nextHint, secondary }: FooterProps) { + return ( +
+ {onBack ? ( + + ) : } +
+ {nextHint && {nextHint}} + {secondary} + {onNext && ( + + )} +
+
+ ); +} + +function Status({ kind, children }: { kind: 'ok' | 'warn' | 'err' | 'wait'; children: React.ReactNode }) { + return ( +
+ + {kind === 'ok' ? '✓' : kind === 'warn' ? '!' : kind === 'err' ? '×' : } + + {children} +
+ ); +} + +/** + * A key that's already in the store. Re-validated live on mount so a + * key saved last month that has since been revoked (or an account that + * ran out of credit) is caught here, not on the first real question. + */ +function SavedKey({ name, label, onReplace, extra }: { + name: ApiKeyName; + label: string; + onReplace: () => void; + extra?: React.ReactNode; +}) { + const [state, setState] = useState<{ kind: 'wait' } | { kind: 'ok' } | { kind: 'err'; error: string }>({ kind: 'wait' }); + const run = useCallback(async () => { + setState({ kind: 'wait' }); + const res = await window.flicky.validateStoredApiKey(name); + setState(res.ok ? { kind: 'ok' } : { kind: 'err', error: res.error ?? 'Validation failed.' }); + }, [name]); + useEffect(() => { void run(); }, [run]); + + return ( + <> +
{label}
+ {state.kind === 'wait' && testing the saved key against the provider…} + {state.kind === 'ok' && connected — the provider accepted this key} + {state.kind === 'err' && {state.error}} +
+ + + {extra} +
+ + ); +} + +// ── 1. Welcome ───────────────────────────────────────────────────────── + +function WelcomeStep({ onNext }: { onNext: () => void }) { + return ( + <> +
+

Hi, I'm Flicky.

+

+ A voice assistant that can see your screen. Hold a shortcut, ask a question, and a + little blue cursor flies over to point at whatever I'm talking about. +

+
    +
  • + Hold, talk, release. No wake word, no window to click into — it works from + any app. +
  • +
  • + I see what you see. A screenshot of your current screen goes with every + question so I can answer “what does this error mean?” or “where's + the export button?” +
  • +
  • + Bring your own keys. You connect your own accounts for reasoning, + transcription and voice. Keys are encrypted on this machine and only ever sent to + the provider they belong to. +
  • +
+

+ Setup takes about three minutes. Each step checks itself, so when you reach the end + everything actually works. +

+