diff --git a/package.json b/package.json index 0dbe30f3..4547f74f 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "date-fns": "^4.4.0", "lucide-react": "^1.45.0", "radix-ui": "^1.6.7", "react": "^19.2.0", "react-day-picker": "^10.0.1", "react-dom": "^19.2.0", - "recharts": "3.8.0", "sonner": "^2.0.8", "tailwind-merge": "^3.7.0", "tw-animate-css": "^1.4.0" @@ -44,7 +42,7 @@ "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.1.0", "shadcn": "^4.21.0", - "sharp": "0.35.4", + "sharp": "0.35.5", "tailwindcss": "^4.1.0", "typescript": "^5.9.0", "vite": "^7.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbbe6370..7d9c7e3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,9 +41,6 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - date-fns: - specifier: ^4.4.0 - version: 4.4.0 lucide-react: specifier: ^1.45.0 version: 1.45.0(react@19.2.8) @@ -59,9 +56,6 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.8(react@19.2.8) - recharts: - specifier: 3.8.0 - version: 3.8.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1) sonner: specifier: ^2.0.8 version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -94,8 +88,8 @@ importers: specifier: ^4.21.0 version: 4.21.0(typescript@5.9.3) sharp: - specifier: 0.35.4 - version: 0.35.4(@types/node@26.5.1) + specifier: 0.35.5 + version: 0.35.5(@types/node@26.5.1) tailwindcss: specifier: ^4.1.0 version: 4.3.3 @@ -495,160 +489,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -684,8 +678,8 @@ packages: '@marijn/find-cluster-break@1.0.4': resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} - '@modelcontextprotocol/sdk@1.30.0': - resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1403,17 +1397,6 @@ packages: '@radix-ui/rect@1.1.3': resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} - '@reduxjs/toolkit@2.12.0': - resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} - peerDependencies: - react: ^16.9.0 || ^17.0.0 || ^18 || ^19 - react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 - peerDependenciesMeta: - react: - optional: true - react-redux: - optional: true - '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -1562,12 +1545,6 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@standard-schema/utils@0.3.0': - resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1759,33 +1736,6 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/d3-array@3.2.2': - resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} - - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-ease@3.0.2': - resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-path@3.1.1': - resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} - - '@types/d3-scale@4.0.9': - resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - - '@types/d3-shape@3.2.0': - resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} - - '@types/d3-time@3.0.4': - resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} - - '@types/d3-timer@3.0.2': - resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1803,9 +1753,6 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} - '@types/use-sync-external-store@0.0.6': - resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} - '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -2032,50 +1979,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - d3-array@3.2.4: - resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} - engines: {node: '>=12'} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-format@3.1.2: - resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} @@ -2092,9 +1995,6 @@ packages: supports-color: optional: true - decimal.js-light@2.5.1: - resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} - dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -2193,9 +2093,6 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - es-toolkit@1.52.0: - resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -2220,9 +2117,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - eventsource-parser@3.1.1: resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} @@ -2389,12 +2283,6 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - immer@10.2.0: - resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} - - immer@11.1.18: - resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==} - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2402,10 +2290,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - internmap@2.0.3: - resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} - engines: {node: '>=12'} - ip-address@10.7.2: resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} engines: {node: '>= 12'} @@ -2854,8 +2738,8 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} qs@6.16.0: @@ -2901,21 +2785,6 @@ packages: peerDependencies: react: ^19.2.8 - react-is@19.3.0: - resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==} - - react-redux@9.3.0: - resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} - peerDependencies: - '@types/react': ^18.2.25 || ^19 - react: ^18.0 || ^19 - redux: ^5.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - redux: - optional: true - react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -2958,29 +2827,10 @@ packages: resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} - recharts@3.8.0: - resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==} - engines: {node: '>=18'} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - redux-thunk@3.1.0: - resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} - peerDependencies: - redux: ^5.0.0 - - redux@5.0.1: - resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - reselect@5.1.1: - resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} - reselect@5.3.0: resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} @@ -3043,8 +2893,8 @@ packages: engines: {node: '>=20.18.1'} hasBin: true - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -3107,8 +2957,8 @@ packages: '@types/react': optional: true - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map@0.6.1: @@ -3282,9 +3132,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - victory-vendor@37.3.6: - resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - vite@7.3.6: resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3808,108 +3655,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -3949,7 +3796,7 @@ snapshots: '@marijn/find-cluster-break@1.0.4': {} - '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.32.1(zod@3.25.76)': dependencies: '@hono/node-server': 2.1.1(hono@4.13.7) ajv: 8.20.0 @@ -4733,18 +4580,6 @@ snapshots: '@radix-ui/rect@1.1.3': {} - '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@standard-schema/utils': 0.3.0 - immer: 11.1.18 - redux: 5.0.1 - redux-thunk: 3.1.0(redux@5.0.1) - reselect: 5.1.1 - optionalDependencies: - react: 19.2.8 - react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) - '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.63.1': @@ -4826,10 +4661,6 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@standard-schema/spec@1.1.0': {} - - '@standard-schema/utils@0.3.0': {} - '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -4837,7 +4668,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.3 '@tailwindcss/oxide-android-arm64@4.3.3': @@ -4979,30 +4810,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/d3-array@3.2.2': {} - - '@types/d3-color@3.1.3': {} - - '@types/d3-ease@3.0.2': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-path@3.1.1': {} - - '@types/d3-scale@4.0.9': - dependencies: - '@types/d3-time': 3.0.4 - - '@types/d3-shape@3.2.0': - dependencies: - '@types/d3-path': 3.1.1 - - '@types/d3-time@3.0.4': {} - - '@types/d3-timer@3.0.2': {} - '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -5019,8 +4826,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/use-sync-external-store@0.0.6': {} - '@types/validate-npm-package-name@4.0.2': {} '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@26.5.1)(jiti@2.7.0)(lightningcss@1.32.0))': @@ -5227,44 +5032,6 @@ snapshots: csstype@3.2.3: {} - d3-array@3.2.4: - dependencies: - internmap: 2.0.3 - - d3-color@3.1.0: {} - - d3-ease@3.0.1: {} - - d3-format@3.1.2: {} - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@3.1.0: {} - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.2 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@3.0.1: {} - date-fns@4.4.0: {} debounce-fn@4.0.0: @@ -5275,8 +5042,6 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js-light@2.5.1: {} - dedent@1.7.2: {} deepmerge@4.3.1: {} @@ -5346,8 +5111,6 @@ snapshots: dependencies: es-errors: 1.3.0 - es-toolkit@1.52.0: {} - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -5389,8 +5152,6 @@ snapshots: etag@1.8.1: {} - eventemitter3@5.0.4: {} - eventsource-parser@3.1.1: {} eventsource@3.0.7: @@ -5455,7 +5216,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0 @@ -5594,10 +5355,6 @@ snapshots: ignore@5.3.2: {} - immer@10.2.0: {} - - immer@11.1.18: {} - import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -5605,8 +5362,6 @@ snapshots: inherits@2.0.4: {} - internmap@2.0.3: {} - ip-address@10.7.2: {} ipaddr.js@1.9.1: {} @@ -5931,7 +5686,7 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 powershell-utils@0.1.0: {} @@ -5946,7 +5701,7 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -6043,17 +5798,6 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 - react-is@19.3.0: {} - - react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): - dependencies: - '@types/use-sync-external-store': 0.0.6 - react: 19.2.8 - use-sync-external-store: 1.7.0(react@19.2.8) - optionalDependencies: - '@types/react': 19.2.18 - redux: 5.0.1 - react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): @@ -6093,36 +5837,8 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - recharts@3.8.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.3.0)(react@19.2.8)(redux@5.0.1): - dependencies: - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) - clsx: 2.1.1 - decimal.js-light: 2.5.1 - es-toolkit: 1.52.0 - eventemitter3: 5.0.4 - immer: 10.2.0 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-is: 19.3.0 - react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) - reselect: 5.1.1 - tiny-invariant: 1.3.3 - use-sync-external-store: 1.7.0(react@19.2.8) - victory-vendor: 37.3.6 - transitivePeerDependencies: - - '@types/react' - - redux - - redux-thunk@3.1.0(redux@5.0.1): - dependencies: - redux: 5.0.1 - - redux@5.0.1: {} - require-from-string@2.0.2: {} - reselect@5.1.1: {} - reselect@5.3.0: {} resolve-from@4.0.0: {} @@ -6224,7 +5940,7 @@ snapshots: '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) '@dotenvx/dotenvx': 1.75.1 - '@modelcontextprotocol/sdk': 1.30.0(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.32.1(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.9 cn: 0.2.6 @@ -6258,37 +5974,37 @@ snapshots: - supports-color - typescript - sharp@0.35.4(@types/node@26.5.1): + sharp@0.35.5(@types/node@26.5.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 '@types/node': 26.5.1 shebang-command@2.0.0: @@ -6347,7 +6063,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map@0.6.1: {} @@ -6476,23 +6192,6 @@ snapshots: vary@1.1.2: {} - victory-vendor@37.3.6: - dependencies: - '@types/d3-array': 3.2.2 - '@types/d3-ease': 3.0.2 - '@types/d3-interpolate': 3.0.4 - '@types/d3-scale': 4.0.9 - '@types/d3-shape': 3.2.0 - '@types/d3-time': 3.0.4 - '@types/d3-timer': 3.0.2 - d3-array: 3.2.4 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-scale: 4.0.2 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-timer: 3.0.1 - vite@7.3.6(@types/node@26.5.1)(jiti@2.7.0)(lightningcss@1.32.0): dependencies: esbuild: 0.28.2 diff --git a/src-tauri/crates/tw-adopt/src/clients.rs b/src-tauri/crates/tw-adopt/src/clients.rs index b5834c09..488d7b92 100644 --- a/src-tauri/crates/tw-adopt/src/clients.rs +++ b/src-tauri/crates/tw-adopt/src/clients.rs @@ -244,11 +244,28 @@ impl Gateway { } } - fn v1(&self) -> String { + /// 带 `/v1` 的地址:OpenAI 形状的客户端拼的是 `{base}/chat/completions` 这种 + pub(crate) fn v1(&self) -> String { format!("{}/v1", self.base.trim_end_matches('/')) } } +/// 网关列出来的模型,同名的只算第一个,先后照网关答的。写进配置的清单都过一遍:JSON 对象 +/// 里重复的键、TOML 里两张同名的表,各家解析器认哪一个说法不一 +pub(crate) fn unique(models: &[ModelCard]) -> Vec<&ModelCard> { + let mut seen = std::collections::HashSet::new(); + models + .iter() + .filter(|m| seen.insert(m.id.as_str())) + .collect() +} + +/// 网关不要密钥时,在要写密钥的地方写的那个值(Qwen Code 的 `settings.env`、Grok Build 的 +/// `api_key`)。**不能留空**:留空的话这两个客户端都会退回用户自己的凭据(各自为什么见 +/// [`crate::qwen::edits`]、[`crate::grok`] 文件开头)。这个值什么都打不开,网关会以「没有 +/// 这把密钥」拒绝;打码时认得它,它不是谁的密钥 +pub(crate) const NO_KEY: &str = "no-key"; + /// 我们要往配置里写的一个字段。 /// /// **这张表就是那个白名单,而它枚举的是「我们要写什么」** —— 有限、封闭、 @@ -1283,12 +1300,8 @@ impl Client { /// (上游、路由、手写的规格改过)。顺序不算,同名的只算第一个 pub fn models_stale(&self, written: &[ModelCard], now: &[ModelCard]) -> bool { let norm = |xs: &[ModelCard]| { - let mut seen = std::collections::HashSet::new(); - let mut v: Vec = xs - .iter() - .filter(|m| seen.insert(m.id.as_str())) - .map(|m| self.as_written(m)) - .collect(); + let mut v: Vec = + unique(xs).into_iter().map(|m| self.as_written(m)).collect(); v.sort(); v }; diff --git a/src-tauri/crates/tw-adopt/src/detect.rs b/src-tauri/crates/tw-adopt/src/detect.rs index b86ce659..6705d7aa 100644 --- a/src-tauri/crates/tw-adopt/src/detect.rs +++ b/src-tauri/crates/tw-adopt/src/detect.rs @@ -176,16 +176,6 @@ pub struct Finding { pub fix: Option, } -/// 只有 `ps` 那一支要:它拿到的是「跑了多久」,得从现在往回倒。Windows -/// 那一支直接拿到创建时刻。 -#[cfg(not(windows))] -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - /// `ps` 的 `etime`:`[[dd-]hh:]mm:ss`。 /// /// macOS 的 `ps` 没有 `etimes`(整秒),只有这个格式;`lstart` 是本地化 @@ -253,7 +243,7 @@ fn running_since(markers: &[&str]) -> Vec { else { return Vec::new(); }; - let now = now_ms(); + let now = crate::now_ms(); String::from_utf8_lossy(&out.stdout) .lines() .filter_map(parse_ps_line) @@ -474,9 +464,7 @@ fn env_conflicts(_home: &Path, names: &[&str]) -> Vec { HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, RRF_RT_ANY, RegGetValueW, }; - fn wide(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() - } + use crate::wide; fn is_set(root: HKEY, sub: &str, name: &str) -> bool { let (sub, name) = (wide(sub), wide(name)); let mut len: u32 = 0; diff --git a/src-tauri/crates/tw-adopt/src/foreign.rs b/src-tauri/crates/tw-adopt/src/foreign.rs index 7aa57981..da00ec92 100644 --- a/src-tauri/crates/tw-adopt/src/foreign.rs +++ b/src-tauri/crates/tw-adopt/src/foreign.rs @@ -129,13 +129,6 @@ pub struct Applied { pub warnings: Vec, } -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - /// 跟着符号链接走到真身。 /// /// 不用 `canonicalize`:文件还不存在时它直接失败,而「第一次接管、 @@ -368,17 +361,10 @@ fn replace(tmp: &Path, real: &Path) -> std::io::Result<()> { /// 也就是 unix 那条 `rename` 在这里的对应物。 #[cfg(windows)] fn replace(tmp: &Path, real: &Path) -> std::io::Result<()> { - use std::os::windows::ffi::OsStrExt; + use crate::wide; use windows_sys::Win32::Storage::FileSystem::{ MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, }; - - fn wide(p: &Path) -> Vec { - p.as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect() - } let (from, to) = (wide(tmp), wide(real)); let mv = |flags| { // SAFETY: 两个参数都是以 NUL 结尾的 UTF-16,函数只读它们。 @@ -429,6 +415,13 @@ fn replace_in_wsl( /// 工具会把 mtime 全改成同一天)。序号补零到固定宽度,同一毫秒里的 /// 几份也按先后排。 pub fn backup_root() -> PathBuf { + // 测试里不许落到真的备份目录:测试的备份目录建在临时目录里、显式传进去(桌面端的 `data_dir` + // 有同样的检查) + #[cfg(test)] + assert!( + std::env::var_os("THINKWATCH_HOME").is_some(), + "a test reached the real backup directory; give it a temporary one" + ); tw_api::data::dir().join("backups") } @@ -514,7 +507,7 @@ pub fn is_ours(root: &Path, backup: &Path) -> bool { } fn backup_to(root: &Path, real: &Path, text: impl AsRef<[u8]>) -> Result { - backup_at(root, real, text, now_ms()) + backup_at(root, real, text, crate::now_ms()) } /// 时间戳从外面传进来,测试才能稳定地造出「同一毫秒」。 diff --git a/src-tauri/crates/tw-adopt/src/grok.rs b/src-tauri/crates/tw-adopt/src/grok.rs index 3fd3e79d..aaa64305 100644 --- a/src-tauri/crates/tw-adopt/src/grok.rs +++ b/src-tauri/crates/tw-adopt/src/grok.rs @@ -12,9 +12,10 @@ //! 它自己也写这份文件(`/model`、`/settings`、自动更新之后),用的是重新序列化,注释和排版 //! 一律丢掉 —— 哨兵注释会跟着没了,旁文件里的记录还在,还原照样做得了。 -use crate::clients::{Edit, Gateway, ModelCard}; +use crate::clients::{Edit, Gateway, ModelCard, NO_KEY, unique}; use crate::json::Val; -use crate::plan::lookup; +use crate::pi::{count_of, flag_of}; +use crate::plan::{lookup, lookup_str}; /// 我们写的模型表的名字:`[model."thinkwatch/<模型>"]`。 /// @@ -52,15 +53,6 @@ pub fn backend_for(model: &str) -> &'static str { } } -/// 网关列出来的模型,同名的只算一次 -fn unique(gw: &Gateway) -> Vec<&ModelCard> { - let mut seen = std::collections::HashSet::new(); - gw.models - .iter() - .filter(|m| seen.insert(m.id.as_str())) - .collect() -} - /// 一张模型表里的字段。**没有网关密钥也要写一个不为空的 `api_key`**:空着的话 Grok 退回 /// 用户的会话令牌,见文件开头;这个值什么都打不开,网关会以「没有这把密钥」拒绝。 /// @@ -70,10 +62,7 @@ fn table(gw: &Gateway, m: &ModelCard) -> Vec<(String, Val)> { let mut t = vec![ ("model".into(), Val::s(model)), ("name".into(), Val::s(format!("{model} (ThinkWatch)"))), - ( - "base_url".into(), - Val::s(format!("{}/v1", gw.base.trim_end_matches('/'))), - ), + ("base_url".into(), Val::s(gw.v1())), ("api_backend".into(), Val::s(backend_for(model))), ( "api_key".into(), @@ -109,7 +98,7 @@ fn spec(m: &ModelCard) -> Vec<(String, Val)> { /// 手动配置那一页列的字段:每张表拆成一项一项,照着就能写。接管写整张表,见 [`edits`] pub fn fields(gw: &Gateway) -> Vec { - let mut v: Vec = unique(gw) + let mut v: Vec = unique(&gw.models) .into_iter() .flat_map(|m| { let k = key_of(&m.id); @@ -139,7 +128,7 @@ pub fn fields(gw: &Gateway) -> Vec { /// /// `current` 是此刻的 config.toml,读不出来就当空的 pub fn edits(gw: &Gateway, current: &str) -> Vec { - let models = unique(gw); + let models = unique(&gw.models); let Some(first) = models.first() else { return Vec::new(); }; @@ -177,9 +166,6 @@ pub fn edits(gw: &Gateway, current: &str) -> Vec { v } -/// 网关不要密钥时写进 `api_key` 的那个值。见 [`edits`] -pub const NO_KEY: &str = "no-key"; - /// 此刻 `[models] default` 选着的那一张 fn default_in(text: &str) -> Option { match crate::toml::get(text, &["models", "default"]).ok()?? { @@ -211,19 +197,6 @@ fn ours(text: &str) -> Vec<(String, Val)> { } } -fn field(v: &Val, key: &str) -> Option { - match v { - Val::Obj(ms) => ms - .iter() - .find(|(k, _)| k == key) - .and_then(|(_, v)| match v { - Val::Str(s) => Some(s.clone()), - _ => None, - }), - _ => None, - } -} - /// 配置里此刻指向哪儿:`[models] default` 选着的那一张是我们的,就是它的地址。 /// /// 默认模型换回了别的(xAI 的内置模型、用户自己的表),Grok 平常就不走网关了,那就是「我们 @@ -259,15 +232,9 @@ pub fn models_in(text: &str) -> Option> { ours(text) .iter() .map(|(k, t)| ModelCard { - id: field(t, "model").unwrap_or_else(|| k[KEY_PREFIX.len()..].to_string()), - context_window: match lookup(t, &["context_window"]) { - Some(Val::Num(n)) => n.parse().ok(), - _ => None, - }, - reasoning: match lookup(t, &["supports_reasoning_effort"]) { - Some(Val::Bool(b)) => Some(b), - _ => None, - }, + id: lookup_str(t, &["model"]).unwrap_or_else(|| k[KEY_PREFIX.len()..].to_string()), + context_window: count_of(lookup(t, &["context_window"])), + reasoning: flag_of(lookup(t, &["supports_reasoning_effort"])), ..Default::default() }) .collect(), @@ -306,7 +273,7 @@ pub fn secrets(text: &str) -> Vec { match crate::toml::get(text, &["model"]).ok().flatten() { Some(Val::Obj(ms)) => ms .iter() - .filter_map(|(_, t)| field(t, "api_key")) + .filter_map(|(_, t)| lookup_str(t, &["api_key"])) .filter(|k| k != NO_KEY) .collect(), _ => Vec::new(), diff --git a/src-tauri/crates/tw-adopt/src/hermes.rs b/src-tauri/crates/tw-adopt/src/hermes.rs index 5d4a8fbb..0681a08b 100644 --- a/src-tauri/crates/tw-adopt/src/hermes.rs +++ b/src-tauri/crates/tw-adopt/src/hermes.rs @@ -57,10 +57,7 @@ pub fn edits(gw: &Gateway, current: &str) -> Vec { value: Val::s(value), secret: false, }; - let mut v = vec![ - plain("provider", "custom"), - plain("base_url", &format!("{}/v1", gw.base.trim_end_matches('/'))), - ]; + let mut v = vec![plain("provider", "custom"), plain("base_url", &gw.v1())]; if let Some(k) = &gw.key { v.push(Edit { path: at("api_key"), diff --git a/src-tauri/crates/tw-adopt/src/lib.rs b/src-tauri/crates/tw-adopt/src/lib.rs index 0051e896..4ea111e9 100644 --- a/src-tauri/crates/tw-adopt/src/lib.rs +++ b/src-tauri/crates/tw-adopt/src/lib.rs @@ -32,3 +32,18 @@ pub mod wsl; pub mod wslconfig; pub mod yaml; pub mod yamlval; + +/// 现在,Unix 毫秒:备份的目录名、接管记录的时刻、客户端进程起了多久 +pub(crate) fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// 以 0 结尾的 UTF-16,交给 Windows 的 `…W` 函数(路径、注册表的键和值名) +#[cfg(windows)] +pub(crate) fn wide(s: impl AsRef) -> Vec { + use std::os::windows::ffi::OsStrExt; + s.as_ref().encode_wide().chain(std::iter::once(0)).collect() +} diff --git a/src-tauri/crates/tw-adopt/src/opencode.rs b/src-tauri/crates/tw-adopt/src/opencode.rs index 3f948ac2..891e87fa 100644 --- a/src-tauri/crates/tw-adopt/src/opencode.rs +++ b/src-tauri/crates/tw-adopt/src/opencode.rs @@ -65,11 +65,9 @@ pub fn shape_in(text: &str) -> Shape { /// **name 不留空**:opencode 的模型选择器显示的就是它,空着就是一行空白。 /// 同名的只写一次:JSON 对象里重复的键,各家解析器取哪一个说法不一。 pub fn models_val(models: &[ModelCard], shape: Shape) -> Val { - let mut seen = std::collections::HashSet::new(); Val::Obj( - models - .iter() - .filter(|m| seen.insert(m.id.as_str())) + crate::clients::unique(models) + .into_iter() .map(|m| { let mut fields = vec![("name".to_string(), Val::s(&m.id))]; fields.extend(specs(m, shape)); @@ -183,7 +181,7 @@ pub fn edits(gw: &Gateway, shape: Shape) -> Vec { }, Edit { path: at(&[shape.settings(), "baseURL"]), - value: Val::s(format!("{}/v1", gw.base.trim_end_matches('/'))), + value: Val::s(gw.v1()), secret: false, }, ]; diff --git a/src-tauri/crates/tw-adopt/src/pi.rs b/src-tauri/crates/tw-adopt/src/pi.rs index 6f3d26c2..96a7b7c5 100644 --- a/src-tauri/crates/tw-adopt/src/pi.rs +++ b/src-tauri/crates/tw-adopt/src/pi.rs @@ -30,7 +30,6 @@ //! **默认模型不动。**和 opencode 一样只加一个 provider:Pi 的 `settings.json`、omp 的 //! `config.yml` 一个字节都不改,ThinkWatch 的模型由用户在 `/model` 里选。 -use std::collections::HashSet; use std::path::Path; use tw_types::{Msg, msg}; @@ -129,11 +128,9 @@ pub fn api_for(model: &str) -> Api { /// 网关答了的规格也写上(见 [`specs`]);没答的那一项不写,Pi 照它的默认值跑,omp 按模型名 /// 从它自带的目录里补。同名的只写一次,先后照网关答的。 pub fn models_val(gw: &Gateway) -> Val { - let mut seen = HashSet::new(); Val::Arr( - gw.models - .iter() - .filter(|m| seen.insert(m.id.as_str())) + crate::clients::unique(&gw.models) + .into_iter() .map(|m| { let api = api_for(&m.id); let mut fields = vec![("id".to_string(), Val::s(&m.id))]; diff --git a/src-tauri/crates/tw-adopt/src/plan.rs b/src-tauri/crates/tw-adopt/src/plan.rs index 31c2de80..65191132 100644 --- a/src-tauri/crates/tw-adopt/src/plan.rs +++ b/src-tauri/crates/tw-adopt/src/plan.rs @@ -784,7 +784,13 @@ fn apply_file(plan: &Plan, backup_root: &Path) -> Result<(Applied, Option Option { Some(cur.clone()) } -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) +/// [`lookup`] 到的那一项,是字符串才算 +pub(crate) fn lookup_str(v: &Val, path: &[&str]) -> Option { + match lookup(v, path)? { + Val::Str(s) => Some(s), + _ => None, + } } /// 一份文件旁边的接管记录。**没有是 `None`;在却读不出来、解析不了是错**,不是「没有」。 diff --git a/src-tauri/crates/tw-adopt/src/qwen.rs b/src-tauri/crates/tw-adopt/src/qwen.rs index f8aac025..e1184c61 100644 --- a/src-tauri/crates/tw-adopt/src/qwen.rs +++ b/src-tauri/crates/tw-adopt/src/qwen.rs @@ -16,9 +16,10 @@ //! 而这个产品的规矩是如实说明客户端是谁。Chat Completions 拼的是 `{baseUrl}/chat/completions`, //! 所以写带 `/v1` 的地址。 -use crate::clients::{Edit, Gateway, ModelCard, PROVIDER_ID}; +use crate::clients::{Edit, Gateway, ModelCard, NO_KEY, PROVIDER_ID, unique}; use crate::json::Val; -use crate::plan::lookup; +use crate::pi::{count_of, flag_of}; +use crate::plan::{lookup, lookup_str}; /// 装着网关密钥的那个环境变量:写进 `settings.env`,每一条模型用 `envKey` 指着它。 /// @@ -27,23 +28,6 @@ use crate::plan::lookup; /// 名字的话,Qwen 的请求会记在 dsh 那把密钥名下 pub const KEY_ENV: &str = "THINKWATCH_QWEN_API_KEY"; -/// 网关不要密钥时写进那个变量的值。**不能留空**:变量是空的,Qwen 退回 -/// `OPENAI_API_KEY`,用户自己的密钥就发到了网关。这个值什么都打不开 -pub const NO_KEY: &str = "no-key"; - -/// 网关列出来的模型,同名的只算一次 -fn unique(gw: &Gateway) -> Vec<&ModelCard> { - let mut seen = std::collections::HashSet::new(); - gw.models - .iter() - .filter(|m| seen.insert(m.id.as_str())) - .collect() -} - -fn v1(gw: &Gateway) -> String { - format!("{}/v1", gw.base.trim_end_matches('/')) -} - fn at(path: &[&str]) -> Vec { path.iter().map(|s| s.to_string()).collect() } @@ -55,11 +39,11 @@ fn at(path: &[&str]) -> Vec { /// /// 网关一个模型都没列出来时什么都不写,接管说明里会说(`adopt.plan.no_models`) pub fn edits(gw: &Gateway, current: &str) -> Vec { - let models = unique(gw); + let models = unique(&gw.models); let Some(first) = models.first() else { return Vec::new(); }; - let base = v1(gw); + let base = gw.v1(); let entries: Vec = models .iter() .map(|m| { @@ -97,6 +81,7 @@ pub fn edits(gw: &Gateway, current: &str) -> Vec { value: Val::s(k), secret: true, }, + // **不能留空**:变量是空的,Qwen 退回 `OPENAI_API_KEY`,用户自己的密钥就发到了网关 None => plain(&["env", KEY_ENV], Val::s(NO_KEY)), }, plain(&["security", "auth", "selectedType"], Val::s("openai")), @@ -140,19 +125,6 @@ fn entries(text: &str) -> Vec { } } -fn field(v: &Val, key: &str) -> Option { - match v { - Val::Obj(ms) => ms - .iter() - .find(|(k, _)| k == key) - .and_then(|(_, v)| match v { - Val::Str(s) => Some(s.clone()), - _ => None, - }), - _ => None, - } -} - /// 配置里此刻指向哪儿:`model.name` 选着的是我们那一组里的一条,就是它的地址。 /// /// 用户在 `/model` 里换回了自己的模型,Qwen 就不走网关了 —— 那就是「我们写的不在了」 @@ -167,8 +139,8 @@ pub fn endpoint(text: &str) -> Option { }; entries(text) .iter() - .filter(|e| field(e, "id").as_deref() == Some(name.as_str())) - .filter_map(|e| field(e, "baseUrl")) + .filter(|e| lookup_str(e, &["id"]).as_deref() == Some(name.as_str())) + .filter_map(|e| lookup_str(e, &["baseUrl"])) .find(|b| picked.as_ref().is_none_or(|p| p == b)) } @@ -191,19 +163,10 @@ pub fn models_in(text: &str) -> Option> { entries(text) .iter() .filter_map(|e| { - let id = field(e, "id")?; - let context_window = match lookup(e, &["generationConfig", "contextWindowSize"]) { - Some(Val::Num(n)) => n.parse().ok(), - _ => None, - }; - let image_input = match lookup(e, &["generationConfig", "modalities", "image"]) { - Some(Val::Bool(b)) => Some(b), - _ => None, - }; Some(ModelCard { - id, - context_window, - image_input, + id: lookup_str(e, &["id"])?, + context_window: count_of(lookup(e, &["generationConfig", "contextWindowSize"])), + image_input: flag_of(lookup(e, &["generationConfig", "modalities", "image"])), ..Default::default() }) }) @@ -248,6 +211,7 @@ mod tests { panic!("{e:?}") }; assert_eq!(es.len(), 2); + let field = |e: &Val, k: &str| lookup_str(e, &[k]); assert_eq!(field(&es[0], "id").as_deref(), Some("qwen3-coder-plus")); assert_eq!( field(&es[0], "baseUrl").as_deref(), @@ -376,7 +340,7 @@ mod tests { let gc = |id: &str, path: &[&str]| { let e = entries(&text) .into_iter() - .find(|e| field(e, "id").as_deref() == Some(id)) + .find(|e| lookup_str(e, &["id"]).as_deref() == Some(id)) .unwrap(); lookup(&e, &[&["generationConfig"], path].concat()) }; diff --git a/src-tauri/crates/tw-adopt/src/wsl.rs b/src-tauri/crates/tw-adopt/src/wsl.rs index faf690b6..b4982058 100644 --- a/src-tauri/crates/tw-adopt/src/wsl.rs +++ b/src-tauri/crates/tw-adopt/src/wsl.rs @@ -271,10 +271,8 @@ fn run(args: &[&str], limit: std::time::Duration) -> Option { /// `GetVersionEx` —— 应用清单没声明兼容哪些版本时,那个函数报的是 Windows 8。 #[cfg(windows)] pub fn windows_build() -> Option { + use crate::wide; use windows_sys::Win32::System::Registry::{HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ, RegGetValueW}; - fn wide(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() - } let key = wide(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); let name = wide("CurrentBuildNumber"); let mut buf = [0u16; 32]; @@ -550,9 +548,7 @@ pub fn distros() -> Vec { RegEnumKeyExW, RegGetValueW, RegOpenKeyExW, }; - fn wide(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() - } + use crate::wide; fn dword(key: HKEY, sub: &[u16], name: &str) -> Option { let name = wide(name); let mut v: u32 = 0; diff --git a/src-tauri/examples/menubar_preview.rs b/src-tauri/examples/menubar_preview.rs index 3fa21f04..b062c097 100644 --- a/src-tauri/examples/menubar_preview.rs +++ b/src-tauri/examples/menubar_preview.rs @@ -1,17 +1,26 @@ //! 菜单栏的样子,用假数据看。**不起应用、不碰 `~/.thinkwatch` 和正在用的网关。** //! //! ```sh -//! # 导出几种状态的预览图(深色、浅色各一张) +//! # 导出几种状态的预览图:中文、英文,深色、浅色各一张 //! cargo run --manifest-path src-tauri/Cargo.toml --example menubar_preview -- --png <目录> +//! # 再加上菜单栏那一块的三档 +//! cargo run --manifest-path src-tauri/Cargo.toml --example menubar_preview -- --png <目录> --bars //! # 挂到菜单栏上,点开看真的菜单。Ctrl-C 退出 -//! cargo run --manifest-path src-tauri/Cargo.toml --example menubar_preview -- sub +//! cargo run --manifest-path src-tauri/Cargo.toml --example menubar_preview -- busy //! ``` //! -//! 状态:`sub`(订阅账号、有请求在跑)、`alert`(额度用完、有提醒)、`payg`(按量计费、 -//! 有新版本)、`down`(网关无法启动)、`starting`、`remote`(连着远程、断线重连中)。 -//! 另可加 `--en`、`--icon`、`--numbers`,以及 `--dump`(打出原生菜单里的项就退出)。 +//! 状态:`busy`(有提醒、一个额度窗口、两个在跑、有新版本)、`idle`(今天还没有请求)、 +//! `alert`(额度用完、有重置卡,另一家是积分制套餐)、`remote`(连着远程)、`stopped`、 +//! `unlinked`(连着远程、断线重连中)、`starting`、`safemode`。另可加 `--en`、`--icon`、 +//! `--numbers`,以及 `--dump`(打出原生菜单里的项就退出)。 -#[cfg(target_os = "macos")] +/// `--png` 导出的那几种状态 +#[cfg(all(target_os = "macos", debug_assertions))] +const STATES: [&str; 8] = [ + "busy", "idle", "alert", "remote", "stopped", "unlinked", "starting", "safemode", +]; + +#[cfg(all(target_os = "macos", debug_assertions))] fn main() { use objc2::MainThreadMarker; use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; @@ -36,19 +45,28 @@ fn main() { if let Some(i) = args.iter().position(|a| a == "--png") { let dir = std::path::PathBuf::from(args.get(i + 1).expect("--png 后面要一个目录")); std::fs::create_dir_all(&dir).expect("建不了目录"); - for name in ["sub", "alert", "payg", "down", "starting"] { - let (bar, rows) = model::build(&snapshot(name), style); - for dark in [true, false] { - let path = dir.join(format!( - "{name}-{}.png", - if dark { "dark" } else { "light" } - )); - let ok = macos::write_png(&macos::preview_image(&bar, &rows, dark), &path); - println!("{} {}", if ok { "wrote" } else { "FAILED" }, path.display()); + for (lang, tag) in [(Lang::Zh, "zh"), (Lang::En, "en")] { + i18n::set(lang); + for name in STATES { + let (bar, rows) = model::build(&snapshot(name), style); + for dark in [true, false] { + let path = dir.join(format!( + "{name}-{tag}-{}.png", + if dark { "dark" } else { "light" } + )); + let ok = macos::preview::write_png( + &macos::preview::preview_image(&bar, &rows, dark), + &path, + ); + println!("{} {}", if ok { "wrote" } else { "FAILED" }, path.display()); + } } } + if !args.iter().any(|a| a == "--bars") { + return; + } // 菜单栏那一块的三档,各种状态并排 - let bars: Vec = ["sub", "alert", "payg", "down", "starting"] + let bars: Vec = STATES .iter() .flat_map(|n| { [ @@ -65,7 +83,7 @@ fn main() { "bar-{i:02}-{}.png", if dark { "dark" } else { "light" } )); - macos::write_png(&macos::preview_image(bar, &[], dark), &path); + macos::preview::write_png(&macos::preview::preview_image(bar, &[], dark), &path); } } return; @@ -75,7 +93,7 @@ fn main() { .iter() .find(|a| !a.starts_with("--")) .cloned() - .unwrap_or_else(|| "sub".to_string()); + .unwrap_or_else(|| "busy".to_string()); macos::install( mtm, |action| println!("点了:{action:?}"), @@ -85,13 +103,13 @@ fn main() { macos::apply(mtm, &bar, &rows); // `--dump`:把原生菜单里实际有的项打出来就退出(看子菜单、勾选、分隔线) if args.iter().any(|a| a == "--dump") { - for line in macos::describe_menu() { + for line in macos::preview::describe_menu() { println!("{line}"); } return; } // 进行中的秒数每秒走:和应用里一样,只在后台线程里现算、投递到主线程 - if name == "sub" || name == "alert" { + if name == "busy" || name == "alert" || name == "remote" { std::thread::spawn(move || { loop { std::thread::sleep(std::time::Duration::from_secs(1)); @@ -100,7 +118,7 @@ fn main() { let (bar, rows) = model::build(&snap, style); macos::apply(mtm, &bar, &rows); // 菜单开着时主线程在事件跟踪模式:这一行打出来,就说明投递照样执行了 - if macos::is_open() { + if macos::preview::is_open() { println!("菜单开着时更新了一次"); } }); @@ -110,11 +128,11 @@ fn main() { // `--open <秒>`:过一会儿自动把菜单打开,再过这么多秒退出(截图用) if let Some(i) = args.iter().position(|a| a == "--open") { let secs: u64 = args.get(i + 1).and_then(|s| s.parse().ok()).unwrap_or(6); - macos::open_menu_later(mtm, 1.2); + macos::preview::open_menu_later(mtm, 1.2); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(1200)); std::thread::sleep(std::time::Duration::from_secs(secs)); - macos::on_main(|_| macos::close_menu()); + macos::on_main(|_| macos::preview::close_menu()); std::thread::sleep(std::time::Duration::from_millis(300)); std::process::exit(0); }); @@ -122,7 +140,7 @@ fn main() { app.run(); } -#[cfg(target_os = "macos")] +#[cfg(all(target_os = "macos", debug_assertions))] fn snapshot(name: &str) -> thinkwatch_lite_lib::menubar::model::Snapshot { use thinkwatch_lite_lib::menubar::model::*; let now = std::time::SystemTime::now() @@ -132,6 +150,8 @@ fn snapshot(name: &str) -> thinkwatch_lite_lib::menubar::model::Snapshot { // 秒数从启动那一刻算起,看得出它在走 static START: std::sync::OnceLock = std::sync::OnceLock::new(); let start = *START.get_or_init(|| now); + const H: u64 = 3_600_000; + const DAY: u64 = 86_400_000; let window = |w: &str, used: f64, in_ms: u64| Window { window: w.into(), used_percent: used, @@ -147,166 +167,211 @@ fn snapshot(name: &str) -> thinkwatch_lite_lib::menubar::model::Snapshot { remaining, }) }; + // 今天每小时的 token:`hours` 的最后一格是此刻所在的那一小时,其余的到午夜都还没到。 + // 合计就是「今日」的大数字 —— 和真的一样,两者出自同一问 + let day = |hours: &[i64], unit: i64, requests: i64, failed: i64, cost_micros: i64| { + let mut tokens: Vec = hours.iter().map(|h| h * unit).collect(); + let total = tokens.iter().sum(); + tokens.resize(24, 0); + let hourly = Hourly { + from_ms: (now - (hours.len() as u64 - 1) * H - H / 2) as i64, + tokens, + }; + let today = Today { + requests, + failed, + tokens: total, + cost_micros, + ..Default::default() + }; + (Some(today), Some(hourly)) + }; + let busy_hours = [ + 0, 0, 0, 0, 0, 0, 0, 1, 3, 8, 12, 10, 6, 9, 14, 16, 11, 9, 7, 5, 10, 7, + ]; + let remote_hours = [ + 0, 0, 0, 0, 0, 0, 0, 0, 2, 5, 6, 4, 3, 5, 7, 6, 4, 3, 2, 1, 3, 2, + ]; + let live = |id: u64, app: &str, model: &str, secs: u64| Live { + id, + app: Some(app.into()), + key: app.into(), + model: model.into(), + started_ms: start - secs * 1000, + }; + let remote = matches!(name, "remote" | "unlinked"); let base = Snapshot { gateway: Gateway::Running, - addr: Some("127.0.0.1:18790".into()), + addr: Some(if remote { + "192.168.1.20:8788".into() + } else { + "127.0.0.1:8788".into() + }), notices_on: true, now_ms: now, + remote: remote.then(|| "office-mac".to_string()), connections: vec![ Connection { id: "local".into(), name: thinkwatch_lite_lib::connection::store::local_name().into(), - current: name != "remote", + current: !remote, }, Connection { id: "a1".into(), - name: "home-server".into(), - current: name == "remote", + name: "office-mac".into(), + current: remote, }, Connection { id: "b2".into(), - name: "office-nas".into(), + name: "home-server".into(), current: false, }, ], + groups: vec![Group { + name: "主力".into(), + members: vec!["Claude".into(), "chatgpt".into(), "openrouter".into()], + selected: Some("Claude".into()), + }], ..Default::default() }; - if name == "remote" { - return Snapshot { - gateway: Gateway::Unlinked, - remote: Some("home-server".into()), - ..base - }; - } match name { - "sub" => Snapshot { - today: Some(Today { - requests: 186, - failed: 0, - tokens: 3_100_000, - cost_micros: 41_200_000, - ..Default::default() - }), - quotas: vec![ - Quota { - provider: "chatgpt".into(), - windows: vec![ - window("5h", 42.0, 3 * 3_600_000), - window("weekly", 18.0, 4 * 86_400_000), - ], + "busy" => { + let (today, hourly) = day(&busy_hours, 9_700, 128, 2, 3_470_000); + Snapshot { + today, + hourly, + rate: Some(42), + quotas: vec![Quota { + provider: "ChatGPT".into(), + windows: vec![window("weekly", 31.0, 4 * DAY)], reset_credits: None, - }, - Quota { - provider: "glm".into(), - windows: vec![ - Window { - credits: credits(2_000.0, 23.0, 1_976.0), - ..window("5h", 1.0, 58 * 60_000) - }, - Window { - credits: credits(10_000.0, 268.0, 9_731.0), - ..window("weekly", 2.0, 3 * 86_400_000) - }, - ], - reset_credits: None, - }, - ], - live: vec![ - Live { - id: 41, - app: Some("codex".into()), - key: "codex".into(), - model: "gpt-5-codex".into(), - started_ms: start - 102_000, - }, - Live { - id: 42, - app: Some("opencode".into()), - key: "default".into(), - model: "gpt-5-mini".into(), - started_ms: start - 8_000, - }, - ], - rate: Some(52), - groups: vec![Group { - name: "主力".into(), - members: vec!["chatgpt".into(), "openai".into(), "openrouter".into()], - selected: Some("chatgpt".into()), + }], + notices: vec![NoticeLine { + key: "balance:openrouter".into(), + level: Level::Warning, + title: thinkwatch_lite_lib::tr!( + "OpenRouter 余额不足", + "OpenRouter balance is low" + ) + .into(), + body: thinkwatch_lite_lib::tr!( + "已暂停使用 10 分钟。", + "Paused for 10 minutes." + ) + .into(), + }], + live: vec![ + live(41, "claude-code", "claude-opus-4-6", 12), + live(42, "codex", "gpt-5.2-codex", 3), + ], + update: Some("2026.10.9".into()), + ..base + } + } + "idle" => Snapshot { + today: Some(Today::default()), + hourly: Some(Hourly { + from_ms: (now - 9 * H - H / 2) as i64, + tokens: vec![0; 24], + }), + quotas: vec![Quota { + provider: "ChatGPT".into(), + windows: vec![window("weekly", 27.0, 4 * DAY)], + reset_credits: None, }], ..base }, - "alert" => Snapshot { - today: Some(Today { - requests: 244, - failed: 9, - tokens: 4_200_000, - cost_micros: 40_260_000, - ..Default::default() - }), - quotas: vec![Quota { - provider: "chatgpt".into(), - windows: vec![ - Window { - status: Some("rejected".into()), - ..window("5h", 100.0, 42 * 60_000) + "alert" => { + let (today, hourly) = day(&busy_hours, 32_800, 244, 9, 40_260_000); + Snapshot { + today: today.map(|t| Today { unpriced: 3, ..t }), + hourly, + rate: Some(52), + listen_error: Some(thinkwatch_lite_lib::tr!( + "端口 8788 已被占用,仍在旧地址上服务。", + "Port 8788 is already in use. The old address is still serving." + ) + .into()), + quotas: vec![ + Quota { + provider: "chatgpt".into(), + windows: vec![ + Window { + status: Some("rejected".into()), + ..window("5h", 100.0, 42 * 60_000) + }, + window("weekly", 83.0, 3 * DAY), + ], + reset_credits: Some(2), + }, + Quota { + provider: "glm".into(), + windows: vec![ + Window { + credits: credits(2_000.0, 23.0, 1_976.0), + ..window("5h", 1.0, 58 * 60_000) + }, + Window { + credits: credits(10_000.0, 268.0, 9_731.0), + ..window("weekly", 2.0, 3 * DAY) + }, + ], + reset_credits: None, }, - window("weekly", 63.0, 3 * 86_400_000), ], - reset_credits: Some(2), - }], - notices: vec![ - NoticeLine { - key: "quota:chatgpt:5h".into(), - level: Level::Warning, - title: "chatgpt 的订阅额度已用完".into(), - body: "5 小时额度已用完,42 分钟后重置。经此上游的请求会被拒绝。".into(), - }, - NoticeLine { - key: "auth:gemini".into(), - level: Level::Warning, - title: "gemini 拒绝了当前凭据".into(), - body: "上游返回未授权(401),该上游的凭据可能已失效。".into(), - }, - NoticeLine { - key: "upstream:anthropic".into(), - level: Level::Info, - title: "上游「anthropic」无法连接".into(), - body: "连续多次请求失败,暂停向其转发。".into(), - }, - NoticeLine { - key: "proxy:corp".into(), - level: Level::Info, - title: "代理「corp」不通".into(), - body: "经此代理的上游都无法连接。".into(), - }, - ], - live: vec![Live { - id: 7, - app: Some("claude-code".into()), - key: "claude-code".into(), - model: "claude-sonnet-4-5".into(), - started_ms: start - 21_000, - }], - groups: vec![Group { - name: "主力".into(), - members: vec!["chatgpt".into(), "openrouter".into()], - selected: Some("openrouter".into()), - }], + notices: vec![ + NoticeLine { + key: "quota:chatgpt:5h".into(), + level: Level::Warning, + title: thinkwatch_lite_lib::tr!("chatgpt 的订阅额度已用完", "The chatgpt subscription quota is used up").into(), + body: thinkwatch_lite_lib::tr!("5 小时额度已用完,42 分钟后重置。经此上游的请求会被拒绝。", "The 5h quota is used up and resets in 42 min. Requests through this upstream are refused.").into(), + }, + NoticeLine { + key: "auth:gemini".into(), + level: Level::Critical, + title: thinkwatch_lite_lib::tr!("gemini 拒绝了当前凭据", "gemini rejected the current credentials").into(), + body: thinkwatch_lite_lib::tr!("上游返回未授权(401),该上游的凭据可能已失效。", "The upstream answered 401 Unauthorized. Its credentials may have expired.").into(), + }, + NoticeLine { + key: "upstream:anthropic".into(), + level: Level::Info, + title: thinkwatch_lite_lib::tr!("上游「anthropic」无法连接", "The upstream anthropic is unreachable").into(), + body: thinkwatch_lite_lib::tr!("连续多次请求失败,暂停向其转发。", "Several requests in a row failed. Forwarding to it is paused.").into(), + }, + NoticeLine { + key: "proxy:corp".into(), + level: Level::Info, + title: thinkwatch_lite_lib::tr!("代理「corp」不通", "The proxy corp is unreachable").into(), + body: thinkwatch_lite_lib::tr!("经此代理的上游都无法连接。", "No upstream behind this proxy can be reached.").into(), + }, + ], + live: (0..7) + .map(|i| live(7 + i, "claude-code", "claude-sonnet-4-5", 21 + i * 13)) + .collect(), + ..base + } + } + "remote" => { + let (today, hourly) = day(&remote_hours, 6_700, 56, 0, 1_120_000); + Snapshot { + today, + hourly, + rate: Some(38), + live: vec![live(9, "claude-code", "claude-sonnet-4-5", 5)], + ..base + } + } + "stopped" => Snapshot { + gateway: Gateway::Stopped, ..base }, - "payg" => Snapshot { - today: Some(Today { - requests: 312, - failed: 0, - tokens: 5_800_000, - cost_micros: 3_420_000, - ..Default::default() - }), - update: Some("2026.9.8".into()), + "unlinked" => Snapshot { + gateway: Gateway::Unlinked, + update: Some("2026.10.9".into()), ..base }, - "down" => Snapshot { - gateway: Gateway::Failed, + "safemode" => Snapshot { + gateway: Gateway::SafeMode, ..base }, _ => Snapshot { @@ -316,5 +381,6 @@ fn snapshot(name: &str) -> thinkwatch_lite_lib::menubar::model::Snapshot { } } -#[cfg(not(target_os = "macos"))] +/// 预览的那几样只在开发构建里(`menubar::macos::preview`):`cargo run` 就是开发构建 +#[cfg(not(all(target_os = "macos", debug_assertions)))] fn main() {} diff --git a/src-tauri/examples/menubar_shots.rs b/src-tauri/examples/menubar_shots.rs index 513dcaaf..ac871270 100644 --- a/src-tauri/examples/menubar_shots.rs +++ b/src-tauri/examples/menubar_shots.rs @@ -11,7 +11,8 @@ //! - `menubar-{light,dark}.png`:菜单栏上的那一块(标识和两行数字),放在一段菜单栏底色上。 //! 和语言无关:上面只有数字。 //! - `{zh,en}/menubar-menu-{light,dark}.png`:点开之后的菜单。**标准菜单项是近似画的**(真的 -//! 由 AppKit 画,离屏拿不到),自定义的几行和菜单栏那一块是真的,见 `macos::preview_image`。 +//! 由 AppKit 画,离屏拿不到),自定义的几行和菜单栏那一块是真的,见 +//! `macos::preview::preview_image`。 //! //! 数据和截图页(scripts/shots/)是同一个场景:今天的用量由 `--today` 传进来,就是概览上 //! 的那几个数;ChatGPT 额度、正在跑的那一条请求、连接列表和截图页里的一致。 @@ -20,7 +21,7 @@ //! `NOW`):额度还有多久重置、正在跑的那一条跑了几秒,都从它算,所以哪天拍出来都一样。 //! 不给就用同一刻(本地时间 2026-09-25 16:42:07)。 -#[cfg(target_os = "macos")] +#[cfg(all(target_os = "macos", debug_assertions))] fn main() { use std::path::{Path, PathBuf}; @@ -136,8 +137,8 @@ fn main() { }; /* - **正好按 2 倍写。**`macos::write_png` 按屏幕的倍数再乘 2,在视网膜屏上出来的是 4 倍, - 换一台机器又不一样;这里自己开一块 2 倍的画布画进去,再转成 sRGB 存盘。 + **正好按 2 倍写。**`macos::preview::write_png` 按屏幕的倍数再乘 2,在视网膜屏上出来的 + 是 4 倍,换一台机器又不一样;这里自己开一块 2 倍的画布画进去,再转成 sRGB 存盘。 */ let write = |image: &Retained, path: &Path| { let size = image.size(); @@ -235,12 +236,14 @@ fn main() { i18n::set(lang); let (bar, rows) = model::build(&snapshot(), model::Style::Full); for dark in [false, true] { - let image = macos::preview_image(&bar, &rows, dark); + let image = macos::preview::preview_image(&bar, &rows, dark); let name = format!("menubar-menu-{}.png", if dark { "dark" } else { "light" }); write(&image, &out.join(dir).join(name)); } } } -#[cfg(not(target_os = "macos"))] +/// 离屏画图的那几样只在开发构建里(`menubar::macos::preview`):`pnpm shots` 用 `cargo run`, +/// 就是开发构建 +#[cfg(not(all(target_os = "macos", debug_assertions)))] fn main() {} diff --git a/src-tauri/src/clients/mod.rs b/src-tauri/src/clients/mod.rs index acb95efd..4ebdb3fd 100644 --- a/src-tauri/src/clients/mod.rs +++ b/src-tauri/src/clients/mod.rs @@ -98,49 +98,110 @@ async fn gateway(state: &AppState) -> Out { /// **问的是网关,不是 core 的控制面** —— 同一把密钥在网关上被允许用哪些模型,只有 /// 网关按它的 `allow` 答得准。opencode、Pi、oh-my-pi、Grok Build、Qwen Code 要把这份清单写进 /// 配置(它们不自己去问),Hermes Agent 要从里面挑一个默认模型。 -async fn models_of(base: &str, key: &str) -> Result, Msg> { - fetch_models(base, key, false).await +/// +/// `anthropic` = 按 Anthropic 的方式问(密钥放在 `x-api-key`、带 `anthropic-version`): +/// Claude Desktop 就是这么问的,网关按这个答它说得通的那些 +async fn fetch_models(base: &str, key: &str, anthropic: bool) -> Result, Msg> { + Lister::new(base)?.ask(key, anthropic).await } -/// [`models_of`]。`anthropic` = 按 Anthropic 的方式问(密钥放在 `x-api-key`、带 -/// `anthropic-version`):Claude Desktop 就是这么问的,网关按这个答它说得通的那些 -async fn fetch_models(base: &str, key: &str, anthropic: bool) -> Result, Msg> { - let url = format!("{}/v1/models", base.trim_end_matches('/')); - let failed = |detail: String| { - msg!( - "control.models_unreadable", url = url.clone(), detail = detail => - "The model list could not be read from {url}: {detail}" - ) - }; - // 和 updater::fetch_text 同一套 TLS:连远程 core 时网关可能在 https 后面 - if rustls::crypto::CryptoProvider::get_default().is_none() { - let _ = rustls::crypto::ring::default_provider().install_default(); +/// 问一个网关的模型清单:地址,和问它用的 HTTP 客户端。 +/// +/// **几问共用一个**:建一个客户端要装 TLS、读一遍系统代理,客户端页每刷新一次要问好几把 +/// 密钥([`listed_models`])。克隆它只是多一个引用,连接池也是同一个 +#[derive(Clone)] +struct Lister { + client: reqwest::Client, + url: String, +} + +impl Lister { + fn new(base: &str) -> Result { + let url = format!("{}/v1/models", base.trim_end_matches('/')); + // 和 updater::fetch_text 同一套 TLS:连远程 core 时网关可能在 https 后面 + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + let mut builder = reqwest::Client::builder() + .user_agent("ThinkWatch-Lite") + .timeout(std::time::Duration::from_secs(5)); + // 本机的网关不经过系统代理:代理那头够不到这台机器的回环地址 + if ops::is_loopback(base) { + builder = builder.no_proxy(); + } + match builder.build() { + Ok(client) => Ok(Lister { client, url }), + Err(e) => Err(models_unreadable(&url, e)), + } } - let mut builder = reqwest::Client::builder() - .user_agent("ThinkWatch-Lite") - .timeout(std::time::Duration::from_secs(5)); - // 本机的网关不经过系统代理:代理那头够不到这台机器的回环地址 - if ops::is_loopback(base) { - builder = builder.no_proxy(); + + /// 拿这把密钥问一次。`anthropic` 见 [`fetch_models`] + async fn ask(&self, key: &str, anthropic: bool) -> Result, Msg> { + let failed = |e: &dyn std::fmt::Display| models_unreadable(&self.url, e); + let req = self.client.get(&self.url); + let req = if anthropic { + req.header("x-api-key", key) + .header("anthropic-version", "2023-06-01") + } else { + req.bearer_auth(key) + }; + let text = req + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|e| failed(&e))? + .text() + .await + .map_err(|e| failed(&e))?; + let body: serde_json::Value = serde_json::from_str(&text).map_err(|e| failed(&e))?; + Ok(model_cards(&body)) } - let client = builder.build().map_err(|e| failed(e.to_string()))?; - let req = client.get(&url); - let req = if anthropic { - req.header("x-api-key", key) - .header("anthropic-version", "2023-06-01") - } else { - req.bearer_auth(key) +} + +fn models_unreadable(url: &str, e: impl std::fmt::Display) -> Msg { + msg!( + "control.models_unreadable", url = url.to_string(), detail = e.to_string() => + "The model list could not be read from {url}: {detail}" + ) +} + +/// 客户端页上那几个要写模型的客户端(opencode、Pi、oh-my-pi、Grok Build、Qwen Code)和要挑 +/// 一个默认模型的(Hermes Agent),网关此刻给它那把密钥答什么:拿来判断配置里的清单过没 +/// 过期,也给手动配置那一栏照着写(没装的那几个也有那一栏)。还没有它自己的密钥就按接管 +/// 时会用的那把问。**问不到的不在里面** —— 网关停着的时候客户端页照样要打得开。 +/// +/// **同一把密钥只问一次,几把同时问。**还没有自己密钥的都按默认的那把问,六个客户端多半 +/// 是同一问;以前是一个接一个、每问新建一个 HTTP 客户端,页面开着、有请求时每三秒一遍 +async fn listed_models(gw: &ops::Gateway) -> BTreeMap> { + let mut by_key: BTreeMap> = BTreeMap::new(); + for c in tw_adopt::clients::adoptable() + .iter() + .filter(|c| c.writes_models || tw_adopt::clients::picks_model(c)) + { + if let Ok((_, key, _)) = ops::key_for(gw, c.id) { + by_key.entry(key).or_default().push(c.id); + } + } + let mut out = BTreeMap::new(); + if by_key.is_empty() { + return out; + } + let Ok(lister) = Lister::new(&gw.base) else { + return out; }; - let text = req - .send() - .await - .and_then(|r| r.error_for_status()) - .map_err(|e| failed(e.to_string()))? - .text() - .await - .map_err(|e| failed(e.to_string()))?; - let body: serde_json::Value = serde_json::from_str(&text).map_err(|e| failed(e.to_string()))?; - Ok(model_cards(&body)) + let mut asks = tokio::task::JoinSet::new(); + for (key, ids) in by_key { + let lister = lister.clone(); + asks.spawn(async move { (ids, lister.ask(&key, false).await) }); + } + while let Some(done) = asks.join_next().await { + if let Ok((ids, Ok(models))) = done { + for id in ids { + out.insert(id.to_string(), models.clone()); + } + } + } + out } /// OpenAI 形状的 `/v1/models`:`data[]` 的 `id`,连同网关给的规格。 @@ -183,7 +244,7 @@ async fn models_for( key: &str, ) -> Result, Msg> { if c.writes_models || tw_adopt::clients::picks_model(c) { - models_of(base, key).await + fetch_models(base, key, false).await } else if c.id == tw_adopt::desktop::ID { fetch_models(base, key, true).await } else { @@ -298,22 +359,9 @@ pub(crate) async fn blocking(f: impl FnOnce() -> T + Send + ' #[tauri::command] pub async fn list_clients(state: tauri::State<'_, AppState>) -> Out { let gw = gateway(&state).await?; - // 把模型写进配置的那几个(opencode、Pi、oh-my-pi、Grok Build、Qwen Code)和要挑一个默认 - // 模型的(Hermes Agent),问一下网关此刻给它那把密钥答什么:拿来判断配置里的清单过没过期, - // 也给手动配置那一栏照着写。还没有它自己的密钥就按接管时会用的那把问。**问不到就不说** - // —— 网关停着的时候客户端页照样要打得开 - let mut models = BTreeMap::new(); - for c in tw_adopt::clients::adoptable() - .iter() - .filter(|c| c.writes_models || tw_adopt::clients::picks_model(c)) - { - if let Ok((_, key, _)) = ops::key_for(&gw, c.id) - && let Ok(ms) = models_of(&gw.base, &key).await - { - models.insert(c.id.to_string(), ms); - } - } - Ok(ops::list(&home_dir(), &backups(), &gw, &models)) + let models = listed_models(&gw).await; + // 检测要读每个客户端的配置文件(可能在一个离线的网络位置上):放在阻塞线程上 + blocking(move || ops::list(&home_dir(), &backups(), &gw, &models)).await } /// 客户端页的 WSL 部分:每个发行版一组。 @@ -357,28 +405,33 @@ pub async fn list_wsl(state: tauri::State<'_, AppState>) -> Out (ops::list_wsl(&w, &backups(), &gw), None), - Err(e) => (Vec::new(), Some(e)), - }; - wire::WslGroup { - distro: d.name, - network, - adoptable, - error, - clients, - gateway_base: gw.base.clone(), - } - }) - .collect(); + // 检测要读发行版里的配置文件,走的是 `\\wsl.localhost\…`,一个文件也要好一阵: + // 同样放在阻塞线程上 + let out = blocking(move || { + let config = wsl::config_mode(); + opened + .into_iter() + .map(|(d, w)| { + let (network, adoptable) = wsl::network(remote, &d, config, || { + wsl2.clone() + .unwrap_or(tw_adopt::wsl::Wsl2::Nat { version: None }) + }); + let (clients, error) = match w { + Ok(w) => (ops::list_wsl(&w, &backups(), &gw), None), + Err(e) => (Vec::new(), Some(e)), + }; + wire::WslGroup { + distro: d.name, + network, + adoptable, + error, + clients, + gateway_base: gw.base.clone(), + } + }) + .collect() + }) + .await?; Ok(wire::WslResponse { distros: out }) } @@ -716,7 +769,7 @@ pub async fn set_client_locations( let _ = app.emit( "local-event", wire::LocalEvent::ClientsChanged { - at_ms: crate::scan::now_ms(), + at_ms: crate::notices::now_ms(), }, ); Ok(()) diff --git a/src-tauri/src/clients/ops.rs b/src-tauri/src/clients/ops.rs index 9db00447..d6586d29 100644 --- a/src-tauri/src/clients/ops.rs +++ b/src-tauri/src/clients/ops.rs @@ -968,936 +968,4 @@ pub fn repoint( /// 测试用的假机器,`super::tests`(改为指向服务器那几条)也用 #[cfg(test)] -pub(crate) mod tests { - use super::*; - - pub(crate) fn key(name: &str, value: &str, client: Option<&str>, default: bool) -> ClientView { - ClientView { - name: name.into(), - key: value.into(), - max_concurrent: None, - route: None, - allow: None, - client: client.map(str::to_string), - disabled: false, - default, - last_seen_ms: None, - limits: vec![], - unpriced_models: vec![], - } - } - - fn gw(keys: Vec) -> Gateway { - Gateway { - base: "http://127.0.0.1:8788".into(), - keys, - } - } - - /// 备份放在这台假机器里,**不碰开发者自己的数据目录** - fn backups(home: &tempfile::TempDir) -> std::path::PathBuf { - home.path().join("backups") - } - - /// 一台装了 Claude Code 的机器:`~/.claude/` 在,配置文件还没有 - fn home_with_claude() -> tempfile::TempDir { - let d = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(d.path().join(".claude")).unwrap(); - d - } - - #[test] - fn listing_says_which_clients_are_here_and_which_are_only_advice() { - let home = home_with_claude(); - let r = list( - home.path(), - &backups(&home), - &gw(vec![key("default", "tw-d", None, true)]), - &BTreeMap::new(), - ); - let cc = r.clients.iter().find(|c| c.id == "claude-code").unwrap(); - assert!(cc.installed); - assert_eq!(cc.adopted_at_ms, None); - assert!(r.manual.iter().any(|m| m.id == "cursor")); - assert_eq!(r.gateway_base, "http://127.0.0.1:8788"); - assert_eq!(r.keys, vec!["default"]); - } - - /// 「使用中」按那把密钥算:为它生成的密钥被用过,就是它被用过 - #[test] - fn in_use_is_read_off_the_clients_own_key() { - let home = home_with_claude(); - let mut mine = key("claude-code", "tw-c", Some("claude-code"), false); - mine.last_seen_ms = Some(42); - let r = list( - home.path(), - &backups(&home), - &gw(vec![key("default", "tw-d", None, true), mine]), - &BTreeMap::new(), - ); - let cc = r.clients.iter().find(|c| c.id == "claude-code").unwrap(); - assert_eq!(cc.key.as_deref(), Some("claude-code")); - assert_eq!(cc.last_seen_ms, Some(42)); - let cursor = r.manual.iter().find(|m| m.id == "cursor").unwrap(); - assert_eq!(cursor.key, None); - assert_eq!(cursor.last_seen_ms, None); - } - - /// 手动配置给出的字段就是接管时写的那几项,密钥那一项不给值 - #[test] - fn every_client_says_how_to_connect_it_by_hand_without_the_key() { - let home = tempfile::tempdir().unwrap(); - let r = list( - home.path(), - &backups(&home), - &gw(vec![key("default", "tw-d", None, true)]), - &BTreeMap::new(), - ); - let cc = r.clients.iter().find(|c| c.id == "claude-code").unwrap(); - assert!(!cc.manual.fields.is_empty()); - assert!( - cc.manual - .fields - .iter() - .any(|f| f.secret && f.value.is_none()) - ); - let json = serde_json::to_string(&r).unwrap(); - assert!(!json.contains("tw-d"), "{json}"); - } - - #[test] - fn planning_writes_nothing_and_never_echoes_the_key() { - let home = home_with_claude(); - let p = plan_adopt( - home.path(), - "claude-code", - &gw(vec![key("default", "tw-secret-value", None, true)]), - Vec::new(), - &Around::default(), - ) - .unwrap(); - assert!(!home.path().join(".claude/settings.json").exists()); - assert!(!p.after.contains("tw-secret-value"), "{}", p.after); - assert!(p.after.contains(MASK)); - // 还没有为它留着的:确认之前就说要新建一把,叫什么 - assert_eq!(p.key.as_deref(), Some("claude-code")); - assert!(p.key_created); - } - - /// 为它留着一把的,接着用那一把;名字被别的占了就往后编号 - #[test] - fn the_plan_says_which_key_goes_in_and_whether_it_is_made_now() { - let home = home_with_claude(); - let kept = plan_adopt( - home.path(), - "claude-code", - &gw(vec![ - key("default", "tw-d", None, true), - key("mine", "tw-m", Some("claude-code"), false), - ]), - Vec::new(), - &Around::default(), - ) - .unwrap(); - assert_eq!(kept.key.as_deref(), Some("mine")); - assert!(!kept.key_created); - - let taken = plan_adopt( - home.path(), - "claude-code", - &gw(vec![ - key("default", "tw-d", None, true), - key("claude-code", "tw-x", None, false), - ]), - Vec::new(), - &Around::default(), - ) - .unwrap(); - assert_eq!(taken.key.as_deref(), Some("claude-code-2")); - assert!(taken.key_created); - } - - #[test] - fn without_any_key_there_is_nothing_to_point_a_client_with() { - let home = home_with_claude(); - let e = plan_adopt( - home.path(), - "claude-code", - &gw(Vec::new()), - Vec::new(), - &Around::default(), - ) - .unwrap_err(); - assert_eq!(e.code, "control.no_keys"); - } - - #[test] - fn a_client_we_do_not_know_is_refused_by_name() { - let home = tempfile::tempdir().unwrap(); - let e = plan_adopt( - home.path(), - "../etc", - &gw(Vec::new()), - Vec::new(), - &Around::default(), - ) - .unwrap_err(); - assert_eq!(e.code, "control.client_unknown"); - assert!(!known("../etc")); - assert!(known("claude-code") && known("cursor")); - } - - /// `/setup-bedrock` 把 Bedrock 的 API key、访问密钥写在 settings.json 的 `env` 里: - /// diff 画的是整份文件,它们一律打码;区域、开关这些照常显示 - #[test] - fn aws_credentials_in_claude_codes_env_never_show_in_the_diff() { - let home = home_with_claude(); - let settings = home.path().join(".claude/settings.json"); - std::fs::write( - &settings, - r#"{ "env": { - "CLAUDE_CODE_USE_BEDROCK": "1", - "AWS_REGION": "us-west-2", - "AWS_BEARER_TOKEN_BEDROCK": "ABSKQmVkcm9ja0FQSUtleS1leGFtcGxl", - "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", - "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" -} }"#, - ) - .unwrap(); - let secrets = [ - "ABSKQmVkcm9ja0FQSUtleS1leGFtcGxl", - "AKIAIOSFODNN7EXAMPLE", - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - ]; - let p = plan_adopt( - home.path(), - "claude-code", - &gw(vec![key("default", "tw-new", None, true)]), - Vec::new(), - &Around::default(), - ) - .unwrap(); - for text in [p.before.as_deref().unwrap(), p.after.as_str()] { - for s in secrets { - assert!(!text.contains(s), "{s} shows in:\n{text}"); - } - assert!(text.contains("us-west-2"), "{text}"); - } - assert!( - p.after.contains("\"CLAUDE_CODE_USE_BEDROCK\": \"\""), - "{}", - p.after - ); - assert!( - p.fields - .iter() - .any(|f| f.path == "env.CLAUDE_CODE_USE_BEDROCK" && f.value.as_deref() == Some("")), - "{:?}", - p.fields - ); - // 交给界面的整份(说明、新建上游的草稿)里也没有:草稿里只有变量引用 - let sent = serde_json::to_string(&p).unwrap(); - for s in secrets { - assert!(!sent.contains(s), "{s} goes to the UI:\n{sent}"); - } - assert!( - matches!(&p.bedrock, Some(d) if d.region == "us-west-2" - && matches!(&d.auth, wire::DraftAuth::Key { key } if key == "${AWS_BEARER_TOKEN_BEDROCK}")), - "{:?}", - p.bedrock - ); - - adopt( - home.path(), - &backups(&home), - "claude-code", - &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-new", Vec::new()), - Some(&p.digest), - &Around::default(), - ) - .unwrap(); - let r = plan_restore(home.path(), "claude-code", &[]).unwrap(); - for text in [r.before.as_deref().unwrap(), r.after.as_str()] { - for s in secrets { - assert!(!text.contains(s), "{s} shows in:\n{text}"); - } - } - } - - /// Grok Build 一个模型一张表,整张表算密钥:字段列表里不给值,diff 里网关那把打码; - /// 用户自己那张表里发给别家的 `api_key`、Qwen Code `/auth` 写在 `env` 里的密钥,diff 里 - /// 同样看不到 - #[test] - fn grok_and_qwen_diffs_show_no_key_of_anyone() { - let home = tempfile::tempdir().unwrap(); - let grok = tw_adopt::paths::GROK_CONFIG.resolve(home.path()); - std::fs::create_dir_all(grok.parent().unwrap()).unwrap(); - std::fs::write( - &grok, - "[model.mine]\nmodel = \"x\"\nbase_url = \"https://api.example.com/v1\"\napi_key = \"sk-mine-0123456789\"\n", - ) - .unwrap(); - let qwen = tw_adopt::paths::QWEN_SETTINGS.resolve(home.path()); - std::fs::create_dir_all(qwen.parent().unwrap()).unwrap(); - std::fs::write( - &qwen, - r#"{ "env": { "DASHSCOPE_API_KEY": "sk-dash-0123456789" } }"#, - ) - .unwrap(); - let g = gw(vec![key("default", "tw-secret-value", None, true)]); - let models: Vec = vec!["claude-sonnet-5".into(), "gpt-5.5".into()]; - for (id, theirs) in [ - ("grok-build", "sk-mine-0123456789"), - ("qwen-code", "sk-dash-0123456789"), - ] { - let p = plan_adopt(home.path(), id, &g, models.clone(), &Around::default()).unwrap(); - for text in [p.before.as_deref().unwrap(), p.after.as_str()] { - assert!(!text.contains(theirs), "{id}: {text}"); - assert!(!text.contains("tw-secret-value"), "{id}: {text}"); - } - assert!(p.after.contains(MASK), "{id}: {}", p.after); - let sent = serde_json::to_string(&p).unwrap(); - assert!(!sent.contains("tw-secret-value"), "{id}: {sent}"); - } - let p = plan_adopt(home.path(), "grok-build", &g, models, &Around::default()).unwrap(); - let table = p - .fields - .iter() - .find(|f| f.path == "model.thinkwatch/gpt-5.5") - .expect("一个模型一张表"); - assert!(table.secret && table.value.is_none(), "{table:?}"); - assert!( - p.fields - .iter() - .any(|f| f.path == "models.default" && f.value.is_some()), - "{:?}", - p.fields - ); - } - - /// 关哪几个开关还看 shell 配置和用户环境:确认框和落盘之间那边变了,写下去的就会多 - /// 一项确认框里没有的改动 —— 当成「改过了」,什么都不写 - #[test] - fn a_switch_that_appears_after_the_review_stops_the_write() { - let home = home_with_claude(); - let settings = home.path().join(".claude/settings.json"); - std::fs::write(&settings, "{}\n").unwrap(); - let g = gw(vec![key("default", "tw-new", None, true)]); - let reviewed = plan_adopt( - home.path(), - "claude-code", - &g, - Vec::new(), - &Around::default(), - ) - .unwrap(); - let later = Around { - env: [("CLAUDE_CODE_USE_BEDROCK".to_string(), "1".to_string())].into(), - ..Default::default() - }; - let e = adopt( - home.path(), - &backups(&home), - "claude-code", - &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-new", Vec::new()), - Some(&reviewed.digest), - &later, - ) - .unwrap_err(); - assert_eq!(e.code, "adopt.plan.stale"); - assert_eq!(std::fs::read_to_string(&settings).unwrap(), "{}\n"); - } - - #[test] - fn adopt_then_restore_puts_the_users_file_back_and_masks_their_key() { - let home = home_with_claude(); - let settings = home.path().join(".claude/settings.json"); - let original = r#"{ "env": { "ANTHROPIC_BASE_URL": "https://example.com", "ANTHROPIC_AUTH_TOKEN": "users-own" } }"#; - std::fs::write(&settings, original).unwrap(); - - // 接管的 diff:被换下来的是用户自己的那把,改之前那一栏里也不能有它 - let p = plan_adopt( - home.path(), - "claude-code", - &gw(vec![key("default", "tw-new", None, true)]), - Vec::new(), - &Around::default(), - ) - .unwrap(); - let before = p.before.as_deref().unwrap(); - assert!(!before.contains("users-own"), "{before}"); - assert!(before.contains(HIDDEN), "{before}"); - assert!( - p.after.contains(MASK) && !p.after.contains("tw-new"), - "{}", - p.after - ); - // 不是密钥的照常显示 - assert!(before.contains("https://example.com"), "{before}"); - - let a = adopt( - home.path(), - &backups(&home), - "claude-code", - &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-new", Vec::new()), - None, - &Around::default(), - ) - .unwrap(); - assert_eq!(a.takes_effect, wire::TakesEffect::Immediately); - let written = std::fs::read_to_string(&settings).unwrap(); - assert!(written.contains("tw-new") && written.contains("127.0.0.1:8788")); - assert!( - adopted(home.path(), &backups(&home)) - .iter() - .any(|c| c.id == "claude-code") - ); - - let keys = vec![key("claude-code", "tw-new", Some("claude-code"), false)]; - let p = plan_restore(home.path(), "claude-code", &keys).unwrap(); - assert!(!p.before.as_deref().unwrap().contains("tw-new")); - assert_eq!(p.key.as_deref(), Some("claude-code")); - // 用户自己的那把正要被写回去:它也不出现在 diff 里 - assert!(!p.after.contains("tw-new")); - assert!(!p.after.contains("users-own"), "{}", p.after); - // core 不在(拿不到密钥清单)时,网关那把照样盖得住:它在密钥字段上 - let offline = plan_restore(home.path(), "claude-code", &[]).unwrap(); - assert!(!offline.before.as_deref().unwrap().contains("tw-new")); - assert!(!offline.after.contains("users-own"), "{}", offline.after); - - restore(home.path(), &backups(&home), "claude-code", None).unwrap(); - let back: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); - let want: serde_json::Value = serde_json::from_str(original).unwrap(); - assert_eq!(back, want); - assert!(adopted(home.path(), &backups(&home)).is_empty()); - } - - /// opencode 的模型清单跟网关对不上了才提示更新;顺序不算,没接管的不提示 - #[test] - fn a_stale_opencode_model_list_is_flagged_on_the_listing() { - let home = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(home.path().join(".config/opencode")).unwrap(); - let keys = vec![key("opencode", "tw-o", Some("opencode"), false)]; - let now = |ms: &[&str]| { - BTreeMap::from([( - "opencode".to_string(), - ms.iter().map(|m| ModelCard::named(*m)).collect::>(), - )]) - }; - let stale = |models: &BTreeMap>| { - list(home.path(), &backups(&home), &gw(keys.clone()), models) - .clients - .into_iter() - .find(|c| c.id == "opencode") - .unwrap() - .models_stale - }; - assert!(!stale(&now(&["a"])), "没接管的没有清单可比"); - - adopt( - home.path(), - &backups(&home), - "opencode", - &tw_adopt::clients::Gateway::keyed( - "http://127.0.0.1:8788", - "tw-o", - vec!["a".into(), "b".into()], - ), - None, - &Around::default(), - ) - .unwrap(); - assert!(!stale(&now(&["b", "a"]))); - assert!(stale(&now(&["a", "b", "c"]))); - assert!(!stale(&BTreeMap::new()), "问不到网关就不说"); - - // 手动配置那一栏照网关此刻答的写 - let r = list( - home.path(), - &backups(&home), - &gw(keys.clone()), - &now(&["m1"]), - ); - let oc = r.clients.iter().find(|c| c.id == "opencode").unwrap(); - assert!( - oc.manual - .fields - .iter() - .any(|f| f.path == "provider.thinkwatch.models" - && f.value.as_deref() == Some("{m1: {name: m1}}")), - "{:?}", - oc.manual.fields - ); - } - - /// Pi 和 oh-my-pi 也把模型写进配置:清单跟网关对不上了一样提示更新;diff 里看不到密钥 - #[test] - fn a_stale_pi_or_omp_model_list_is_flagged_too() { - for id in ["pi", "omp"] { - let home = tempfile::tempdir().unwrap(); - let keys = vec![key(id, "tw-p", Some(id), false)]; - let now = |ms: &[&str]| { - BTreeMap::from([( - id.to_string(), - ms.iter().map(|m| ModelCard::named(*m)).collect::>(), - )]) - }; - let stale = |models: &BTreeMap>| { - list(home.path(), &backups(&home), &gw(keys.clone()), models) - .clients - .into_iter() - .find(|c| c.id == id) - .unwrap() - .models_stale - }; - let target = tw_adopt::clients::Gateway::keyed( - "http://127.0.0.1:8788", - "tw-p", - vec!["claude-a".into(), "b".into()], - ); - let v = plan_adopt( - home.path(), - id, - &gw(keys.clone()), - target.models.clone(), - &Around::default(), - ) - .unwrap(); - assert!(!v.after.contains("tw-p"), "{id}:{}", v.after); - assert!( - v.fields.iter().any(|f| f.secret && f.value.is_none()), - "{id}" - ); - adopt( - home.path(), - &backups(&home), - id, - &target, - None, - &Around::default(), - ) - .unwrap(); - assert!(!stale(&now(&["b", "claude-a"])), "{id}"); - assert!(stale(&now(&["claude-a"])), "{id}"); - } - } - - /// 别处的 home(WSL 里的、测试的临时目录)不带用户指定的配置文件:那份设置只管 - /// 这台电脑上的 - #[test] - fn a_home_other_than_this_computers_gets_the_default_files() { - let home = home_with_claude(); - assert!(all(home.path()).iter().all(|c| c.custom_config.is_none())); - } - - #[test] - fn restoring_something_we_never_adopted_refuses_instead_of_guessing() { - let home = home_with_claude(); - assert!(restore(home.path(), &backups(&home), "claude-code", None).is_err()); - } - - /// 接管着的客户端的密钥删不得;它的主人是哪个,只有这台机器答得上来 - #[test] - fn a_key_in_an_adopted_clients_config_is_found_by_its_owner() { - let home = home_with_claude(); - let keys = vec![ - key("default", "tw-d", None, true), - key("claude-code", "tw-c", Some("claude-code"), false), - ]; - assert!(adopted_owner(home.path(), &backups(&home), &keys, "claude-code").is_none()); - adopt( - home.path(), - &backups(&home), - "claude-code", - &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-c", Vec::new()), - None, - &Around::default(), - ) - .unwrap(); - let owner = adopted_owner(home.path(), &backups(&home), &keys, "claude-code").unwrap(); - assert_eq!(owner.id, "claude-code"); - assert_eq!(key_used_by(owner.name).code, "control.key_used_by_client"); - assert!(adopted_owner(home.path(), &backups(&home), &keys, "default").is_none()); - } - - #[test] - fn a_loopback_endpoint_is_this_machine_and_a_server_is_not() { - for e in [ - "http://127.0.0.1:8788", - "http://127.0.0.1:8788/v1", - "http://localhost:8788/v1", - "http://[::1]:8788", - ] { - assert!(is_loopback(e), "{e}"); - } - for e in [ - "http://192.168.1.20:8788/v1", - "http://nas.local:8788", - "", - "http://", - ] { - assert!(!is_loopback(e), "{e}"); - } - assert_eq!( - host_port("http://127.0.0.1:8788/v1"), - Some("127.0.0.1:8788") - ); - } - - /// 接管着、指着本机网关的才算;指到了服务器上的不算 - #[test] - fn only_clients_pointing_at_this_machine_count() { - let home = home_with_claude(); - std::fs::create_dir_all(home.path().join(".codex")).unwrap(); - adopt( - home.path(), - &backups(&home), - "claude-code", - &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-c", Vec::new()), - None, - &Around::default(), - ) - .unwrap(); - adopt( - home.path(), - &backups(&home), - "codex", - &tw_adopt::clients::Gateway::keyed("http://192.168.1.20:8788", "tw-x", Vec::new()), - None, - &Around::default(), - ) - .unwrap(); - let here: Vec<_> = adopted_on_this_machine(home.path(), &backups(&home)) - .into_iter() - .map(|(c, e)| (c.id, e)) - .collect(); - assert_eq!( - here, - vec![("claude-code", "http://127.0.0.1:8788".to_string())] - ); - } - - /// 一个假的 WSL 发行版:`etc/passwd` 和默认用户的 home,Claude Code 和 Codex 都装了 - pub(crate) fn wsl_home() -> (tempfile::TempDir, tw_adopt::wsl::WslHome) { - let d = tempfile::tempdir().unwrap(); - let root = d.path().join("Ubuntu"); - std::fs::create_dir_all(root.join("etc")).unwrap(); - std::fs::write( - root.join("etc/passwd"), - "u:x:1000:1000::/home/u:/bin/bash\n", - ) - .unwrap(); - std::fs::create_dir_all(root.join("home/u/.claude")).unwrap(); - std::fs::create_dir_all(root.join("home/u/.codex")).unwrap(); - std::fs::create_dir_all(root.join("home/u/.config/zed")).unwrap(); - let w = tw_adopt::wsl::WslHome::read( - tw_adopt::wsl::Distro { - name: "Ubuntu".into(), - version: 2, - uid: 1000, - }, - root, - ) - .unwrap(); - (d, w) - } - - /// WSL 里只列第一批的那两个;路径写成 WSL 里的样子;密钥认的是为 WSL 里 - /// 这一份发的那把,不是这台电脑上同一个客户端的那把 - #[test] - fn a_wsl_distro_lists_its_own_copies_with_their_own_keys() { - let (d, w) = wsl_home(); - let b = d.path().join("backups"); - let mut mine = key( - "claude-code-wsl-ubuntu", - "tw-w", - Some("claude-code-wsl-ubuntu"), - false, - ); - mine.last_seen_ms = Some(7); - let gw = Gateway { - base: "http://127.0.0.1:8788".into(), - keys: vec![ - key("default", "tw-d", None, true), - key("claude-code", "tw-c", Some("claude-code"), false), - mine, - ], - }; - let r = list_wsl(&w, &b, &gw); - let ids: Vec<_> = r.iter().map(|c| c.id.as_str()).collect(); - assert_eq!(ids, ["claude-code", "codex"]); - let cc = &r[0]; - assert!(cc.installed); - assert_eq!(cc.path, "~/.claude/settings.json"); - assert_eq!(cc.key.as_deref(), Some("claude-code-wsl-ubuntu")); - assert_eq!(cc.last_seen_ms, Some(7)); - // 手动配置给的地址和这台电脑上的一样 - assert_eq!(cc.manual.endpoint, "http://127.0.0.1:8788"); - assert_eq!(cc.manual.steps[0].arg("file"), "~/.claude/settings.json"); - assert_eq!(r[1].key, None); - // 接管的方案:新建的那把叫 WSL 那一份的名字 - let p = plan_adopt_as( - &w.home, - "codex", - "codex-wsl-ubuntu", - &gw, - Vec::new(), - &Around::default(), - ) - .unwrap(); - assert_eq!(p.key.as_deref(), Some("codex-wsl-ubuntu")); - assert!(p.key_created); - assert!(known("codex-wsl-ubuntu")); - } - - /// WSL 1 和 mirrored 下,WSL 里的客户端写的就是 `127.0.0.1`,和这台电脑上的一样 - /// (地址由 `wsl::target` 给,它交回的就是这台电脑的那一个);还原回到原样 - #[test] - fn a_wsl_copy_is_pointed_at_127_0_0_1_and_restored_to_what_it_was() { - let (d, w) = wsl_home(); - let b = d.path().join("backups"); - let settings = w.home.join(".claude").join("settings.json"); - let original = - r#"{ "model": "opus", "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com" } }"#; - std::fs::write(&settings, original).unwrap(); - let config = w.home.join(".codex").join("config.toml"); - std::fs::write(&config, "model = \"gpt-5\"\n").unwrap(); - - adopt( - &w.home, - &b, - "claude-code", - &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-c", Vec::new()), - None, - &Around::default(), - ) - .unwrap(); - adopt( - &w.home, - &b, - "codex", - &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-x", Vec::new()), - None, - &Around::default(), - ) - .unwrap(); - let listed = list_wsl( - &w, - &b, - &Gateway { - base: "http://127.0.0.1:8788".into(), - keys: Vec::new(), - }, - ); - for c in &listed { - let e = c.endpoint.as_deref().unwrap_or_default(); - assert!(e.starts_with("http://127.0.0.1:8788"), "{}: {e}", c.id); - assert!(is_loopback(e)); - } - - restore(&w.home, &b, "claude-code", None).unwrap(); - restore(&w.home, &b, "codex", None).unwrap(); - let back: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); - let want: serde_json::Value = serde_json::from_str(original).unwrap(); - assert_eq!(back, want); - assert!(adopted(&w.home, &b).is_empty()); - } - - /// 按 NAT 的做法接管过的(指着 WSL 虚拟网卡的地址)不迁移:列出来的就是它 - /// 此刻指着的地址(界面据此标成未生效),照样能还原 - #[test] - fn a_copy_adopted_the_old_nat_way_is_listed_as_is_and_can_be_restored() { - let (d, w) = wsl_home(); - let b = d.path().join("backups"); - adopt( - &w.home, - &b, - "claude-code", - &tw_adopt::clients::Gateway::keyed("http://172.27.96.1:8788", "tw-c", Vec::new()), - None, - &Around::default(), - ) - .unwrap(); - let gw = Gateway { - base: "http://127.0.0.1:8788".into(), - keys: Vec::new(), - }; - let cc = list_wsl(&w, &b, &gw) - .into_iter() - .find(|c| c.id == "claude-code") - .unwrap(); - assert!(cc.adopted_at_ms.is_some()); - assert_eq!(cc.endpoint.as_deref(), Some("http://172.27.96.1:8788")); - restore(&w.home, &b, "claude-code", None).unwrap(); - assert!(adopted(&w.home, &b).is_empty()); - } - - /// 换了密钥之后重新指一次:新值写进它的配置 - #[test] - fn repointing_writes_the_new_key_into_the_clients_config() { - let home = home_with_claude(); - adopt( - home.path(), - &backups(&home), - "claude-code", - &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-old", Vec::new()), - None, - &Around::default(), - ) - .unwrap(); - let c = find("claude-code", home.path()).unwrap(); - let s = repoint( - home.path(), - &backups(&home), - &c, - "http://127.0.0.1:8788", - "tw-fresh", - Vec::new(), - &Around::default(), - ) - .unwrap(); - assert_eq!(s.client, "claude-code"); - let text = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap(); - assert!( - text.contains("tw-fresh") && !text.contains("tw-old"), - "{text}" - ); - } - - /// 重新指向 Claude Desktop:**模型列表按新网关此刻答的换**,和接管同一个入口。 - /// 按单个文件的通用那一套算的话,列表还是旧网关那一份 - #[test] - fn repointing_claude_desktop_rewrites_its_model_list_too() { - let home = tempfile::tempdir().unwrap(); - if tw_adopt::desktop::managed(home.path()).is_some() { - // 这台跑测试的机器上的 Claude Desktop 由组织托管:接管本来就会被拒 - return; - } - let first = tw_adopt::desktop::first_party_config(home.path()); - std::fs::create_dir_all(first.parent().unwrap()).unwrap(); - std::fs::write(&first, "{\n \"globalShortcut\": \"Alt+Space\"\n}\n").unwrap(); - let base = "http://127.0.0.1:8788"; - let id = tw_adopt::desktop::ID; - adopt( - home.path(), - &backups(&home), - id, - &tw_adopt::clients::Gateway::keyed(base, "tw-old", vec!["claude-sonnet-5".into()]), - None, - &Around::default(), - ) - .unwrap(); - let c = find(id, home.path()).unwrap(); - repoint( - home.path(), - &backups(&home), - &c, - "http://10.0.0.2:8788", - "tw-fresh", - vec!["claude-opus-5".into(), "gpt-5".into()], - &Around::default(), - ) - .unwrap(); - let profile: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tw_adopt::desktop::profile_path(home.path())).unwrap(), - ) - .unwrap(); - assert_eq!(profile["inferenceGatewayApiKey"], "tw-fresh"); - assert_eq!( - profile["inferenceModels"], - serde_json::json!(["claude-opus-5"]) - ); - } - - /// 同一份原文同一个指纹;原文、路径、有没有这个文件,任何一样不同都不同 - #[test] - fn the_fingerprint_follows_every_file_the_plan_read() { - let a = Path::new("/h/a.json"); - let b = Path::new("/h/b.json"); - let f = |xs: &[(&Path, Option<&[u8]>)]| fingerprint(xs.iter().copied()); - assert_eq!(f(&[(a, Some(b"x"))]), f(&[(a, Some(b"x"))])); - assert_ne!(f(&[(a, Some(b"x"))]), f(&[(a, Some(b"y"))])); - assert_ne!(f(&[(a, Some(b"x"))]), f(&[(b, Some(b"x"))])); - assert_ne!(f(&[(a, None)]), f(&[(a, Some(b""))])); - assert_ne!( - f(&[(a, Some(b"x"))]), - f(&[(a, Some(b"x")), (b, None)]), - "多一份文件也算" - ); - } - - /// 看着差异的时候文件被改了:**什么都不写**,按此刻的文件重算的那一份才写得进去 - #[test] - fn a_file_changed_while_its_diff_was_shown_is_not_written() { - let home = home_with_claude(); - let settings = home.path().join(".claude/settings.json"); - std::fs::write(&settings, "{\n \"model\": \"opus\"\n}\n").unwrap(); - let g = gw(vec![key("default", "tw-d", None, true)]); - let shown = plan_adopt( - home.path(), - "claude-code", - &g, - Vec::new(), - &Around::default(), - ) - .unwrap(); - - // 客户端自己在这时改了一项设置 - let changed = "{\n \"model\": \"sonnet\"\n}\n"; - std::fs::write(&settings, changed).unwrap(); - let adopt_now = |expect: &str| { - adopt( - home.path(), - &backups(&home), - "claude-code", - &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-d", Vec::new()), - Some(expect), - &Around::default(), - ) - }; - let e = adopt_now(&shown.digest).unwrap_err(); - assert_eq!(e.code, "adopt.plan.stale"); - assert_eq!(std::fs::read_to_string(&settings).unwrap(), changed); - assert!(adopted(home.path(), &backups(&home)).is_empty()); - - // 重新算一份给人看,照那一份就写得进去 - let again = plan_adopt( - home.path(), - "claude-code", - &g, - Vec::new(), - &Around::default(), - ) - .unwrap(); - assert_ne!(again.digest, shown.digest); - adopt_now(&again.digest).unwrap(); - assert_eq!(adopted(home.path(), &backups(&home)).len(), 1); - - // 还原也一样 - let shown = plan_restore(home.path(), "claude-code", &[]).unwrap(); - let edited = std::fs::read_to_string(&settings) - .unwrap() - .replace("sonnet", "haiku"); - std::fs::write(&settings, &edited).unwrap(); - let e = restore( - home.path(), - &backups(&home), - "claude-code", - Some(&shown.digest), - ) - .unwrap_err(); - assert_eq!(e.code, "adopt.plan.stale"); - assert_eq!(std::fs::read_to_string(&settings).unwrap(), edited); - let again = plan_restore(home.path(), "claude-code", &[]).unwrap(); - restore( - home.path(), - &backups(&home), - "claude-code", - Some(&again.digest), - ) - .unwrap(); - assert!(adopted(home.path(), &backups(&home)).is_empty()); - } -} +pub(crate) mod tests; diff --git a/src-tauri/src/clients/ops/tests.rs b/src-tauri/src/clients/ops/tests.rs new file mode 100644 index 00000000..f89a459d --- /dev/null +++ b/src-tauri/src/clients/ops/tests.rs @@ -0,0 +1,931 @@ +use super::*; + +pub(crate) fn key(name: &str, value: &str, client: Option<&str>, default: bool) -> ClientView { + ClientView { + name: name.into(), + key: value.into(), + max_concurrent: None, + route: None, + allow: None, + client: client.map(str::to_string), + disabled: false, + default, + last_seen_ms: None, + limits: vec![], + unpriced_models: vec![], + } +} + +fn gw(keys: Vec) -> Gateway { + Gateway { + base: "http://127.0.0.1:8788".into(), + keys, + } +} + +/// 备份放在这台假机器里,**不碰开发者自己的数据目录** +fn backups(home: &tempfile::TempDir) -> std::path::PathBuf { + home.path().join("backups") +} + +/// 一台装了 Claude Code 的机器:`~/.claude/` 在,配置文件还没有 +fn home_with_claude() -> tempfile::TempDir { + let d = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(d.path().join(".claude")).unwrap(); + d +} + +#[test] +fn listing_says_which_clients_are_here_and_which_are_only_advice() { + let home = home_with_claude(); + let r = list( + home.path(), + &backups(&home), + &gw(vec![key("default", "tw-d", None, true)]), + &BTreeMap::new(), + ); + let cc = r.clients.iter().find(|c| c.id == "claude-code").unwrap(); + assert!(cc.installed); + assert_eq!(cc.adopted_at_ms, None); + assert!(r.manual.iter().any(|m| m.id == "cursor")); + assert_eq!(r.gateway_base, "http://127.0.0.1:8788"); + assert_eq!(r.keys, vec!["default"]); +} + +/// 「使用中」按那把密钥算:为它生成的密钥被用过,就是它被用过 +#[test] +fn in_use_is_read_off_the_clients_own_key() { + let home = home_with_claude(); + let mut mine = key("claude-code", "tw-c", Some("claude-code"), false); + mine.last_seen_ms = Some(42); + let r = list( + home.path(), + &backups(&home), + &gw(vec![key("default", "tw-d", None, true), mine]), + &BTreeMap::new(), + ); + let cc = r.clients.iter().find(|c| c.id == "claude-code").unwrap(); + assert_eq!(cc.key.as_deref(), Some("claude-code")); + assert_eq!(cc.last_seen_ms, Some(42)); + let cursor = r.manual.iter().find(|m| m.id == "cursor").unwrap(); + assert_eq!(cursor.key, None); + assert_eq!(cursor.last_seen_ms, None); +} + +/// 手动配置给出的字段就是接管时写的那几项,密钥那一项不给值 +#[test] +fn every_client_says_how_to_connect_it_by_hand_without_the_key() { + let home = tempfile::tempdir().unwrap(); + let r = list( + home.path(), + &backups(&home), + &gw(vec![key("default", "tw-d", None, true)]), + &BTreeMap::new(), + ); + let cc = r.clients.iter().find(|c| c.id == "claude-code").unwrap(); + assert!(!cc.manual.fields.is_empty()); + assert!( + cc.manual + .fields + .iter() + .any(|f| f.secret && f.value.is_none()) + ); + let json = serde_json::to_string(&r).unwrap(); + assert!(!json.contains("tw-d"), "{json}"); +} + +#[test] +fn planning_writes_nothing_and_never_echoes_the_key() { + let home = home_with_claude(); + let p = plan_adopt( + home.path(), + "claude-code", + &gw(vec![key("default", "tw-secret-value", None, true)]), + Vec::new(), + &Around::default(), + ) + .unwrap(); + assert!(!home.path().join(".claude/settings.json").exists()); + assert!(!p.after.contains("tw-secret-value"), "{}", p.after); + assert!(p.after.contains(MASK)); + // 还没有为它留着的:确认之前就说要新建一把,叫什么 + assert_eq!(p.key.as_deref(), Some("claude-code")); + assert!(p.key_created); +} + +/// 为它留着一把的,接着用那一把;名字被别的占了就往后编号 +#[test] +fn the_plan_says_which_key_goes_in_and_whether_it_is_made_now() { + let home = home_with_claude(); + let kept = plan_adopt( + home.path(), + "claude-code", + &gw(vec![ + key("default", "tw-d", None, true), + key("mine", "tw-m", Some("claude-code"), false), + ]), + Vec::new(), + &Around::default(), + ) + .unwrap(); + assert_eq!(kept.key.as_deref(), Some("mine")); + assert!(!kept.key_created); + + let taken = plan_adopt( + home.path(), + "claude-code", + &gw(vec![ + key("default", "tw-d", None, true), + key("claude-code", "tw-x", None, false), + ]), + Vec::new(), + &Around::default(), + ) + .unwrap(); + assert_eq!(taken.key.as_deref(), Some("claude-code-2")); + assert!(taken.key_created); +} + +#[test] +fn without_any_key_there_is_nothing_to_point_a_client_with() { + let home = home_with_claude(); + let e = plan_adopt( + home.path(), + "claude-code", + &gw(Vec::new()), + Vec::new(), + &Around::default(), + ) + .unwrap_err(); + assert_eq!(e.code, "control.no_keys"); +} + +#[test] +fn a_client_we_do_not_know_is_refused_by_name() { + let home = tempfile::tempdir().unwrap(); + let e = plan_adopt( + home.path(), + "../etc", + &gw(Vec::new()), + Vec::new(), + &Around::default(), + ) + .unwrap_err(); + assert_eq!(e.code, "control.client_unknown"); + assert!(!known("../etc")); + assert!(known("claude-code") && known("cursor")); +} + +/// `/setup-bedrock` 把 Bedrock 的 API key、访问密钥写在 settings.json 的 `env` 里: +/// diff 画的是整份文件,它们一律打码;区域、开关这些照常显示 +#[test] +fn aws_credentials_in_claude_codes_env_never_show_in_the_diff() { + let home = home_with_claude(); + let settings = home.path().join(".claude/settings.json"); + std::fs::write( + &settings, + r#"{ "env": { + "CLAUDE_CODE_USE_BEDROCK": "1", + "AWS_REGION": "us-west-2", + "AWS_BEARER_TOKEN_BEDROCK": "ABSKQmVkcm9ja0FQSUtleS1leGFtcGxl", + "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +} }"#, + ) + .unwrap(); + let secrets = [ + "ABSKQmVkcm9ja0FQSUtleS1leGFtcGxl", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ]; + let p = plan_adopt( + home.path(), + "claude-code", + &gw(vec![key("default", "tw-new", None, true)]), + Vec::new(), + &Around::default(), + ) + .unwrap(); + for text in [p.before.as_deref().unwrap(), p.after.as_str()] { + for s in secrets { + assert!(!text.contains(s), "{s} shows in:\n{text}"); + } + assert!(text.contains("us-west-2"), "{text}"); + } + assert!( + p.after.contains("\"CLAUDE_CODE_USE_BEDROCK\": \"\""), + "{}", + p.after + ); + assert!( + p.fields + .iter() + .any(|f| f.path == "env.CLAUDE_CODE_USE_BEDROCK" && f.value.as_deref() == Some("")), + "{:?}", + p.fields + ); + // 交给界面的整份(说明、新建上游的草稿)里也没有:草稿里只有变量引用 + let sent = serde_json::to_string(&p).unwrap(); + for s in secrets { + assert!(!sent.contains(s), "{s} goes to the UI:\n{sent}"); + } + assert!( + matches!(&p.bedrock, Some(d) if d.region == "us-west-2" + && matches!(&d.auth, wire::DraftAuth::Key { key } if key == "${AWS_BEARER_TOKEN_BEDROCK}")), + "{:?}", + p.bedrock + ); + + adopt( + home.path(), + &backups(&home), + "claude-code", + &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-new", Vec::new()), + Some(&p.digest), + &Around::default(), + ) + .unwrap(); + let r = plan_restore(home.path(), "claude-code", &[]).unwrap(); + for text in [r.before.as_deref().unwrap(), r.after.as_str()] { + for s in secrets { + assert!(!text.contains(s), "{s} shows in:\n{text}"); + } + } +} + +/// Grok Build 一个模型一张表,整张表算密钥:字段列表里不给值,diff 里网关那把打码; +/// 用户自己那张表里发给别家的 `api_key`、Qwen Code `/auth` 写在 `env` 里的密钥,diff 里 +/// 同样看不到 +#[test] +fn grok_and_qwen_diffs_show_no_key_of_anyone() { + let home = tempfile::tempdir().unwrap(); + let grok = tw_adopt::paths::GROK_CONFIG.resolve(home.path()); + std::fs::create_dir_all(grok.parent().unwrap()).unwrap(); + std::fs::write( + &grok, + "[model.mine]\nmodel = \"x\"\nbase_url = \"https://api.example.com/v1\"\napi_key = \"sk-mine-0123456789\"\n", + ) + .unwrap(); + let qwen = tw_adopt::paths::QWEN_SETTINGS.resolve(home.path()); + std::fs::create_dir_all(qwen.parent().unwrap()).unwrap(); + std::fs::write( + &qwen, + r#"{ "env": { "DASHSCOPE_API_KEY": "sk-dash-0123456789" } }"#, + ) + .unwrap(); + let g = gw(vec![key("default", "tw-secret-value", None, true)]); + let models: Vec = vec!["claude-sonnet-5".into(), "gpt-5.5".into()]; + for (id, theirs) in [ + ("grok-build", "sk-mine-0123456789"), + ("qwen-code", "sk-dash-0123456789"), + ] { + let p = plan_adopt(home.path(), id, &g, models.clone(), &Around::default()).unwrap(); + for text in [p.before.as_deref().unwrap(), p.after.as_str()] { + assert!(!text.contains(theirs), "{id}: {text}"); + assert!(!text.contains("tw-secret-value"), "{id}: {text}"); + } + assert!(p.after.contains(MASK), "{id}: {}", p.after); + let sent = serde_json::to_string(&p).unwrap(); + assert!(!sent.contains("tw-secret-value"), "{id}: {sent}"); + } + let p = plan_adopt(home.path(), "grok-build", &g, models, &Around::default()).unwrap(); + let table = p + .fields + .iter() + .find(|f| f.path == "model.thinkwatch/gpt-5.5") + .expect("一个模型一张表"); + assert!(table.secret && table.value.is_none(), "{table:?}"); + assert!( + p.fields + .iter() + .any(|f| f.path == "models.default" && f.value.is_some()), + "{:?}", + p.fields + ); +} + +/// 关哪几个开关还看 shell 配置和用户环境:确认框和落盘之间那边变了,写下去的就会多 +/// 一项确认框里没有的改动 —— 当成「改过了」,什么都不写 +#[test] +fn a_switch_that_appears_after_the_review_stops_the_write() { + let home = home_with_claude(); + let settings = home.path().join(".claude/settings.json"); + std::fs::write(&settings, "{}\n").unwrap(); + let g = gw(vec![key("default", "tw-new", None, true)]); + let reviewed = plan_adopt( + home.path(), + "claude-code", + &g, + Vec::new(), + &Around::default(), + ) + .unwrap(); + let later = Around { + env: [("CLAUDE_CODE_USE_BEDROCK".to_string(), "1".to_string())].into(), + ..Default::default() + }; + let e = adopt( + home.path(), + &backups(&home), + "claude-code", + &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-new", Vec::new()), + Some(&reviewed.digest), + &later, + ) + .unwrap_err(); + assert_eq!(e.code, "adopt.plan.stale"); + assert_eq!(std::fs::read_to_string(&settings).unwrap(), "{}\n"); +} + +#[test] +fn adopt_then_restore_puts_the_users_file_back_and_masks_their_key() { + let home = home_with_claude(); + let settings = home.path().join(".claude/settings.json"); + let original = r#"{ "env": { "ANTHROPIC_BASE_URL": "https://example.com", "ANTHROPIC_AUTH_TOKEN": "users-own" } }"#; + std::fs::write(&settings, original).unwrap(); + + // 接管的 diff:被换下来的是用户自己的那把,改之前那一栏里也不能有它 + let p = plan_adopt( + home.path(), + "claude-code", + &gw(vec![key("default", "tw-new", None, true)]), + Vec::new(), + &Around::default(), + ) + .unwrap(); + let before = p.before.as_deref().unwrap(); + assert!(!before.contains("users-own"), "{before}"); + assert!(before.contains(HIDDEN), "{before}"); + assert!( + p.after.contains(MASK) && !p.after.contains("tw-new"), + "{}", + p.after + ); + // 不是密钥的照常显示 + assert!(before.contains("https://example.com"), "{before}"); + + let a = adopt( + home.path(), + &backups(&home), + "claude-code", + &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-new", Vec::new()), + None, + &Around::default(), + ) + .unwrap(); + assert_eq!(a.takes_effect, wire::TakesEffect::Immediately); + let written = std::fs::read_to_string(&settings).unwrap(); + assert!(written.contains("tw-new") && written.contains("127.0.0.1:8788")); + assert!( + adopted(home.path(), &backups(&home)) + .iter() + .any(|c| c.id == "claude-code") + ); + + let keys = vec![key("claude-code", "tw-new", Some("claude-code"), false)]; + let p = plan_restore(home.path(), "claude-code", &keys).unwrap(); + assert!(!p.before.as_deref().unwrap().contains("tw-new")); + assert_eq!(p.key.as_deref(), Some("claude-code")); + // 用户自己的那把正要被写回去:它也不出现在 diff 里 + assert!(!p.after.contains("tw-new")); + assert!(!p.after.contains("users-own"), "{}", p.after); + // core 不在(拿不到密钥清单)时,网关那把照样盖得住:它在密钥字段上 + let offline = plan_restore(home.path(), "claude-code", &[]).unwrap(); + assert!(!offline.before.as_deref().unwrap().contains("tw-new")); + assert!(!offline.after.contains("users-own"), "{}", offline.after); + + restore(home.path(), &backups(&home), "claude-code", None).unwrap(); + let back: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); + let want: serde_json::Value = serde_json::from_str(original).unwrap(); + assert_eq!(back, want); + assert!(adopted(home.path(), &backups(&home)).is_empty()); +} + +/// opencode 的模型清单跟网关对不上了才提示更新;顺序不算,没接管的不提示 +#[test] +fn a_stale_opencode_model_list_is_flagged_on_the_listing() { + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(home.path().join(".config/opencode")).unwrap(); + let keys = vec![key("opencode", "tw-o", Some("opencode"), false)]; + let now = |ms: &[&str]| { + BTreeMap::from([( + "opencode".to_string(), + ms.iter().map(|m| ModelCard::named(*m)).collect::>(), + )]) + }; + let stale = |models: &BTreeMap>| { + list(home.path(), &backups(&home), &gw(keys.clone()), models) + .clients + .into_iter() + .find(|c| c.id == "opencode") + .unwrap() + .models_stale + }; + assert!(!stale(&now(&["a"])), "没接管的没有清单可比"); + + adopt( + home.path(), + &backups(&home), + "opencode", + &tw_adopt::clients::Gateway::keyed( + "http://127.0.0.1:8788", + "tw-o", + vec!["a".into(), "b".into()], + ), + None, + &Around::default(), + ) + .unwrap(); + assert!(!stale(&now(&["b", "a"]))); + assert!(stale(&now(&["a", "b", "c"]))); + assert!(!stale(&BTreeMap::new()), "问不到网关就不说"); + + // 手动配置那一栏照网关此刻答的写 + let r = list( + home.path(), + &backups(&home), + &gw(keys.clone()), + &now(&["m1"]), + ); + let oc = r.clients.iter().find(|c| c.id == "opencode").unwrap(); + assert!( + oc.manual + .fields + .iter() + .any(|f| f.path == "provider.thinkwatch.models" + && f.value.as_deref() == Some("{m1: {name: m1}}")), + "{:?}", + oc.manual.fields + ); +} + +/// Pi 和 oh-my-pi 也把模型写进配置:清单跟网关对不上了一样提示更新;diff 里看不到密钥 +#[test] +fn a_stale_pi_or_omp_model_list_is_flagged_too() { + for id in ["pi", "omp"] { + let home = tempfile::tempdir().unwrap(); + let keys = vec![key(id, "tw-p", Some(id), false)]; + let now = |ms: &[&str]| { + BTreeMap::from([( + id.to_string(), + ms.iter().map(|m| ModelCard::named(*m)).collect::>(), + )]) + }; + let stale = |models: &BTreeMap>| { + list(home.path(), &backups(&home), &gw(keys.clone()), models) + .clients + .into_iter() + .find(|c| c.id == id) + .unwrap() + .models_stale + }; + let target = tw_adopt::clients::Gateway::keyed( + "http://127.0.0.1:8788", + "tw-p", + vec!["claude-a".into(), "b".into()], + ); + let v = plan_adopt( + home.path(), + id, + &gw(keys.clone()), + target.models.clone(), + &Around::default(), + ) + .unwrap(); + assert!(!v.after.contains("tw-p"), "{id}:{}", v.after); + assert!( + v.fields.iter().any(|f| f.secret && f.value.is_none()), + "{id}" + ); + adopt( + home.path(), + &backups(&home), + id, + &target, + None, + &Around::default(), + ) + .unwrap(); + assert!(!stale(&now(&["b", "claude-a"])), "{id}"); + assert!(stale(&now(&["claude-a"])), "{id}"); + } +} + +/// 别处的 home(WSL 里的、测试的临时目录)不带用户指定的配置文件:那份设置只管 +/// 这台电脑上的 +#[test] +fn a_home_other_than_this_computers_gets_the_default_files() { + let home = home_with_claude(); + assert!(all(home.path()).iter().all(|c| c.custom_config.is_none())); +} + +#[test] +fn restoring_something_we_never_adopted_refuses_instead_of_guessing() { + let home = home_with_claude(); + assert!(restore(home.path(), &backups(&home), "claude-code", None).is_err()); +} + +/// 接管着的客户端的密钥删不得;它的主人是哪个,只有这台机器答得上来 +#[test] +fn a_key_in_an_adopted_clients_config_is_found_by_its_owner() { + let home = home_with_claude(); + let keys = vec![ + key("default", "tw-d", None, true), + key("claude-code", "tw-c", Some("claude-code"), false), + ]; + assert!(adopted_owner(home.path(), &backups(&home), &keys, "claude-code").is_none()); + adopt( + home.path(), + &backups(&home), + "claude-code", + &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-c", Vec::new()), + None, + &Around::default(), + ) + .unwrap(); + let owner = adopted_owner(home.path(), &backups(&home), &keys, "claude-code").unwrap(); + assert_eq!(owner.id, "claude-code"); + assert_eq!(key_used_by(owner.name).code, "control.key_used_by_client"); + assert!(adopted_owner(home.path(), &backups(&home), &keys, "default").is_none()); +} + +#[test] +fn a_loopback_endpoint_is_this_machine_and_a_server_is_not() { + for e in [ + "http://127.0.0.1:8788", + "http://127.0.0.1:8788/v1", + "http://localhost:8788/v1", + "http://[::1]:8788", + ] { + assert!(is_loopback(e), "{e}"); + } + for e in [ + "http://192.168.1.20:8788/v1", + "http://nas.local:8788", + "", + "http://", + ] { + assert!(!is_loopback(e), "{e}"); + } + assert_eq!( + host_port("http://127.0.0.1:8788/v1"), + Some("127.0.0.1:8788") + ); +} + +/// 接管着、指着本机网关的才算;指到了服务器上的不算 +#[test] +fn only_clients_pointing_at_this_machine_count() { + let home = home_with_claude(); + std::fs::create_dir_all(home.path().join(".codex")).unwrap(); + adopt( + home.path(), + &backups(&home), + "claude-code", + &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-c", Vec::new()), + None, + &Around::default(), + ) + .unwrap(); + adopt( + home.path(), + &backups(&home), + "codex", + &tw_adopt::clients::Gateway::keyed("http://192.168.1.20:8788", "tw-x", Vec::new()), + None, + &Around::default(), + ) + .unwrap(); + let here: Vec<_> = adopted_on_this_machine(home.path(), &backups(&home)) + .into_iter() + .map(|(c, e)| (c.id, e)) + .collect(); + assert_eq!( + here, + vec![("claude-code", "http://127.0.0.1:8788".to_string())] + ); +} + +/// 一个假的 WSL 发行版:`etc/passwd` 和默认用户的 home,Claude Code 和 Codex 都装了 +pub(crate) fn wsl_home() -> (tempfile::TempDir, tw_adopt::wsl::WslHome) { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("Ubuntu"); + std::fs::create_dir_all(root.join("etc")).unwrap(); + std::fs::write( + root.join("etc/passwd"), + "u:x:1000:1000::/home/u:/bin/bash\n", + ) + .unwrap(); + std::fs::create_dir_all(root.join("home/u/.claude")).unwrap(); + std::fs::create_dir_all(root.join("home/u/.codex")).unwrap(); + std::fs::create_dir_all(root.join("home/u/.config/zed")).unwrap(); + let w = tw_adopt::wsl::WslHome::read( + tw_adopt::wsl::Distro { + name: "Ubuntu".into(), + version: 2, + uid: 1000, + }, + root, + ) + .unwrap(); + (d, w) +} + +/// WSL 里只列第一批的那两个;路径写成 WSL 里的样子;密钥认的是为 WSL 里 +/// 这一份发的那把,不是这台电脑上同一个客户端的那把 +#[test] +fn a_wsl_distro_lists_its_own_copies_with_their_own_keys() { + let (d, w) = wsl_home(); + let b = d.path().join("backups"); + let mut mine = key( + "claude-code-wsl-ubuntu", + "tw-w", + Some("claude-code-wsl-ubuntu"), + false, + ); + mine.last_seen_ms = Some(7); + let gw = Gateway { + base: "http://127.0.0.1:8788".into(), + keys: vec![ + key("default", "tw-d", None, true), + key("claude-code", "tw-c", Some("claude-code"), false), + mine, + ], + }; + let r = list_wsl(&w, &b, &gw); + let ids: Vec<_> = r.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(ids, ["claude-code", "codex"]); + let cc = &r[0]; + assert!(cc.installed); + assert_eq!(cc.path, "~/.claude/settings.json"); + assert_eq!(cc.key.as_deref(), Some("claude-code-wsl-ubuntu")); + assert_eq!(cc.last_seen_ms, Some(7)); + // 手动配置给的地址和这台电脑上的一样 + assert_eq!(cc.manual.endpoint, "http://127.0.0.1:8788"); + assert_eq!(cc.manual.steps[0].arg("file"), "~/.claude/settings.json"); + assert_eq!(r[1].key, None); + // 接管的方案:新建的那把叫 WSL 那一份的名字 + let p = plan_adopt_as( + &w.home, + "codex", + "codex-wsl-ubuntu", + &gw, + Vec::new(), + &Around::default(), + ) + .unwrap(); + assert_eq!(p.key.as_deref(), Some("codex-wsl-ubuntu")); + assert!(p.key_created); + assert!(known("codex-wsl-ubuntu")); +} + +/// WSL 1 和 mirrored 下,WSL 里的客户端写的就是 `127.0.0.1`,和这台电脑上的一样 +/// (地址由 `wsl::target` 给,它交回的就是这台电脑的那一个);还原回到原样 +#[test] +fn a_wsl_copy_is_pointed_at_127_0_0_1_and_restored_to_what_it_was() { + let (d, w) = wsl_home(); + let b = d.path().join("backups"); + let settings = w.home.join(".claude").join("settings.json"); + let original = + r#"{ "model": "opus", "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com" } }"#; + std::fs::write(&settings, original).unwrap(); + let config = w.home.join(".codex").join("config.toml"); + std::fs::write(&config, "model = \"gpt-5\"\n").unwrap(); + + adopt( + &w.home, + &b, + "claude-code", + &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-c", Vec::new()), + None, + &Around::default(), + ) + .unwrap(); + adopt( + &w.home, + &b, + "codex", + &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-x", Vec::new()), + None, + &Around::default(), + ) + .unwrap(); + let listed = list_wsl( + &w, + &b, + &Gateway { + base: "http://127.0.0.1:8788".into(), + keys: Vec::new(), + }, + ); + for c in &listed { + let e = c.endpoint.as_deref().unwrap_or_default(); + assert!(e.starts_with("http://127.0.0.1:8788"), "{}: {e}", c.id); + assert!(is_loopback(e)); + } + + restore(&w.home, &b, "claude-code", None).unwrap(); + restore(&w.home, &b, "codex", None).unwrap(); + let back: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); + let want: serde_json::Value = serde_json::from_str(original).unwrap(); + assert_eq!(back, want); + assert!(adopted(&w.home, &b).is_empty()); +} + +/// 按 NAT 的做法接管过的(指着 WSL 虚拟网卡的地址)不迁移:列出来的就是它 +/// 此刻指着的地址(界面据此标成未生效),照样能还原 +#[test] +fn a_copy_adopted_the_old_nat_way_is_listed_as_is_and_can_be_restored() { + let (d, w) = wsl_home(); + let b = d.path().join("backups"); + adopt( + &w.home, + &b, + "claude-code", + &tw_adopt::clients::Gateway::keyed("http://172.27.96.1:8788", "tw-c", Vec::new()), + None, + &Around::default(), + ) + .unwrap(); + let gw = Gateway { + base: "http://127.0.0.1:8788".into(), + keys: Vec::new(), + }; + let cc = list_wsl(&w, &b, &gw) + .into_iter() + .find(|c| c.id == "claude-code") + .unwrap(); + assert!(cc.adopted_at_ms.is_some()); + assert_eq!(cc.endpoint.as_deref(), Some("http://172.27.96.1:8788")); + restore(&w.home, &b, "claude-code", None).unwrap(); + assert!(adopted(&w.home, &b).is_empty()); +} + +/// 换了密钥之后重新指一次:新值写进它的配置 +#[test] +fn repointing_writes_the_new_key_into_the_clients_config() { + let home = home_with_claude(); + adopt( + home.path(), + &backups(&home), + "claude-code", + &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-old", Vec::new()), + None, + &Around::default(), + ) + .unwrap(); + let c = find("claude-code", home.path()).unwrap(); + let s = repoint( + home.path(), + &backups(&home), + &c, + "http://127.0.0.1:8788", + "tw-fresh", + Vec::new(), + &Around::default(), + ) + .unwrap(); + assert_eq!(s.client, "claude-code"); + let text = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap(); + assert!( + text.contains("tw-fresh") && !text.contains("tw-old"), + "{text}" + ); +} + +/// 重新指向 Claude Desktop:**模型列表按新网关此刻答的换**,和接管同一个入口。 +/// 按单个文件的通用那一套算的话,列表还是旧网关那一份 +#[test] +fn repointing_claude_desktop_rewrites_its_model_list_too() { + let home = tempfile::tempdir().unwrap(); + if tw_adopt::desktop::managed(home.path()).is_some() { + // 这台跑测试的机器上的 Claude Desktop 由组织托管:接管本来就会被拒 + return; + } + let first = tw_adopt::desktop::first_party_config(home.path()); + std::fs::create_dir_all(first.parent().unwrap()).unwrap(); + std::fs::write(&first, "{\n \"globalShortcut\": \"Alt+Space\"\n}\n").unwrap(); + let base = "http://127.0.0.1:8788"; + let id = tw_adopt::desktop::ID; + adopt( + home.path(), + &backups(&home), + id, + &tw_adopt::clients::Gateway::keyed(base, "tw-old", vec!["claude-sonnet-5".into()]), + None, + &Around::default(), + ) + .unwrap(); + let c = find(id, home.path()).unwrap(); + repoint( + home.path(), + &backups(&home), + &c, + "http://10.0.0.2:8788", + "tw-fresh", + vec!["claude-opus-5".into(), "gpt-5".into()], + &Around::default(), + ) + .unwrap(); + let profile: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tw_adopt::desktop::profile_path(home.path())).unwrap(), + ) + .unwrap(); + assert_eq!(profile["inferenceGatewayApiKey"], "tw-fresh"); + assert_eq!( + profile["inferenceModels"], + serde_json::json!(["claude-opus-5"]) + ); +} + +/// 同一份原文同一个指纹;原文、路径、有没有这个文件,任何一样不同都不同 +#[test] +fn the_fingerprint_follows_every_file_the_plan_read() { + let a = Path::new("/h/a.json"); + let b = Path::new("/h/b.json"); + let f = |xs: &[(&Path, Option<&[u8]>)]| fingerprint(xs.iter().copied()); + assert_eq!(f(&[(a, Some(b"x"))]), f(&[(a, Some(b"x"))])); + assert_ne!(f(&[(a, Some(b"x"))]), f(&[(a, Some(b"y"))])); + assert_ne!(f(&[(a, Some(b"x"))]), f(&[(b, Some(b"x"))])); + assert_ne!(f(&[(a, None)]), f(&[(a, Some(b""))])); + assert_ne!( + f(&[(a, Some(b"x"))]), + f(&[(a, Some(b"x")), (b, None)]), + "多一份文件也算" + ); +} + +/// 看着差异的时候文件被改了:**什么都不写**,按此刻的文件重算的那一份才写得进去 +#[test] +fn a_file_changed_while_its_diff_was_shown_is_not_written() { + let home = home_with_claude(); + let settings = home.path().join(".claude/settings.json"); + std::fs::write(&settings, "{\n \"model\": \"opus\"\n}\n").unwrap(); + let g = gw(vec![key("default", "tw-d", None, true)]); + let shown = plan_adopt( + home.path(), + "claude-code", + &g, + Vec::new(), + &Around::default(), + ) + .unwrap(); + + // 客户端自己在这时改了一项设置 + let changed = "{\n \"model\": \"sonnet\"\n}\n"; + std::fs::write(&settings, changed).unwrap(); + let adopt_now = |expect: &str| { + adopt( + home.path(), + &backups(&home), + "claude-code", + &tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", "tw-d", Vec::new()), + Some(expect), + &Around::default(), + ) + }; + let e = adopt_now(&shown.digest).unwrap_err(); + assert_eq!(e.code, "adopt.plan.stale"); + assert_eq!(std::fs::read_to_string(&settings).unwrap(), changed); + assert!(adopted(home.path(), &backups(&home)).is_empty()); + + // 重新算一份给人看,照那一份就写得进去 + let again = plan_adopt( + home.path(), + "claude-code", + &g, + Vec::new(), + &Around::default(), + ) + .unwrap(); + assert_ne!(again.digest, shown.digest); + adopt_now(&again.digest).unwrap(); + assert_eq!(adopted(home.path(), &backups(&home)).len(), 1); + + // 还原也一样 + let shown = plan_restore(home.path(), "claude-code", &[]).unwrap(); + let edited = std::fs::read_to_string(&settings) + .unwrap() + .replace("sonnet", "haiku"); + std::fs::write(&settings, &edited).unwrap(); + let e = restore( + home.path(), + &backups(&home), + "claude-code", + Some(&shown.digest), + ) + .unwrap_err(); + assert_eq!(e.code, "adopt.plan.stale"); + assert_eq!(std::fs::read_to_string(&settings).unwrap(), edited); + let again = plan_restore(home.path(), "claude-code", &[]).unwrap(); + restore( + home.path(), + &backups(&home), + "claude-code", + Some(&again.digest), + ) + .unwrap(); + assert!(adopted(home.path(), &backups(&home)).is_empty()); +} diff --git a/src-tauri/src/connection/store.rs b/src-tauri/src/connection/store.rs index 077deca9..00c9ceb4 100644 --- a/src-tauri/src/connection/store.rs +++ b/src-tauri/src/connection/store.rs @@ -88,6 +88,11 @@ impl Connections { } } + /// 这次启动是不是连远程([`Connections::startup_target`] 是一条远程连接) + pub fn starts_remote(&self) -> bool { + self.remote(&self.startup_target()).is_some() + } + /// 名字有没有被别的连接用掉(大小写不分)。「本机」也算一个名字 pub fn name_taken(&self, name: &str, except: Option<&str>) -> bool { let n = name.trim().to_lowercase(); diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs index 89be9193..d2bc7c91 100644 --- a/src-tauri/src/control.rs +++ b/src-tauri/src/control.rs @@ -75,15 +75,18 @@ fn local_key(config: &Path) -> Result { }) } -/// 连上本机的控制面:socket(Windows 上是回环端口),再握手。 -async fn local(at: &tw_api::control::Address, config: &Path) -> Result> { - let key = local_key(config)?; - match handshake(at, &key).await { +/// 连上本机的控制面:socket(Windows 上是回环端口),再拿 `key` 握手。 +async fn local( + at: &tw_api::control::Address, + config: &Path, + key: &tw_link::ControlKey, +) -> Result> { + match handshake(at, key).await { // **钥匙不对就再读一次。**读钥匙和握手之间 core 换了钥匙(`--rotate`), // 手上这一把就是旧的;再读到的还是同一把,才是真的不对 Err(tw_link::LinkError::WrongKey) => { let again = local_key(config)?; - if again == key { + if &again == key { return Err(link_failed(tw_link::LinkError::WrongKey)); } handshake(at, &again).await.map_err(link_failed) @@ -179,8 +182,9 @@ fn refused(status: hyper::StatusCode, body: &[u8]) -> anyhow::Error { /// 控制面在哪、凭什么进去。 #[derive(Clone)] pub enum Target { - /// 本机的 core:数据目录里的 socket(Windows 上是回环端口)。钥匙每次连接时从 - /// `config` 现读 —— 换过钥匙(`twcore control-key --rotate`),下一条连接就用新的 + /// 本机的 core:数据目录里的 socket(Windows 上是回环端口)。钥匙每一问都从 + /// `config` 现读(一个命令里接连几问的,读一次,见 [`Pinned`])—— 换过钥匙 + /// (`twcore control-key --rotate`),下一问就用新的 Local { at: tw_api::control::Address, /// 本机那份 config.yaml @@ -230,20 +234,20 @@ impl ControlClient { self.target.read().expect("锁未中毒").clone() } - /// 连上控制面:建连,再握手。 - /// - /// **连不上时只说 core 现在不在。**socket 的路径和「No such file or - /// directory (os error 2)」是给写代码的人看的,而几乎每个命令在 core 不在 - /// 的时候回给界面的都是这一句。原话进日志 + /// 取定此刻连哪、拿哪把钥匙(见 [`Pinned`])。本机那一档在这里读一次钥匙: + /// 文件不在、还没有钥匙都说 core 不在 + pub fn pin(&self) -> Result { + Ok(Pinned(match self.target() { + Target::Local { at, config } => { + let key = local_key(&config)?; + Resolved::Local { at, config, key } + } + Target::Remote(r) => Resolved::Remote(r), + })) + } + async fn connect(&self) -> Result> { - match self.target() { - Target::Local { at, config } => local(&at, &config).await, - // 远程那一档的建连和握手见 `connection::connector` - Target::Remote(r) => crate::connection::connector::open(&r) - .await - .map(|(stream, _)| stream) - .map_err(anyhow::Error::new), - } + self.pin()?.connect().await } /// 调一个端点。 @@ -255,9 +259,10 @@ impl ControlClient { /// 每次新建连接。控制面的调用频率是「用户点一下」的量级,连接复用 /// 带来的复杂度(连接死了怎么办、什么时候重建)换不来任何东西。 /// **事件流是例外**,它走 `subscribe_events`,本来就是长连接。 + /// + /// 一个命令里要接连问好几样的,先 [`ControlClient::pin`] 一次,再同时问 pub async fn call(&self, params: &[&str], req: &E::Req) -> Result { - let (path, body) = request::(params, req)?; - decode::(send(self.connect().await?, E::METHOD, &path, body).await?) + self.pin()?.call::(params, req).await } /// 带超时的心跳探测。 @@ -378,6 +383,51 @@ impl ControlClient { } } +/// 连哪、拿哪把钥匙,取定了的一份([`ControlClient::pin`])。 +/// +/// **一个命令里接连问好几样时用它**:本机那一档每一问都要现读一遍 config.yaml、 +/// 解析一遍 YAML 才拿得到钥匙,概览一次问九样就是九遍。取定一次,几样同时问。 +/// 中途换了钥匙(`--rotate`)照样接得上:握手说钥匙不对就再读一次(见 [`local`])。 +/// +/// **它不跟着切换连接走**:拿着它的那几问照旧问取定时的那个 core。一个命令的工夫里 +/// 切了连接,界面本来就要整个重新取一遍 +#[derive(Clone)] +pub struct Pinned(Resolved); + +#[derive(Clone)] +enum Resolved { + Local { + at: tw_api::control::Address, + config: std::path::PathBuf, + key: tw_link::ControlKey, + }, + Remote(crate::connection::connector::RemoteTarget), +} + +impl Pinned { + /// 连上控制面:建连,再握手。 + /// + /// **连不上时只说 core 现在不在。**socket 的路径和「No such file or + /// directory (os error 2)」是给写代码的人看的,而几乎每个命令在 core 不在 + /// 的时候回给界面的都是这一句。原话进日志 + async fn connect(&self) -> Result> { + match &self.0 { + Resolved::Local { at, config, key } => local(at, config, key).await, + // 远程那一档的建连和握手见 `connection::connector` + Resolved::Remote(r) => crate::connection::connector::open(r) + .await + .map(|(stream, _)| stream) + .map_err(anyhow::Error::new), + } + } + + /// 调一个端点,同 [`ControlClient::call`] + pub async fn call(&self, params: &[&str], req: &E::Req) -> Result { + let (path, body) = request::(params, req)?; + decode::(send(self.connect().await?, E::METHOD, &path, body).await?) + } +} + /// 在一条已经连上、握过手的流上问一次 `/status`。 /// /// 试连远程时用(`connection::connector::test`):**握手的那条连接接着问**,不再为这 diff --git a/src-tauri/src/dashboard.rs b/src-tauri/src/dashboard.rs index 95d55feb..dd1202e2 100644 --- a/src-tauri/src/dashboard.rs +++ b/src-tauri/src/dashboard.rs @@ -30,70 +30,68 @@ pub async fn dashboard( since_ms: i64, bucket_ms: i64, ) -> Out { - let c = &state.control; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0); let since = since_ms.min(now); let bucket = bucket_ms.max(60_000); + // **九样同时问,不排队。**每一问都是一条新连接、一次握手:排着队问,连着远程时 + // 九问的来回一个接一个叠起来,而这一页是默认打开的那一页、每个请求结束都要重取。 + // 钥匙只读一次(`pin`) + let c = state.control.pin().map_err(text)?; + // 延迟、生成速度和汇总**同一个时间窗**。不给窗口的话 core 按「今天零点至今」算, + // 而界面上它们和上面的数字摆在一起,读的人会当成同一段时间 + let win = window(Some(since), None); + let buckets = tw_api::BucketQuery { + from_ms: Some(since), + to_ms: None, + bucket_ms: Some(bucket), + }; + let by_model = tw_api::BucketGroupQuery { + from_ms: Some(since), + to_ms: None, + bucket_ms: Some(bucket), + dim: tw_api::CostDim::Model, + }; + // **上一个等长区间。**一个没有参照系的金额只能读,不能判断 + // ——「$4.05」是多还是少,只有和上一个七天比过才知道 + let before = window(Some(since - (now - since)), Some(since)); + let ( + summary, + latency, + latency_by_provider, + token_rate, + token_rate_by_provider, + storage, + buckets, + buckets_by_model, + prev, + ) = tokio::join!( + c.call::(&[], &win), + c.call::(&[], &win), + c.call::(&[], &win), + c.call::(&[], &win), + c.call::(&[], &win), + c.call::(&[], &()), + c.call::(&[], &buckets), + c.call::(&[], &by_model), + c.call::(&[], &before), + ); Ok(Dashboard { - summary: c - .call::(&[], &window(Some(since), None)) - .await - .map_err(text)?, - // 延迟和汇总**同一个时间窗**。不给窗口的话 core 按「今天零点至今」算,而 - // 界面上它和上面的数字摆在一起,读的人会当成同一段时间 - latency: c - .call::(&[], &window(Some(since), None)) - .await - .ok(), - latency_by_provider: c - .call::(&[], &window(Some(since), None)) - .await - .ok(), - token_rate: c - .call::(&[], &window(Some(since), None)) - .await - .ok(), - token_rate_by_provider: c - .call::(&[], &window(Some(since), None)) - .await - .ok(), - storage: c.call::(&[], &()).await.ok(), + summary: summary.map_err(text)?, + latency: latency.ok(), + latency_by_provider: latency_by_provider.ok(), + token_rate: token_rate.ok(), + token_rate_by_provider: token_rate_by_provider.ok(), + storage: storage.ok(), // 趋势和分组。**拿不到不该让整页失败** —— 这一页别的部分照样有用(同一条: // 观测层的缺失不该扩散)。但拿不到是 `None`,**不是空的**:空的在界面上就是 // 一张全零的图,读作「这段时间没有请求」,那是一个编出来的零 - buckets: c - .call::( - &[], - &tw_api::BucketQuery { - from_ms: Some(since), - to_ms: None, - bucket_ms: Some(bucket), - }, - ) - .await - .ok(), - buckets_by_model: c - .call::( - &[], - &tw_api::BucketGroupQuery { - from_ms: Some(since), - to_ms: None, - bucket_ms: Some(bucket), - dim: tw_api::CostDim::Model, - }, - ) - .await - .ok(), - // **上一个等长区间。**一个没有参照系的金额只能读,不能判断 - // ——「$4.05」是多还是少,只有和上一个七天比过才知道。 - // 拿不到就不显示那句对比,不影响这一页别的部分。 - prev: c - .call::(&[], &window(Some(since - (now - since)), Some(since))) - .await - .ok(), + buckets: buckets.ok(), + buckets_by_model: buckets_by_model.ok(), + // 拿不到就不显示那句对比,不影响这一页别的部分 + prev: prev.ok(), since_ms: since, }) } diff --git a/src-tauri/src/dialog.rs b/src-tauri/src/dialog.rs index 131773a6..f87c83e3 100644 --- a/src-tauri/src/dialog.rs +++ b/src-tauri/src/dialog.rs @@ -77,6 +77,7 @@ fn fallback_text(instruction: &str, content: &str, buttons: &[&str]) -> String { mod imp { use std::sync::atomic::{AtomicBool, Ordering}; + use crate::wide; use windows_sys::Win32::Foundation::{HWND, LPARAM, S_FALSE, S_OK, WPARAM}; use windows_sys::Win32::System::LibraryLoader::{ GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW, @@ -102,10 +103,6 @@ mod imp { verified: *mut BOOL, ) -> HRESULT; - fn wide(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() - } - /// 现取 `TaskDialogIndirect`,见模块说明。**只在系统目录里找**(和清单指定的并排 /// 程序集):绿色版的文件夹在哪都可能,不能让旁边一个同名的 dll 被加载进来 fn task_dialog() -> Option { @@ -136,7 +133,7 @@ mod imp { let title_w = wide(title); let instruction_w = wide(instruction); let content_w = wide(content); - let labels: Vec> = buttons.iter().map(|b| wide(b)).collect(); + let labels: Vec> = buttons.iter().map(wide).collect(); let specs: Vec = labels .iter() .zip(FIRST_BUTTON..) @@ -301,7 +298,7 @@ mod imp { content: &str, buttons: &[&str], ) -> Option { - let text = wide(&super::fallback_text(instruction, content, buttons)); + let text = wide(super::fallback_text(instruction, content, buttons)); let caption = wide(title); let kind = if buttons.len() >= 2 { MB_YESNO } else { MB_OK }; // SAFETY: 两个指针都指向以 0 结尾、在调用期间一直活着的 UTF-16 缓冲区 diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index 2a174b00..e0269a7e 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -305,8 +305,21 @@ pub(crate) async fn heartbeat_loop( use supervisor::{HealthTracker, Verdict, health}; let client = ControlClient::new(at, key_file); let mut tracker = HealthTracker::new(); + let mut state = sup.watch(); loop { + // **不在运行就不醒**:等守护报「运行中」再开始数。core 没起来、连着远程(本机的 + // core 停着)的时候,这里原来照样每五秒醒一次,醒了什么也不做 + if !matches!(*state.borrow_and_update(), CoreState::Running { .. }) { + tracker.reset(); + if state + .wait_for(|s| matches!(s, CoreState::Running { .. })) + .await + .is_err() + { + return; + } + } tokio::time::sleep(health::INTERVAL).await; // 只在 core 应该在跑的时候探。启动中、重启中、安全模式下探测 @@ -347,9 +360,10 @@ pub(crate) async fn heartbeat_loop( /// 把控制面的事件流搬给前端。 /// -/// 断线就重连,但**退避要克制**:core 重启期间 socket 必然连不上,这是 -/// 预期状态而不是故障。1 秒一次的重试既不会刷屏,也不会让用户在 core -/// 恢复后还盯着一个空列表等太久。 +/// 断线就重连。**core 不在的时候不去连**:重启期间 socket 必然连不上,这是预期状态 +/// 而不是故障 —— 等守护报它答应了控制面再订阅,答应的那一刻就接上,不用等下一次 +/// 重试。答应了却订阅不上的,隔 1 秒再试:既不会刷屏,也不会让用户在 core 恢复后还 +/// 盯着一个空列表等太久。 /// /// **重新连上时补报一条「丢过事件」**(`EventsDropped`,条数记 0:丢了多少不知道)。 /// 断开的那一段里发生的事,事件流不会再说一遍 —— 界面要对一次账,否则那段时间 @@ -365,17 +379,18 @@ pub(crate) async fn bridge_events(app: tauri::AppHandle) { let client = st.control.clone(); let mut moved = client.moved(); let mut link = st.link.watch(); + let mut core = st.supervisor.watch(); let mut connected_before = false; loop { moved.mark_unchanged(); - // **连着远程、还没连上时不去订阅**:连接那一层自己在按退避重连,这里再每秒 - // 连一次,就是往服务器上多打一份不带退避的连接。等它连上(或者换了连接) - while !matches!( - *link.borrow_and_update(), - crate::connection::LinkState::Local | crate::connection::LinkState::Connected { .. } - ) { - if link.changed().await.is_err() { - return; + // **订阅得上再去订阅。**连着远程、还没连上时,连接那一层自己在按退避重连,这里 + // 再每秒连一次,就是往服务器上多打一份不带退避的连接;本机的 core 没起来时,每秒 + // 去连一次 socket(每次还要读一遍配置里的钥匙)也只是在空转。等连接那一层连上、 + // 等守护报 core 答应了(或者换了连接) + while !can_subscribe(&link.borrow_and_update(), &core.borrow_and_update()) { + tokio::select! { + r = link.changed() => if r.is_err() { return }, + r = core.changed() => if r.is_err() { return }, } } let opened = Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -480,6 +495,21 @@ pub(crate) async fn bridge_events(app: tauri::AppHandle) { } } +/// 现在订阅不订阅得上:远程的,连接那一层连上了;本机的,守护报 core 答应了控制面 +/// (运行中,或者安全模式里起来了的那一个 —— 安全模式下也要接事件,见上面的 +/// `ConfigReloaded`) +fn can_subscribe(link: &crate::connection::LinkState, core: &CoreState) -> bool { + use crate::connection::LinkState; + match link { + LinkState::Connected { .. } => true, + LinkState::Local => matches!( + core, + CoreState::Running { .. } | CoreState::SafeMode { pid: Some(_) } + ), + LinkState::Waiting | LinkState::Connecting { .. } | LinkState::Down { .. } => false, + } +} + /// 跑着这条事件流,直到它自己结束(`Some`,带着它的结果),或者换了连接(`None`)。 /// /// **先看换没换连接**(`biased`,那一支排在前面):切换之后,旧的那条流可能还攒着 @@ -574,6 +604,50 @@ async fn supervise(sup: Arc, app: tauri::AppHandle) { mod tests { use super::*; + /// 本机的 core 没答应控制面时不订阅(原来每秒去连一次 socket);连着远程的看连接那一层 + #[test] + fn the_event_stream_is_only_tried_when_it_can_be_had() { + use crate::connection::LinkState; + let up = [ + CoreState::Running { pid: 1 }, + CoreState::SafeMode { pid: Some(1) }, + ]; + let down = [ + CoreState::Starting, + CoreState::Restarting { + attempt: 1, + in_ms: 500, + }, + CoreState::SafeMode { pid: None }, + CoreState::Stopped, + CoreState::Exited { reason: "x".into() }, + CoreState::Failed { reason: "x".into() }, + ]; + for c in &up { + assert!(can_subscribe(&LinkState::Local, c), "{c:?}"); + } + for c in &down { + assert!(!can_subscribe(&LinkState::Local, c), "{c:?}"); + } + // 连着远程时本机的 core 停着:看的是连接那一层 + let connected = LinkState::Connected { + info: crate::connection::connector::ServerInfo { + core_version: "0.0.0".into(), + gateway_addr: None, + }, + }; + assert!(can_subscribe(&connected, &CoreState::Stopped)); + for l in [ + LinkState::Waiting, + LinkState::Connecting { + attempt: 1, + ever: false, + }, + ] { + assert!(!can_subscribe(&l, &CoreState::Running { pid: 1 }), "{l:?}"); + } + } + /// **换了连接,先认换连接**:旧的那条流此刻也还有东西可交(两支同时就绪),也不再 /// 去读它。不分先后的话,这里每一次都有一半的机会先读旧的 #[tokio::test] diff --git a/src-tauri/src/keys.rs b/src-tauri/src/keys.rs index d88397da..296cb126 100644 --- a/src-tauri/src/keys.rs +++ b/src-tauri/src/keys.rs @@ -136,30 +136,24 @@ pub async fn key_usage( since_ms: i64, bucket_ms: i64, ) -> Out { - let c = &state.control; - let totals = c - .call::( - &[], - &tw_api::GroupQuery { - from_ms: Some(since_ms), - to_ms: None, - dim: tw_api::CostDim::Client, - }, - ) - .await - .map_err(text)?; - let buckets = c - .call::( - &[], - &tw_api::BucketGroupQuery { - from_ms: Some(since_ms), - to_ms: None, - bucket_ms: Some(bucket_ms), - dim: tw_api::CostDim::Client, - }, - ) - .await - .map_err(text)?; + // 两样同时问,钥匙读一次(同 `dashboard`) + let c = state.control.pin().map_err(text)?; + let totals = tw_api::GroupQuery { + from_ms: Some(since_ms), + to_ms: None, + dim: tw_api::CostDim::Client, + }; + let buckets = tw_api::BucketGroupQuery { + from_ms: Some(since_ms), + to_ms: None, + bucket_ms: Some(bucket_ms), + dim: tw_api::CostDim::Client, + }; + let (totals, buckets) = tokio::join!( + c.call::(&[], &totals), + c.call::(&[], &buckets), + ); + let (totals, buckets) = (totals.map_err(text)?, buckets.map_err(text)?); Ok(wire::KeyUsage { since_ms, bucket_ms, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2191db09..0286be25 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -137,6 +137,12 @@ pub fn run() { single::precheck(); // 缺 WebView2 就开不了窗口(见 `webview2`) webview2::ensure(); + // 本机 core 起之前要读的用户环境,现在就开始读(见 `user_env::read_ahead`):要跑一次 + // 登录 shell,和下面 Tauri 起来的那一段叠在一起。**放在上面几步之后**:改环境变量的 + // 那几处都在前面,转交给另一个实例、做卸载清理的也用不着它。这次要连远程的不读 + if !connection::store::load(&data_dir()).starts_remote() { + tauri::async_runtime::spawn(supervisor::user_env::read_ahead()); + } let builder = tauri::Builder::default(); // **单实例要第一个注册**,插件自己的文档如此要求:它得在别的插件把端口、 // socket、注册表项占上之前就判断出「已经有一个在跑」。 @@ -426,7 +432,7 @@ pub fn run() { // 不来的时候,界面必须还能打开,否则用户连错误都看不到。 // // **只在连本机时起。**连远程时本机的 core 不跑;要先让人选的话,选好了再起 - let remote_start = pick.is_some() || conns.remote(&start_id).is_some(); + let remote_start = pick.is_some() || conns.starts_remote(); if remote_start { if pick.is_some() { link.wait_for_pick(); @@ -447,7 +453,14 @@ pub fn run() { // 菜单栏。**在守护之前建**,这样 core 还没起来的那几秒里 // 用户就已经看到它了 —— 开机自启时尤其重要。 - menubar::install(&handle)?; + // + // **建不起来不让整个应用退出**(Windows、Linux 上托盘是会失败的),下面开窗口 + // 也一样:原来这里是 `?`,一路冒到 `.expect("Tauri 起不来")`,守护和网关跟着 + // 一起没了 —— 用户的客户端全断,只因为少了一个图标。和找不到 core 一样:记下来、 + // 说一声,别的照常 + if let Err(e) = menubar::install(&handle) { + startup_failed(¬ices, notices::rules::tray_failed(), &e); + } // 睡醒、改时钟、换时区时菜单栏的「今日」当场对上,不等睡前排好的那个定时器 clock::watch(&handle); @@ -467,14 +480,18 @@ pub fn run() { // 连接选择先出来,主窗口等选好了再开。**开机自启时也出来**:只有 // 连着两次没走到就绪才会走到这里,那时再悄悄撞一次不如问一句 tracing::info!(?why, "启动时先显示连接选择"); - connection::show_picker(&handle, why)?; + if let Err(e) = connection::show_picker(&handle, why) { + startup_failed(¬ices, notices::rules::picker_failed(), &e); + } } else if let Some(r) = &relaunch { // **更新之后被重新打开的,照更新之前的样子**:之前只在菜单栏,现在也只在 // 菜单栏;之前窗口开着,现在也开着。Homebrew 升级完用 `open -b` 打开、应用 // 自己装完重启,都不带说得出这一点的参数,靠的是退出那一刻记下的(见 // `updater::record_exit`) if r.window { - show_main_window(&handle)?; + if let Err(e) = show_main_window(&handle) { + startup_failed(¬ices, notices::rules::window_failed(), &e); + } } else { tracing::info!("更新之前只在菜单栏,更新之后也不开窗口"); #[cfg(target_os = "macos")] @@ -485,8 +502,8 @@ pub fn run() { #[cfg(target_os = "macos")] become_accessory(&handle); maybe_notify_first_autostart(&handle); - } else { - show_main_window(&handle)?; + } else if let Err(e) = show_main_window(&handle) { + startup_failed(¬ices, notices::rules::window_failed(), &e); } // 量 webview 占多少。**它不是一个功能,是一个回答 @@ -607,6 +624,19 @@ pub fn run() { }); } +/// 启动时菜单栏图标、窗口没建起来:原话进日志,提醒里说一声(系统通知还在的话,窗口 +/// 没开起来也看得见)。应用照常跑 +fn startup_failed( + notices: &Arc, + signal: notices::Signal, + e: &dyn std::fmt::Display, +) { + tracing::error!(key = %signal.key, "启动时没建起来:{e:#}"); + // 交给运行时:总线要排定时器(`tokio::spawn`),而 setup 跑在主线程上、不在运行时里 + let n = notices.clone(); + tauri::async_runtime::spawn(async move { n.ingest(signal, notices::now_ms()) }); +} + /// 退出前先请本机的 core 退出、等它走,**至多等 [`EXIT_WAIT`]**。 /// /// 靠别的都带不走它:进程是 `exit` 退的,运行时不会被析构,守护手上那个子进程的 @@ -651,6 +681,14 @@ fn start_client_watch(handle: &tauri::AppHandle, notices: Arc) scan::restart_client_watch(handle); } +/// 以 0 结尾的 UTF-16,交给 Windows 的 `…W` 函数(路径、注册表的键和值名、对象名、对话框 +/// 上的字)。`supervisor::user_env` 自己另有一份:它要能单独摘出去交叉编译 +#[cfg(windows)] +pub(crate) fn wide(s: impl AsRef) -> Vec { + use std::os::windows::ffi::OsStrExt; + s.as_ref().encode_wide().chain(std::iter::once(0)).collect() +} + /// 数据目录。 /// /// **问契约层要,不自己算。**core 和这里必须落到同一个目录 —— 端口文件、凭据、 @@ -658,5 +696,12 @@ fn start_client_watch(handle: &tauri::AppHandle, notices: Arc) /// 落到当前目录下的 `.thinkwatch`,而 core 在 `%APPDATA%\ThinkWatch`:界面找不到 /// 一个正在跑的网关。`THINKWATCH_HOME` 照旧最优先(测试靠它隔离)。 pub(crate) fn data_dir() -> PathBuf { + // **测试里不许落到真的数据目录。**测试要的目录各自建在临时目录里、显式传进去;走到 + // 这里就是哪条测试漏了 —— 以前就有,在 `~/.thinkwatch/backups` 里留下了几千个目录 + #[cfg(test)] + assert!( + std::env::var_os("THINKWATCH_HOME").is_some(), + "a test reached the real data directory; give it a temporary one" + ); tw_api::data::dir() } diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 0a47f3ac..2f87e7b7 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -8,7 +8,7 @@ use std::path::Path; use tw_adopt::mcp; use tw_types::Msg; -use crate::clients::home_dir; +use crate::clients::{blocking, home_dir}; use crate::error::Out; use crate::wire; @@ -127,14 +127,17 @@ pub fn apply( }) } +// 下面三个都要读(落盘时还要写、要备份)客户端的配置文件:**放在阻塞线程上**,同 +// `scan_clients`。配置位置可能换到了一个离线的网络位置上 + #[tauri::command] pub async fn mcp_targets() -> Out> { - Ok(targets(&home_dir())) + blocking(|| targets(&home_dir())).await } #[tauri::command] pub async fn plan_mcp(req: wire::McpOpRequest) -> Out { - Ok(plan_op(&home_dir(), &req)?) + Ok(blocking(move || plan_op(&home_dir(), &req)).await??) } #[tauri::command] @@ -142,12 +145,15 @@ pub async fn apply_mcp( req: wire::McpOpRequest, expect: Option, ) -> Out { - Ok(apply( - &home_dir(), - &tw_adopt::foreign::backup_root(), - &req, - expect.as_deref(), - )?) + Ok(blocking(move || { + apply( + &home_dir(), + &tw_adopt::foreign::backup_root(), + &req, + expect.as_deref(), + ) + }) + .await??) } #[cfg(test)] diff --git a/src-tauri/src/menubar/macos.rs b/src-tauri/src/menubar/macos.rs index 64bf8676..8e847c3c 100644 --- a/src-tauri/src/menubar/macos.rs +++ b/src-tauri/src/menubar/macos.rs @@ -10,6 +10,9 @@ //! //! 所有 AppKit 调用都在主线程上。别处要改它,用 [`on_main`] 投递 —— 那走的是 GCD //! 的主队列:菜单展开时主线程处在事件跟踪模式,别的投递方式要等菜单关了才执行。 +//! +//! 菜单里自己画的那几行(「今日」那一块、额度条……)在 `info`;只给预览程序用的几样 +//! (离屏画图、点开收起菜单)在 `preview`,只在开发构建里。 use std::cell::{Cell, RefCell}; use std::ptr::NonNull; @@ -18,25 +21,30 @@ use std::rc::Rc; use block2::RcBlock; use objc2::rc::Retained; use objc2::runtime::{AnyObject, Bool, ProtocolObject}; -use objc2::{ - AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, Message, define_class, msg_send, sel, -}; +use objc2::{AnyThread, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; use objc2_app_kit::{ NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSAlertStyle, - NSAppearanceCustomization, NSApplication, NSAttributedStringNSStringDrawing, - NSAutoresizingMaskOptions, NSBezierPath, NSColor, NSControlStateValueOff, - NSControlStateValueOn, NSEvent, NSEventMask, NSEventModifierFlags, NSFont, NSFontAttributeName, - NSFontWeightMedium, NSFontWeightRegular, NSFontWeightSemibold, NSForegroundColorAttributeName, - NSImage, NSImageSymbolConfiguration, NSLineCapStyle, NSLineJoinStyle, NSMenu, NSMenuDelegate, - NSMenuItem, NSStatusBar, NSStatusItem, NSVariableStatusItemLength, NSView, + NSAppearanceCustomization, NSApplication, NSAttributedStringNSStringDrawing, NSBezierPath, + NSColor, NSControlStateValueOff, NSControlStateValueOn, NSEvent, NSEventMask, + NSEventModifierFlags, NSFont, NSFontAttributeName, NSFontWeightMedium, NSFontWeightRegular, + NSFontWeightSemibold, NSForegroundColorAttributeName, NSImage, NSImageSymbolConfiguration, + NSLineCapStyle, NSLineJoinStyle, NSMenu, NSMenuDelegate, NSMenuItem, NSStatusBar, NSStatusItem, + NSVariableStatusItemLength, }; use objc2_foundation::{ NSAttributedString, NSDictionary, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, }; -use super::model::{Action, Bar, Level, Row, StateTone, Style, SubItem, Tone, WindowRow}; +use super::model::{Action, Bar, Row, Style, SubItem, Tone}; + +mod info; +#[cfg(debug_assertions)] +pub mod preview; + +use info::InfoView; -/// 菜单栏往下这么宽。自定义的几行按它画,标准的菜单项跟着撑满 +/// 菜单至少这么宽。自定义的几行按它画,标准的菜单项跟着撑满;「今日」那一块放不下时 +/// 把菜单撑宽一点(见 `info::row_width`) const MENU_WIDTH: f64 = 308.0; /// 自定义行的左右留白:和标准菜单项的图标列对齐 const PAD: f64 = 14.0; @@ -110,35 +118,6 @@ pub fn install( }); } -/// 菜单开着吗 -pub fn is_open() -> bool { - OPEN.with(|o| o.get()) -} - -/// 开发用:过 `delay` 秒像用户点了一下那样把菜单打开(预览程序用)。 -/// -/// **走 run loop 的延时调用,不走 GCD。**菜单的跟踪循环要是嵌在一个主队列任务里, -/// CFRunLoop 为了不让主队列重入,那期间不再处理主队列 —— 投递过来的更新全都要等 -/// 菜单关上。用户真点菜单栏时跟踪循环是从事件分发开始的,没有这个问题 -pub fn open_menu_later(mtm: MainThreadMarker, delay: f64) { - let button = UI.with(|ui| ui.borrow().as_ref().and_then(|ui| ui.item.button(mtm))); - if let Some(button) = button { - let none: Option<&AnyObject> = None; - let _: () = unsafe { - msg_send![&*button, performSelector: sel!(performClick:), withObject: none, afterDelay: delay] - }; - } -} - -/// 开发用:把开着的菜单收起来 -pub fn close_menu() { - UI.with(|ui| { - if let Some(ui) = ui.borrow().as_ref() { - ui.menu.cancelTracking(); - } - }); -} - /// 照着模型画一遍。**没变的不动**:菜单栏那一块文字和颜色都没变就不重画,菜单行的 /// 样子没变就只改文字 pub fn apply(mtm: MainThreadMarker, bar: &Bar, rows: &[Row]) { @@ -312,9 +291,8 @@ fn rebuild(mtm: MainThreadMarker, ui: &mut Ui, rows: &[Row]) { ui.menu.removeAllItems(); ui.items.clear(); ui.actions.clear(); - let quota = quota_text(rows); for row in rows { - let item = make_item(mtm, ui, row, quota); + let item = make_item(mtm, ui, row); ui.menu.addItem(&item); ui.items.push(item); } @@ -324,7 +302,6 @@ fn rebuild(mtm: MainThreadMarker, ui: &mut Ui, rows: &[Row]) { /// 样子没变:只换文字和数据。**不删不加**,开着的菜单不跳 —— 子菜单里的项也一样 fn refresh(mtm: MainThreadMarker, ui: &mut Ui, rows: &[Row]) { ui.actions.clear(); - let quota = quota_text(rows); let drawn = std::mem::take(&mut ui.rows); for (n, (row, item)) in rows.iter().zip(ui.items.clone()).enumerate() { match row { @@ -338,8 +315,9 @@ fn refresh(mtm: MainThreadMarker, ui: &mut Ui, rows: &[Row]) { } _ => { let tag = row_action(row).map(|a| push_action(ui, a.clone())); + set_tooltip(&item, row); if let Some(view) = item.view().and_then(|v| v.downcast::().ok()) { - view.set_info(info_of(row, quota), tag); + view.set_row(row.clone(), tag); } } } @@ -349,25 +327,33 @@ fn refresh(mtm: MainThreadMarker, ui: &mut Ui, rows: &[Row]) { fn row_action(row: &Row) -> Option<&Action> { match row { - Row::Notice { action, .. } - | Row::Quota { action, .. } - | Row::Stats { action, .. } - | Row::Live { action, .. } => Some(action), + Row::Today(d) => Some(&d.action), + Row::Notice { action, .. } | Row::Quota { action, .. } | Row::Live { action, .. } => { + Some(action) + } _ => None, } } +/// 自画的那几行的悬停提示:「今日」那一块的大字是取整过的,准数和那一行放不下的小字在这里 +fn set_tooltip(item: &NSMenuItem, row: &Row) { + let tip = match row { + Row::Today(d) => d.tooltip.as_deref().map(NSString::from_str), + _ => None, + }; + item.setToolTip(tip.as_deref()); + // 自画的那一行盖在菜单项上面,鼠标停在它身上:它自己也带一份 + if let Some(view) = item.view() { + view.setToolTip(tip.as_deref()); + } +} + fn push_action(ui: &mut Ui, action: Action) -> isize { ui.actions.push(action); (ui.actions.len() - 1) as isize } -fn make_item( - mtm: MainThreadMarker, - ui: &mut Ui, - row: &Row, - quota: QuotaText, -) -> Retained { +fn make_item(mtm: MainThreadMarker, ui: &mut Ui, row: &Row) -> Retained { match row { Row::Separator => NSMenuItem::separatorItem(mtm), Row::Item(i) => { @@ -378,9 +364,9 @@ fn make_item( _ => { let item = NSMenuItem::new(mtm); let tag = row_action(row).map(|a| push_action(ui, a.clone())); - let info = info_of(row, quota); - let view = InfoView::new(mtm, info, tag); + let view = InfoView::new(mtm, row.clone(), tag); item.setView(Some(&view)); + set_tooltip(&item, row); item } } @@ -532,572 +518,6 @@ fn symbol(name: &str, color: Option<&NSColor>) -> Option> { } } -// ------------------------------------------------------------------ 自定义的几行 - -/// 自定义行要画的东西 -#[derive(Debug, Clone)] -enum Info { - Header { - title: String, - state: String, - tone: StateTone, - line2: String, - line2_warn: bool, - }, - Section { - title: String, - right: Option, - }, - Notice { - level: Level, - title: String, - body: String, - }, - Quota { - provider: String, - windows: Vec, - /// 菜单里所有额度行共用的字宽,见 [`quota_text`] - text: QuotaText, - }, - Stats { - cells: Vec, - }, - Live { - app: String, - model: String, - elapsed: String, - }, -} - -/// `quota`:整份菜单的额度行一起量出来的字宽([`quota_text`]),只有额度行用 -fn info_of(row: &Row, quota: QuotaText) -> Info { - match row.clone() { - Row::Header { - title, - state, - tone, - line2, - line2_warn, - } => Info::Header { - title, - state, - tone, - line2, - line2_warn, - }, - Row::Section { title, right } => Info::Section { title, right }, - Row::Notice { - level, title, body, .. - } => Info::Notice { level, title, body }, - Row::Quota { - provider, windows, .. - } => Info::Quota { - provider, - windows, - text: quota, - }, - Row::Stats { cells, .. } => Info::Stats { cells }, - Row::Live { - app, - model, - elapsed, - .. - } => Info::Live { - app, - model, - elapsed, - }, - Row::Separator | Row::Item(_) => unreachable!("标准菜单项不走自定义视图"), - } -} - -fn info_height(info: &Info) -> f64 { - match info { - Info::Header { .. } => 46.0, - Info::Section { .. } => 21.0, - Info::Notice { .. } => 40.0, - Info::Quota { windows, .. } => { - 6.0 + 20.0 + windows.iter().map(quota_row_height).sum::() + 3.0 - } - Info::Stats { .. } => 48.0, - Info::Live { .. } => 24.0, - } -} - -pub struct ViewIvars { - info: RefCell, - /// 点了做什么(`Ui::actions` 里的下标)。没有就是一行只读的 - tag: Cell>, -} - -define_class!( - /// 菜单里自己画的一行 - #[unsafe(super(NSView))] - #[thread_kind = MainThreadOnly] - #[name = "ThinkWatchMenuRow"] - #[ivars = ViewIvars] - struct InfoView; - - impl InfoView { - #[unsafe(method(isFlipped))] - fn is_flipped(&self) -> bool { - true - } - - #[unsafe(method(drawRect:))] - fn draw_rect(&self, _dirty: NSRect) { - let highlighted = self.ivars().tag.get().is_some() - && self.enclosingMenuItem().is_some_and(|i| i.isHighlighted()); - draw_info(&self.ivars().info.borrow(), self.bounds(), highlighted); - } - - #[unsafe(method(mouseUp:))] - fn mouse_up(&self, _event: &NSEvent) { - let Some(tag) = self.ivars().tag.get() else { return }; - if let Some(menu) = self.enclosingMenuItem().and_then(|i| unsafe { i.menu() }) { - menu.cancelTracking(); - } - dispatch(tag); - } - } -); - -impl InfoView { - fn new(mtm: MainThreadMarker, info: Info, tag: Option) -> Retained { - let frame = rect(0.0, 0.0, MENU_WIDTH, info_height(&info)); - let this = Self::alloc(mtm).set_ivars(ViewIvars { - info: RefCell::new(info), - tag: Cell::new(tag), - }); - let view: Retained = unsafe { msg_send![super(this), initWithFrame: frame] }; - view.setAutoresizingMask(NSAutoresizingMaskOptions::ViewWidthSizable); - view - } - - fn set_info(&self, info: Info, tag: Option) { - *self.ivars().info.borrow_mut() = info; - self.ivars().tag.set(tag); - self.setNeedsDisplay(true); - } -} - -// ------------------------------------------------------------------ 自定义行的列宽 -// -// **列宽按字量,不按一种语言写死。**中文的字都落在下面的最小列宽里,排出来和原来 -// 写死的一样;英文的「Resets in 3 days」「Requests 4 failed」比那宽,写死的时候 -// 就压到了旁边那一列上。 - -/// 同一行里两段字之间至少空这么多 -const TEXT_GAP: f64 = 10.0; -/// 额度行:窗口名和条之间 -const LABEL_GAP: f64 = 8.0; -/// 额度行:窗口名一列至少这么宽(含它和条之间的空) -const QUOTA_LABEL: f64 = 50.0; -/// 额度行:窗口名一列最多这么宽。上游给了很长的窗口名就截断,不把条挤没 -const QUOTA_LABEL_MAX: f64 = 96.0; -/// 额度行:条和百分比之间 -const QUOTA_BAR_GAP: f64 = 8.0; -/// 额度行:百分比一列,「100%」放得下 -const QUOTA_PCT: f64 = 40.0; -/// 额度行:重置一列至少这么宽(含它和百分比之间的空) -const QUOTA_RESET: f64 = 92.0; -/// 额度行:条最短这么长 -const QUOTA_MIN_BAR: f64 = 40.0; -/// 额度行:一个窗口占多高 -const QUOTA_ROW: f64 = 21.0; -/// 额度行:条下面那一行小字(还剩多少积分)再占多高 -const QUOTA_DETAIL: f64 = 15.0; -/// 「今日」:标签和旁边的橙色小字(失败数)之间 -const NOTE_GAP: f64 = 6.0; - -fn quota_label_font() -> Retained { - sys(12.0, weight(Weight::Regular)) -} - -/// 等宽数字:倒计时在走,同位数时宽度不变 -fn quota_reset_font() -> Retained { - mono(12.0, weight(Weight::Regular)) -} - -/// 条下面那一行小字:比窗口名小一号,和「今日」的标签同一个字号 -fn quota_detail_font() -> Retained { - sys(11.0, weight(Weight::Regular)) -} - -/// 一个窗口占多高:带小字的高一截 -fn quota_row_height(w: &WindowRow) -> f64 { - match w.detail { - Some(_) => QUOTA_ROW + QUOTA_DETAIL, - None => QUOTA_ROW, - } -} - -fn stat_value_font() -> Retained { - mono(17.0, weight(Weight::Semibold)) -} - -fn stat_label_font() -> Retained { - sys(11.0, weight(Weight::Regular)) -} - -fn text_width(text: &str, font: &NSFont) -> f64 { - attributed(text, font, &NSColor::labelColor()).size().width -} - -/// 额度行里最宽的窗口名和最宽的重置时刻。**整份菜单的额度行一起量**:几家的额度 -/// 共用一套列,条的起止上下对齐 -#[derive(Debug, Clone, Copy, Default, PartialEq)] -struct QuotaText { - label: f64, - reset: f64, -} - -fn quota_text(rows: &[Row]) -> QuotaText { - let (label_font, reset_font) = (quota_label_font(), quota_reset_font()); - rows.iter() - .flat_map(|r| match r { - Row::Quota { windows, .. } => windows.as_slice(), - _ => &[], - }) - .fold(QuotaText::default(), |t, wr| QuotaText { - label: t.label.max(text_width(&wr.label, &label_font)), - reset: t.reset.max(text_width(&wr.reset, &reset_font)), - }) -} - -/// 额度行横着怎么排:窗口名 · 条 · 百分比 · 重置 -#[derive(Debug, Clone, Copy, PartialEq)] -struct QuotaCols { - /// 窗口名一列,含它和条之间的空 - label: f64, - bar_x: f64, - bar_w: f64, - /// 百分比右对齐到这里 - pct_right: f64, - /// 重置一列,含它和百分比之间的空。右对齐到 `w - PAD` - reset: f64, -} - -/// 两头的字要多宽给多宽,条拿剩下的。条短到 [`QUOTA_MIN_BAR`] 还放不下时,重置一列 -/// 不再放宽,字截断 -fn quota_cols(w: f64, text: QuotaText) -> QuotaCols { - let label = (text.label + LABEL_GAP).clamp(QUOTA_LABEL, QUOTA_LABEL_MAX); - let bar_x = PAD + label; - let room = w - PAD - bar_x - QUOTA_MIN_BAR - QUOTA_BAR_GAP - QUOTA_PCT; - let reset = (text.reset + TEXT_GAP).max(QUOTA_RESET).min(room); - let bar_w = w - PAD - reset - QUOTA_PCT - QUOTA_BAR_GAP - bar_x; - QuotaCols { - label, - bar_x, - bar_w, - pct_right: bar_x + bar_w + QUOTA_BAR_GAP + QUOTA_PCT, - reset, - } -} - -/// 「今日」几格各多宽:量出每一格的字,交给 [`share_columns`] -fn stat_widths(w: f64, cells: &[super::model::StatCell]) -> Vec { - let (value_font, label_font) = (stat_value_font(), stat_label_font()); - let needs: Vec = cells - .iter() - .map(|c| { - let line2 = text_width(&c.label, &label_font) - + c.note - .as_deref() - .map_or(0.0, |n| NOTE_GAP + text_width(n, &label_font)); - text_width(&c.value, &value_font).max(line2) + TEXT_GAP - }) - .collect(); - share_columns(w - PAD * 2.0, &needs) -} - -/// 把 `avail` 分给几格。**放得下时等分**;有一格要的比等分多(英文的「Requests -/// 4 failed」),就给它要的那么多,其余几格平分剩下的 —— 那几格也不小于自己要的。 -/// 怎么分都放不下时按各自要的比例分,画的时候截断 -fn share_columns(avail: f64, needs: &[f64]) -> Vec { - let total: f64 = needs.iter().sum(); - if total > avail && total > 0.0 { - return needs.iter().map(|n| avail * n / total).collect(); - } - // 从要得最多的一格起:比「剩下的地方平分」还多,就照它要的给,剩下的接着平分 - let mut order: Vec = (0..needs.len()).collect(); - order.sort_by(|&a, &b| needs[b].total_cmp(&needs[a])); - let (mut left, mut rest) = (avail, needs.len()); - let mut fixed = vec![false; needs.len()]; - for i in order { - if needs[i] <= left / rest as f64 { - break; - } - fixed[i] = true; - left -= needs[i]; - rest -= 1; - } - let share = left / rest.max(1) as f64; - needs - .iter() - .zip(fixed) - .map(|(&n, fixed)| if fixed { n } else { share }) - .collect() -} - -fn draw_info(info: &Info, b: NSRect, hl: bool) { - let w = b.size.width; - if hl { - NSColor::selectedContentBackgroundColor().setFill(); - NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( - rect(5.0, 0.0, w - 10.0, b.size.height), - 5.0, - 5.0, - ) - .fill(); - } - // 高亮时一律白字:底色是强调色 - let label = if hl { - NSColor::whiteColor() - } else { - NSColor::labelColor() - }; - let secondary = if hl { - NSColor::whiteColor().colorWithAlphaComponent(0.8) - } else { - NSColor::secondaryLabelColor() - }; - match info { - Info::Header { - title, - state, - tone, - line2, - line2_warn, - } => { - let st = attributed(state, &sys(12.0, weight(Weight::Regular)), &secondary); - let sw = st.size().width; - st.drawAtPoint(NSPoint::new(w - PAD - sw, 7.0)); - let dot = match tone { - StateTone::Ok => NSColor::systemGreenColor(), - StateTone::Busy => NSColor::systemYellowColor(), - StateTone::Warn => NSColor::systemOrangeColor(), - StateTone::Bad => NSColor::systemRedColor(), - }; - let dot_x = w - PAD - sw - 12.0; - fill_oval(dot_x, 11.0, 7.0, &dot); - // 连着远程时标题是服务器的名字,多长都有:截断在状态前面,不压上去 - let t = attributed(title, &sys(13.0, weight(Weight::Semibold)), &label); - draw_clipped(&t, PAD, 6.0, dot_x - TEXT_GAP - PAD); - let color = if *line2_warn { - NSColor::systemOrangeColor() - } else { - secondary.clone() - }; - let l2 = attributed(line2, &mono(12.0, weight(Weight::Regular)), &color); - draw_clipped(&l2, PAD, 25.0, w - PAD * 2.0); - } - Info::Section { title, right } => { - let font = sys(11.0, weight(Weight::Semibold)); - attributed(title, &font, &secondary).drawAtPoint(NSPoint::new(PAD, 4.0)); - if let Some(r) = right { - let a = attributed(r, &font, &secondary); - a.drawAtPoint(NSPoint::new(w - PAD - a.size().width, 4.0)); - } - } - Info::Notice { level, title, body } => { - let (name, color) = match level { - Level::Critical => ("xmark.octagon.fill", NSColor::systemRedColor()), - Level::Warning => ( - "exclamationmark.triangle.fill", - NSColor::systemOrangeColor(), - ), - Level::Info => ("info.circle.fill", NSColor::systemBlueColor()), - }; - let tint = if hl { NSColor::whiteColor() } else { color }; - if let Some(img) = symbol(name, Some(&tint)) { - img.drawInRect(rect(PAD, 5.0, 16.0, 16.0)); - } - let t = attributed(title, &NSFont::menuFontOfSize(0.0), &label); - draw_clipped(&t, TEXT_X, 3.0, w - TEXT_X - PAD); - let bd = attributed(body, &sys(11.5, weight(Weight::Regular)), &secondary); - draw_clipped(&bd, TEXT_X, 21.0, w - TEXT_X - PAD); - } - Info::Quota { - provider, - windows, - text, - } => { - let name = attributed(provider, &sys(13.0, weight(Weight::Medium)), &label); - draw_clipped(&name, PAD, 4.0, w - PAD * 2.0); - let pct_font = mono(12.0, weight(Weight::Medium)); - let QuotaCols { - label: label_col, - bar_x, - bar_w, - pct_right, - reset: reset_col, - } = quota_cols(w, *text); - let mut y = 26.0; - for wr in windows { - let lab = attributed(&wr.label, "a_label_font(), &secondary); - draw_clipped(&lab, PAD, y, label_col - LABEL_GAP); - let track = if hl { - NSColor::whiteColor().colorWithAlphaComponent(0.3) - } else { - NSColor::quaternaryLabelColor() - }; - track.setFill(); - NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( - rect(bar_x, y + 6.0, bar_w, 5.0), - 2.5, - 2.5, - ) - .fill(); - let tone_color = match wr.tone { - Tone::Normal => label.clone(), - Tone::Warn => NSColor::systemOrangeColor(), - Tone::Full => NSColor::systemRedColor(), - }; - let tone_color = if hl { - NSColor::whiteColor() - } else { - tone_color - }; - if let Some(p) = wr.percent { - let fill = if matches!(wr.tone, Tone::Normal) && !hl { - label.colorWithAlphaComponent(0.8) - } else { - tone_color.clone() - }; - fill.setFill(); - let fw = (bar_w * p / 100.0).max(if p > 0.0 { 5.0 } else { 0.0 }); - if fw > 0.0 { - NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( - rect(bar_x, y + 6.0, fw, 5.0), - 2.5, - 2.5, - ) - .fill(); - } - let pct = attributed(&format!("{}%", p.round() as i64), &pct_font, &tone_color); - pct.drawAtPoint(NSPoint::new(pct_right - pct.size().width, y)); - } - // 右对齐。列宽是按字量出来的,截断只在菜单窄得放不下条的时候才会发生 - let rs = attributed(&wr.reset, "a_reset_font(), &secondary); - if let Some(rs) = fit(&rs, reset_col - TEXT_GAP) { - rs.drawAtPoint(NSPoint::new(w - PAD - rs.size().width, y)); - } - // 还剩多少:贴在条的正下方、和条左对齐,读得出是这一个窗口的 - if let Some(detail) = &wr.detail { - let d = attributed(detail, "a_detail_font(), &secondary); - draw_clipped(&d, bar_x, y + 17.0, w - PAD - bar_x); - } - y += quota_row_height(wr); - } - } - Info::Stats { cells } => { - let note_color = if hl { - NSColor::whiteColor() - } else { - NSColor::systemOrangeColor() - }; - let mut x = PAD; - for (c, col) in cells.iter().zip(stat_widths(w, cells)) { - // 格子右边留出和下一格之间的空;字只在这一格里画,放不下的截断 - let right = x + col - TEXT_GAP; - let value = attributed(&c.value, &stat_value_font(), &label); - draw_clipped(&value, x, 3.0, right - x); - let lab = attributed(&c.label, &stat_label_font(), &secondary); - draw_clipped(&lab, x, 27.0, right - x); - if let Some(note) = &c.note { - let nx = x + lab.size().width + NOTE_GAP; - let note = attributed(note, &stat_label_font(), ¬e_color); - draw_clipped(¬e, nx, 27.0, right - nx); - } - x += col; - } - } - Info::Live { - app, - model, - elapsed, - } => { - if let Some(img) = symbol("circle.dashed", Some(&*secondary)) { - img.drawInRect(rect(PAD, 4.0, 16.0, 16.0)); - } - let el = attributed(elapsed, &mono(12.0, weight(Weight::Regular)), &secondary); - let ew = el.size().width; - el.drawAtPoint(NSPoint::new(w - PAD - ew, 4.5)); - // 应用名和模型都在时长左边。放不下先截模型;应用名(认不出应用时是密钥名, - // 多长都有)再长也截在时长前面 - let right = w - PAD - ew - 10.0; - let a = attributed(app, &NSFont::menuFontOfSize(0.0), &label); - let aw = match fit(&a, right - TEXT_X) { - Some(a) => { - a.drawAtPoint(NSPoint::new(TEXT_X, 3.5)); - a.size().width - } - None => 0.0, - }; - let mx = TEXT_X + aw + 8.0; - let m = attributed(model, &sys(12.0, weight(Weight::Regular)), &secondary); - draw_clipped(&m, mx, 4.5, right - mx); - } - } -} - -/// 画一段字,**放不下就截断**:自定义行不会自己换行,超出的部分会画到别的格子上 -fn draw_clipped(s: &NSAttributedString, x: f64, y: f64, max_w: f64) { - if let Some(s) = fit(s, max_w) { - s.drawAtPoint(NSPoint::new(x, y)); - } -} - -/// 列宽是「字宽 + 空」再减去空算回来的,和量出来的字宽之间差一点浮点误差。这点差不算 -/// 放不下 —— 否则正好放得下的字会被截成「4 fai…」 -const FIT_SLACK: f64 = 0.001; - -/// 放得下就是它本身;放不下就从尾巴上去字、末尾加「…」,留下放得下的最多的字。连一个 -/// 「…」都放不下是 None。**要右对齐的先拿它,再按它的宽定位置** -fn fit(s: &NSAttributedString, max_w: f64) -> Option> { - if max_w <= 0.0 { - return None; - } - if s.size().width <= max_w + FIT_SLACK { - return Some(s.retain()); - } - let text = s.string().to_string(); - // SAFETY: 第 0 个字符一定在(上面已经量过它比 max_w 宽),不要范围就传空指针 - let attrs = unsafe { s.attributesAtIndex_effectiveRange(0, std::ptr::null_mut()) }; - let chars: Vec = text.chars().collect(); - // 留前 n 个字再接「…」 - let cut = |n: usize| { - let candidate: String = chars[..n].iter().collect::() + "…"; - unsafe { - NSAttributedString::initWithString_attributes( - NSAttributedString::alloc(), - &NSString::from_str(&candidate), - Some(&attrs), - ) - } - }; - let fits = |a: &NSAttributedString| a.size().width <= max_w + FIT_SLACK; - // 留的字越多越宽,所以**二分找放得下的最多的那个 n**。一个字一个字地往回退、每退 - // 一个量一次,长的服务器名、提醒正文要在主线程上量上百次,菜单开着时每秒一遍。 - // 整段放不下,所以至多留 len - 1 个字 - let mut best = cut(0); - if !fits(&best) { - return None; - } - let (mut lo, mut hi) = (0, chars.len().saturating_sub(1)); - while lo < hi { - let mid = lo + (hi - lo).div_ceil(2); - let a = cut(mid); - if fits(&a) { - (lo, best) = (mid, a); - } else { - hi = mid - 1; - } - } - Some(best) -} - // ------------------------------------------------------------------ 点击与开合 define_class!( @@ -1219,49 +639,6 @@ pub fn confirm_quit(mtm: MainThreadMarker, in_flight: usize) -> bool { /// Esc 的键码 const KEY_ESCAPE: u16 = 53; -/// 此刻菜单里有什么,一行一项,子菜单缩进、勾选的打 ✓、分隔线写成 `---`。 -/// **给预览程序用**(`menubar_preview -- --dump`):没有屏幕录制权限时,核对原生菜单 -/// 真长什么样只能靠它 -pub fn describe_menu() -> Vec { - fn walk(menu: &NSMenu, depth: usize, out: &mut Vec) { - for i in 0..menu.numberOfItems() { - let Some(item) = menu.itemAtIndex(i) else { - continue; - }; - let pad = " ".repeat(depth); - if item.isSeparatorItem() { - out.push(format!("{pad}---")); - continue; - } - let title = item - .attributedTitle() - .map(|t| t.string().to_string()) - .unwrap_or_else(|| item.title().to_string()); - let check = if item.state() == NSControlStateValueOn { - "✓ " - } else { - "" - }; - let title = if title.is_empty() && item.view().is_some() { - "[自绘]".to_string() - } else { - title - }; - out.push(format!("{pad}{check}{title}")); - if let Some(sub) = item.submenu() { - walk(&sub, depth + 1, out); - } - } - } - UI.with(|ui| { - let mut out = Vec::new(); - if let Some(ui) = ui.borrow().as_ref() { - walk(&ui.menu, 0, &mut out); - } - out - }) -} - /// 一句话的提示框(检查更新之后「已是最新版本」这类) pub fn inform(mtm: MainThreadMarker, title: &str, body: &str) { let app = NSApplication::sharedApplication(mtm); @@ -1320,400 +697,3 @@ fn attributed(text: &str, font: &NSFont, color: &NSColor) -> Retained NSRect { NSRect::new(NSPoint::new(x, y), NSSize::new(w, h)) } - -// ------------------------------------------------------------------ 开发用:离屏预览 - -/// 开发用:把菜单栏那一块和整份菜单离屏画成一张图,用来看样子 -/// (`examples/menubar_preview.rs`)。**标准菜单项画的是近似的样子** —— 真的由 -/// AppKit 画;自定义的几行和菜单栏那一块是真的。 -pub fn preview_image(bar: &Bar, rows: &[Row], dark: bool) -> Retained { - use objc2_app_kit::{ - NSAffineTransformNSAppKitAdditions, NSAppearance, NSAppearanceNameAqua, - NSAppearanceNameDarkAqua, NSGraphicsContext, - }; - use objc2_foundation::NSAffineTransform; - - let quota = quota_text(rows); - let heights: Vec = rows - .iter() - .map(|r| match r { - Row::Separator => 11.0, - Row::Item(_) => 24.0, - other => info_height(&info_of(other, quota)), - }) - .collect(); - let bar_img = bar_image(bar); - let bar_h = thickness() + 8.0; - let total = bar_h + 10.0 + heights.iter().sum::() + 10.0; - let width = MENU_WIDTH + 24.0; - let rows = rows.to_vec(); - let block = RcBlock::new(move |_r: NSRect| -> Bool { - let name = unsafe { - if dark { - NSAppearanceNameDarkAqua - } else { - NSAppearanceNameAqua - } - }; - let Some(appearance) = NSAppearance::appearanceNamed(name) else { - return Bool::NO; - }; - let rows = rows.clone(); - let heights = heights.clone(); - let bar_img = bar_img.clone(); - let draw = RcBlock::new(move || { - // 桌面和菜单栏 - let desk = if dark { - (0.11, 0.12, 0.16) - } else { - (0.86, 0.89, 0.94) - }; - NSColor::colorWithSRGBRed_green_blue_alpha(desk.0, desk.1, desk.2, 1.0).setFill(); - NSBezierPath::fillRect(rect(0.0, 0.0, width, total)); - let sz = bar_img.size(); - let at = rect(width - 12.0 - sz.width, 4.0, sz.width, sz.height); - if bar_img.isTemplate() { - // 模板图在真菜单栏上由系统着色:这里照样染一遍,深色菜单栏上是白字 - let src = bar_img.clone(); - let paint = RcBlock::new(move |r: NSRect| -> Bool { - src.drawInRect(r); - let tint = if dark { - NSColor::whiteColor() - } else { - NSColor::blackColor() - }; - tint.setFill(); - objc2_app_kit::NSRectFillUsingOperation( - r, - objc2_app_kit::NSCompositingOperation::SourceAtop, - ); - Bool::YES - }); - NSImage::imageWithSize_flipped_drawingHandler(sz, true, &paint).drawInRect(at); - } else { - bar_img.drawInRect(at); - } - // 菜单的底 - let menu_bg = if dark { - (0.17, 0.17, 0.19) - } else { - (0.96, 0.96, 0.97) - }; - NSColor::colorWithSRGBRed_green_blue_alpha(menu_bg.0, menu_bg.1, menu_bg.2, 1.0) - .setFill(); - NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( - rect(12.0, bar_h, MENU_WIDTH, total - bar_h - 4.0), - 12.0, - 12.0, - ) - .fill(); - let mut y = bar_h + 6.0; - for (row, h) in rows.iter().zip(heights.iter()) { - let Some(ctx) = NSGraphicsContext::currentContext() else { - return; - }; - ctx.saveGraphicsState(); - let t = NSAffineTransform::transform(); - t.translateXBy_yBy(12.0, y); - t.concat(); - let r = rect(0.0, 0.0, MENU_WIDTH, *h); - match row { - Row::Separator => { - NSColor::separatorColor().setFill(); - NSBezierPath::fillRect(rect(PAD, 5.0, MENU_WIDTH - PAD * 2.0, 1.0)); - } - Row::Item(i) => preview_item(i, r), - other => draw_info(&info_of(other, quota), r, false), - } - ctx.restoreGraphicsState(); - y += h; - } - }); - appearance.performAsCurrentDrawingAppearance(&draw); - Bool::YES - }); - NSImage::imageWithSize_flipped_drawingHandler(NSSize::new(width, total), true, &block) -} - -/// 标准菜单项的近似样子:图标、标题、右边的淡色字或快捷键 -fn preview_item(i: &super::model::Item, b: NSRect) { - let color = if i.enabled { - NSColor::labelColor() - } else { - NSColor::tertiaryLabelColor() - }; - if let Some(icon) = i.icon.filter(|s| !s.is_empty()) - && let Some(img) = symbol( - icon, - Some(&*if i.accent { - NSColor::controlAccentColor() - } else { - color.clone() - }), - ) - { - img.drawInRect(rect(PAD, 4.0, 16.0, 16.0)); - } - let font = NSFont::menuFontOfSize(0.0); - attributed(&i.title, &font, &color).drawAtPoint(NSPoint::new(TEXT_X, 3.0)); - let right = i - .key - .map(|k| format!("⌘{}", k.to_uppercase())) - .or_else(|| i.right.clone()) - .or_else(|| (!i.submenu.is_empty()).then(|| "›".to_string())); - if let Some(r) = right { - let a = attributed(&r, &font, &NSColor::secondaryLabelColor()); - a.drawAtPoint(NSPoint::new(b.size.width - PAD - a.size().width, 3.0)); - } -} - -/// 开发用:把一张图写成 PNG -pub fn write_png(image: &NSImage, path: &std::path::Path) -> bool { - use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep}; - // 按 2 倍画,和 Retina 上看到的一样 - let size = image.size(); - let mut proposed = rect(0.0, 0.0, size.width * 2.0, size.height * 2.0); - let Some(cg) = - (unsafe { image.CGImageForProposedRect_context_hints(&mut proposed, None, None) }) - else { - return false; - }; - let rep = NSBitmapImageRep::initWithCGImage(NSBitmapImageRep::alloc(), &cg); - let props = NSDictionary::new(); - let Some(data) = - (unsafe { rep.representationUsingType_properties(NSBitmapImageFileType::PNG, &props) }) - else { - return false; - }; - std::fs::write(path, data.to_vec()).is_ok() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::i18n::{Lang, with_lang}; - use crate::menubar::model::{self, Gateway, Quota, Snapshot, StatCell, Today, Window}; - - /// 量字的几条测试一条一条来:AppKit 排字不必经得起几个线程同时量 - static APPKIT: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - /// 一条挂了不连累别的:锁被挂掉的那条带走了也照样拿 - fn appkit() -> std::sync::MutexGuard<'static, ()> { - APPKIT.lock().unwrap_or_else(|e| e.into_inner()) - } - - const NOW: u64 = 1_800_000_000_000; - const MIN: u64 = 60_000; - const DAY: u64 = 86_400_000; - - /// 截图里那一份菜单:ChatGPT 两个窗口,今天有几次失败 - fn menu(lang: Lang, failed: i64) -> Vec { - let window = |name: &str, used: f64, in_ms: u64| Window { - window: name.into(), - used_percent: used, - resets_at_ms: Some(NOW + in_ms), - status: None, - credits: None, - }; - let s = Snapshot { - gateway: Gateway::Running, - today: Some(Today { - requests: 244, - failed, - tokens: 4_200_000, - cost_micros: 40_260_000, - ..Default::default() - }), - quotas: vec![Quota { - provider: "chatgpt".into(), - windows: vec![ - window("5h", 58.0, 42 * MIN), - window("weekly", 31.0, 3 * DAY), - ], - reset_credits: None, - }], - now_ms: NOW, - ..Default::default() - }; - with_lang(lang, || model::build(&s, model::Style::Full).1) - } - - fn windows(rows: &[Row]) -> Vec { - rows.iter() - .flat_map(|r| match r { - Row::Quota { windows, .. } => windows.clone(), - _ => Vec::new(), - }) - .collect() - } - - fn cells(rows: &[Row]) -> Vec { - rows.iter() - .find_map(|r| match r { - Row::Stats { cells, .. } => Some(cells.clone()), - _ => None, - }) - .expect("今日那一格不见了") - } - - #[test] - fn chinese_quota_rows_keep_the_columns_they_always_had() { - let _appkit = appkit(); - let cols = quota_cols(MENU_WIDTH, quota_text(&menu(Lang::Zh, 0))); - assert_eq!( - cols, - QuotaCols { - label: 50.0, - bar_x: 64.0, - bar_w: 90.0, - pct_right: 202.0, - reset: 92.0, - } - ); - } - - /// 英文的「Resets in 3 days」比写死的 92 宽:曾经压在「31%」上,画成「31%Resets in 3 days」 - #[test] - fn english_reset_times_leave_room_after_the_percentage() { - let _appkit = appkit(); - let rows = menu(Lang::En, 0); - let all = windows(&rows); - assert!(all.iter().any(|w| w.reset == "Resets in 3 days"), "{all:?}"); - let cols = quota_cols(MENU_WIDTH, quota_text(&rows)); - assert!(cols.bar_w >= QUOTA_MIN_BAR, "{cols:?}"); - for w in &all { - let reset_left = MENU_WIDTH - PAD - text_width(&w.reset, "a_reset_font()); - assert!( - reset_left - cols.pct_right >= TEXT_GAP - 1e-9, - "「{}」离百分比只有 {:.1}", - w.reset, - reset_left - cols.pct_right - ); - let label_right = PAD + text_width(&w.label, "a_label_font()); - assert!(label_right + LABEL_GAP <= cols.bar_x + 1e-9, "{w:?}"); - } - } - - /// 英文的「Requests 4 failed」比三等分的一格宽:曾经画成「Requests 4 failedTokens」 - #[test] - fn the_failed_count_stays_inside_its_own_cell() { - let _appkit = appkit(); - let avail = MENU_WIDTH - PAD * 2.0; - for lang in [Lang::Zh, Lang::En] { - let cells = cells(&menu(lang, 4)); - assert!(cells[0].note.is_some()); - let widths = stat_widths(MENU_WIDTH, &cells); - assert!( - (widths.iter().sum::() - avail).abs() < 1e-9, - "{widths:?}" - ); - for (c, w) in cells.iter().zip(&widths) { - let line2 = text_width(&c.label, &stat_label_font()) - + c.note - .as_deref() - .map_or(0.0, |n| NOTE_GAP + text_width(n, &stat_label_font())); - let value = text_width(&c.value, &stat_value_font()); - assert!( - line2.max(value) + TEXT_GAP <= w + 1e-9, - "{lang:?} {c:?} 放不进 {w:.1}" - ); - } - } - // 中文照旧三等分 - let zh = stat_widths(MENU_WIDTH, &cells(&menu(Lang::Zh, 4))); - assert!(zh.iter().all(|w| (w - avail / 3.0).abs() < 1e-9), "{zh:?}"); - } - - /// 积分制套餐的窗口在条下面多一行小字:那一块要高出这一行,不然最后一行被下一项压住 - #[test] - fn a_line_under_a_bar_makes_the_quota_row_taller() { - let row = |detail: Option<&str>| WindowRow { - label: "5 小时".into(), - percent: Some(1.0), - reset: "3 小时后重置".into(), - tone: model::Tone::Normal, - detail: detail.map(str::to_string), - }; - let height = |windows: Vec| { - info_height(&Info::Quota { - provider: "glm".into(), - windows, - text: QuotaText::default(), - }) - }; - let plain = height(vec![row(None), row(None)]); - assert_eq!( - plain, - 6.0 + 20.0 + 2.0 * QUOTA_ROW + 3.0, - "按百分比报的照旧" - ); - assert_eq!( - height(vec![row(Some("剩余 1,976 / 2,000 积分")), row(None)]), - plain + QUOTA_DETAIL - ); - } - - #[test] - fn columns_are_equal_until_one_cell_needs_more() { - assert_eq!(share_columns(300.0, &[50.0, 60.0, 70.0]), [100.0; 3]); - // 多要的那一格照它要的给,其余平分剩下的 - assert_eq!( - share_columns(300.0, &[150.0, 60.0, 70.0]), - [150.0, 75.0, 75.0] - ); - // 平分之后又有一格不够:它也照要的给 - assert_eq!( - share_columns(300.0, &[150.0, 60.0, 85.0]), - [150.0, 65.0, 85.0] - ); - // 怎么分都放不下:按比例 - assert_eq!(share_columns(100.0, &[100.0, 100.0]), [50.0, 50.0]); - assert!(share_columns(100.0, &[]).is_empty()); - } - - #[test] - fn text_that_does_not_fit_is_cut_with_an_ellipsis() { - let _appkit = appkit(); - let s = attributed( - "Resets in 3 days", - "a_reset_font(), - &NSColor::labelColor(), - ); - let whole = fit(&s, 1000.0).unwrap(); - assert_eq!(whole.string().to_string(), "Resets in 3 days"); - // 列宽差一点浮点误差,照样算放得下 - let exact = fit(&s, s.size().width - 1e-9).unwrap(); - assert_eq!(exact.string().to_string(), "Resets in 3 days"); - let cut = fit(&s, 50.0).unwrap(); - assert!(cut.string().to_string().ends_with('…'), "{}", cut.string()); - assert!(cut.size().width <= 50.0); - assert!(fit(&s, 0.0).is_none()); - } - - /// 截断留下的是放得下的最多的字:和一个字一个字往回退、第一个放得下的那一截一样。 - /// 二分只是找得快,每一种宽度下结果都不变 - #[test] - fn cutting_keeps_as_many_characters_as_fit() { - let _appkit = appkit(); - let font = sys(11.5, weight(Weight::Regular)); - let body = "5 小时额度已用完,42 分钟后重置。Requests through this upstream are refused."; - let s = attributed(body, &font, &NSColor::labelColor()); - let chars: Vec = body.chars().collect(); - let whole = s.size().width; - let mut w = 1.0; - while w < whole + 10.0 { - let one_by_one = (0..chars.len()).rev().find_map(|n| { - let t = chars[..n].iter().collect::() + "…"; - let a = attributed(&t, &font, &NSColor::labelColor()); - (a.size().width <= w + FIT_SLACK).then_some(t) - }); - let want = if whole <= w + FIT_SLACK { - Some(body.to_string()) - } else { - one_by_one - }; - assert_eq!(fit(&s, w).map(|a| a.string().to_string()), want, "宽 {w}"); - w += 3.0; - } - } -} diff --git a/src-tauri/src/menubar/macos/info.rs b/src-tauri/src/menubar/macos/info.rs new file mode 100644 index 00000000..e356db9d --- /dev/null +++ b/src-tauri/src/menubar/macos/info.rs @@ -0,0 +1,1109 @@ +//! 菜单里自己画的那几行:网关不在运行时的状态、「今日」那一块、额度、小节标题、提醒、在跑 +//! 的请求。标准菜单项由 AppKit 画(见上一层),这几行 AppKit 画不出来。 +//! +//! 量字和画字的小工具在上一层,菜单栏那一块也用。 + +use std::cell::{Cell, RefCell}; + +use objc2::rc::Retained; +use objc2::{ + AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, Message, define_class, msg_send, +}; +use objc2_app_kit::{ + NSAttributedStringNSExtendedStringDrawing, NSAttributedStringNSStringDrawing, + NSAutoresizingMaskOptions, NSBezierPath, NSColor, NSEvent, NSFont, NSStringDrawingOptions, + NSView, +}; +use objc2_foundation::{ + NSAttributedString, NSMutableAttributedString, NSPoint, NSRect, NSSize, NSString, +}; + +use super::{ + MENU_WIDTH, PAD, TEXT_X, Weight, attributed, dispatch, fill_oval, mono, rect, symbol, sys, + weight, +}; +use crate::menubar::model::{Dash, Level, Row, Spark, StateTone, Tone, WindowRow}; + +// ------------------------------------------------------------------ 自定义的几行 + +/// 这一行要多宽。**菜单按最宽的那一项撑开**:「今日」那一块左边的字整段放下、右边的柱子 +/// 照设计稿的粗细画,要的比菜单的最小宽度多(英文的「128 requests · 2 failed · $3.47」) +/// 时,就让菜单宽一点,最多到 [`MAX_WIDTH`]。别的行跟着菜单走 +pub(super) fn row_width(row: &Row) -> f64 { + match row { + Row::Today(d) => { + let spark = d + .spark + .as_ref() + .map_or(0.0, |b| SPARK_FULL.width(b.len()) + SPARK_GAP); + (PAD * 2.0 + today_text(d).0 + spark) + .ceil() + .clamp(MENU_WIDTH, MAX_WIDTH) + } + _ => MENU_WIDTH, + } +} + +/// 这一行有多高。**按菜单的最小宽度量**:菜单被别的项撑宽时,折行的字只会更少,底下多出 +/// 一点空,不会压到下一行 +pub(super) fn row_height(row: &Row) -> f64 { + let w = MENU_WIDTH; + match row { + Row::Status { reason, .. } => { + STATUS_ROW + + reason.as_deref().map_or(0.0, |r| { + wrapped_height(r, &reason_font(), w - TEXT_X - PAD) + REASON_GAP + }) + + STATUS_BOTTOM + } + Row::Today(d) => match d.warn.as_deref() { + Some(r) => TODAY_WARN_Y + wrapped_height(r, &reason_font(), w - PAD * 2.0) + 8.0, + None => TODAY_META_BASELINE + TODAY_BOTTOM, + }, + Row::Section { .. } => 21.0, + Row::Notice { .. } => 40.0, + Row::Quota { window, .. } => { + QUOTA_BAR_Y + + QUOTA_BAR_H + + window.detail.as_ref().map_or(0.0, |_| QUOTA_DETAIL) + + QUOTA_BOTTOM + } + Row::Live { .. } => 24.0, + Row::Separator | Row::Item(_) => unreachable!("标准菜单项不走自定义视图"), + } +} + +pub struct ViewIvars { + row: RefCell, + /// 点了做什么(`Ui::actions` 里的下标)。没有就是一行只读的 + tag: Cell>, +} + +define_class!( + /// 菜单里自己画的一行 + #[unsafe(super(NSView))] + #[thread_kind = MainThreadOnly] + #[name = "ThinkWatchMenuRow"] + #[ivars = ViewIvars] + pub(super) struct InfoView; + + impl InfoView { + #[unsafe(method(isFlipped))] + fn is_flipped(&self) -> bool { + true + } + + #[unsafe(method(drawRect:))] + fn draw_rect(&self, _dirty: NSRect) { + let highlighted = self.ivars().tag.get().is_some() + && self.enclosingMenuItem().is_some_and(|i| i.isHighlighted()); + draw_row(&self.ivars().row.borrow(), self.bounds(), highlighted); + } + + #[unsafe(method(mouseUp:))] + fn mouse_up(&self, _event: &NSEvent) { + let Some(tag) = self.ivars().tag.get() else { return }; + if let Some(menu) = self.enclosingMenuItem().and_then(|i| unsafe { i.menu() }) { + menu.cancelTracking(); + } + dispatch(tag); + } + } +); + +impl InfoView { + pub(super) fn new(mtm: MainThreadMarker, row: Row, tag: Option) -> Retained { + let frame = rect(0.0, 0.0, row_width(&row), row_height(&row)); + let this = Self::alloc(mtm).set_ivars(ViewIvars { + row: RefCell::new(row), + tag: Cell::new(tag), + }); + let view: Retained = unsafe { msg_send![super(this), initWithFrame: frame] }; + view.setAutoresizingMask(NSAutoresizingMaskOptions::ViewWidthSizable); + view + } + + /// 就地换上新的数据。**要的宽度变大了就跟着放宽**(「今日」那一块从破折号变成一行英文): + /// 高度由样子定,样子没变就不变;变窄不收,菜单不在用户眼前缩一下 + pub(super) fn set_row(&self, row: Row, tag: Option) { + let want = row_width(&row); + let frame = self.frame(); + if want > frame.size.width { + self.setFrameSize(NSSize::new(want, frame.size.height)); + } + *self.ivars().row.borrow_mut() = row; + self.ivars().tag.set(tag); + self.setNeedsDisplay(true); + } +} + +// ------------------------------------------------------------------ 尺寸 +// +// 照着设计稿量的,换成了 AppKit 的系统字体和语义颜色。纵向的位置都按**基线**给:字号不同 +// 的几段字(大字和它后面的单位)要落在同一条线上,按行顶对齐是对不齐的。 + +/// 同一行里两段字之间至少空这么多 +const TEXT_GAP: f64 = 10.0; +/// 自画的行最多把菜单撑到这么宽。再宽就收窄柱子、截字(见 [`today_fit`]) +const MAX_WIDTH: f64 = 380.0; + +/// 状态那一块:点和状态那一行,和标准菜单项一样高 +const STATUS_ROW: f64 = 24.0; +/// 状态那一块:原因和上面那一行之间 +const REASON_GAP: f64 = 1.0; +/// 状态那一块:最下面留的空 +const STATUS_BOTTOM: f64 = 5.0; +/// 原因、监听没换成的那一行橙字最多折成几行 +const REASON_LINES: f64 = 2.0; + +/// 「今日」:上面那一行(「今日」和状态)的基线 +const TODAY_TOP_BASELINE: f64 = 16.0; +/// 「今日」:大字的基线 +const TODAY_HERO_BASELINE: f64 = 47.0; +/// 「今日」:下面那一行(次数、失败、费用)的基线。小柱子的底也落在这条线上 +const TODAY_META_BASELINE: f64 = 68.0; +/// 「今日」:最下面留的空(从那一行的基线算起,含字的下沿) +const TODAY_BOTTOM: f64 = 11.0; +/// 「今日」:监听没换成的那一行橙字从这里起(行顶) +const TODAY_WARN_Y: f64 = TODAY_META_BASELINE + 6.0; +/// 「今日」:状态前面那个点 +const TODAY_DOT: f64 = 6.0; +/// 「今日」:大字和单位之间 +const UNIT_GAP: f64 = 5.0; +/// 「今日」:左边的字和小柱子之间至少空这么多 +const SPARK_GAP: f64 = 14.0; +/// 小柱子满格多高 +const SPARK_MAX_H: f64 = 26.0; +/// 有用量的那一小时至少这么高:比没有用量的短线高出一截,分得清 +const SPARK_MIN_H: f64 = 3.0; +/// 没有用量、还没到的那一小时:一截短线 +const SPARK_STUB_H: f64 = 2.0; + +/// 额度行:上面那一行的基线 +const QUOTA_BASELINE: f64 = 15.0; +/// 额度行:条的顶 +const QUOTA_BAR_Y: f64 = 22.0; +/// 额度行:条有多粗 +const QUOTA_BAR_H: f64 = 4.0; +/// 额度行:条下面那一行小字(还剩多少积分)再占多高 +const QUOTA_DETAIL: f64 = 15.0; +/// 额度行:最下面留的空 +const QUOTA_BOTTOM: f64 = 5.0; + +fn reason_font() -> Retained { + sys(12.0, weight(Weight::Regular)) +} + +fn top_font() -> Retained { + sys(11.0, weight(Weight::Regular)) +} + +fn top_title_font() -> Retained { + sys(11.0, weight(Weight::Semibold)) +} + +/// 等宽数字:数在涨,同位数时宽度不变 +fn hero_font() -> Retained { + mono(28.0, weight(Weight::Semibold)) +} + +fn unit_font() -> Retained { + sys(13.0, weight(Weight::Regular)) +} + +fn meta_font() -> Retained { + mono(12.0, weight(Weight::Regular)) +} + +fn quota_font() -> Retained { + sys(12.0, weight(Weight::Regular)) +} + +/// 等宽数字:倒计时在走,同位数时宽度不变 +fn quota_right_font() -> Retained { + mono(12.0, weight(Weight::Regular)) +} + +/// 条下面那一行小字:比上面那一行小一号 +fn quota_detail_font() -> Retained { + sys(11.0, weight(Weight::Regular)) +} + +fn text_width(text: &str, font: &NSFont) -> f64 { + attributed(text, font, &NSColor::labelColor()).size().width +} + +/// 排出来的一行有多高。**按排字的结果量**,不拿字体的上下沿去加:系统字体排出来的行比 +/// 上下沿之和高一点,按后者给的框装不下第二行,折行的字就只剩一行加「…」 +fn line_height(font: &NSFont) -> f64 { + attributed("Ag", font, &NSColor::labelColor()) + .boundingRectWithSize_options_context( + NSSize::new(f64::MAX, f64::MAX), + NSStringDrawingOptions::UsesLineFragmentOrigin, + None, + ) + .size + .height +} + +/// 一段字折行之后有多高,最多 [`REASON_LINES`] 行 +fn wrapped_height(text: &str, font: &NSFont, max_w: f64) -> f64 { + let s = attributed(text, font, &NSColor::labelColor()); + let h = s + .boundingRectWithSize_options_context( + NSSize::new(max_w, f64::MAX), + NSStringDrawingOptions::UsesLineFragmentOrigin, + None, + ) + .size + .height; + h.min(line_height(font) * REASON_LINES).ceil() +} + +/// 画一段会折行的字:最多 [`REASON_LINES`] 行,再多的截在最后一行末尾 +fn draw_wrapped(s: &NSAttributedString, font: &NSFont, x: f64, y: f64, max_w: f64) { + s.drawWithRect_options_context( + rect(x, y, max_w, (line_height(font) * REASON_LINES).ceil()), + NSStringDrawingOptions::UsesLineFragmentOrigin + | NSStringDrawingOptions::TruncatesLastVisibleLine, + None, + ); +} + +/// 让字的基线落在 `baseline` 上 +fn at_baseline(s: &NSAttributedString, font: &NSFont, x: f64, baseline: f64) { + s.drawAtPoint(NSPoint::new(x, baseline - font.ascender())); +} + +/// 几段颜色不同的字接成一段 +fn joined(parts: &[(&str, &NSFont, &NSColor)]) -> Retained { + let out = NSMutableAttributedString::new(); + for (text, font, color) in parts { + out.appendAttributedString(&attributed(text, font, color)); + } + Retained::into_super(out) +} + +// ------------------------------------------------------------------ 「今日」怎么排 + +/// 「今日」那一块左边的字要多宽:大字连单位和下面那一行里宽的那个;以及跟在那一行后面的 +/// 小字(连同前面的「 · 」) +fn today_text(d: &Dash) -> (f64, Option) { + let hero = text_width(&d.value, &hero_font()) + UNIT_GAP + text_width(&d.unit, &unit_font()); + let meta = text_width(&d.meta_line(), &meta_font()); + let note = d + .note + .as_deref() + .map(|n| text_width(&format!(" · {n}"), &meta_font())); + (hero.max(meta), note) +} + +/// 小柱子的粗细。**放不下时先收窄**,再放不下才不画 +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct SparkSize { + pub bar: f64, + pub gap: f64, +} + +/// 设计稿上的粗细 +pub(super) const SPARK_FULL: SparkSize = SparkSize { bar: 3.0, gap: 1.5 }; +/// 地方不够时 +pub(super) const SPARK_COMPACT: SparkSize = SparkSize { bar: 2.0, gap: 1.0 }; + +impl SparkSize { + /// `n` 根柱子一共多宽 + pub fn width(self, n: usize) -> f64 { + if n == 0 { + return 0.0; + } + n as f64 * self.bar + (n - 1) as f64 * self.gap + } +} + +/// 「今日」那一块横着怎么排 +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct TodayFit { + /// 小柱子画多粗。None:不画 + pub spark: Option, + /// 金额缺着什么那几个字跟不跟在那一行后面 + pub note: bool, +} + +/// 左边的字(大字和那一行,`text_w` 是两者里宽的那个;`note_w` 是跟在后面的小字连同前面 +/// 的「 · 」)和右边的小柱子(`bars` 根)怎么在 `w` 里排开。 +/// +/// **左边的字不让**:费用在那一行的最后,截掉的话截的正是要看的数。先不跟小字(它在悬停 +/// 提示里),再把柱子收窄,最后不画柱子;都放不下时才截字 +pub(super) fn today_fit(text_w: f64, note_w: Option, bars: Option, w: f64) -> TodayFit { + let avail = w - PAD * 2.0; + let fits = |spark: Option, note: bool| { + let right = match (spark, bars) { + (Some(size), Some(n)) => size.width(n) + SPARK_GAP, + _ => 0.0, + }; + let left = text_w + if note { note_w.unwrap_or(0.0) } else { 0.0 }; + left + right <= avail + }; + let mut tries = Vec::new(); + if bars.is_some() { + tries.extend([ + (Some(SPARK_FULL), true), + (Some(SPARK_FULL), false), + (Some(SPARK_COMPACT), false), + ]); + } + tries.extend([(None, true), (None, false)]); + tries + .into_iter() + .filter(|(_, note)| !*note || note_w.is_some()) + .find(|(spark, note)| fits(*spark, *note)) + .map_or( + TodayFit { + spark: None, + note: false, + }, + |(spark, note)| TodayFit { spark, note }, + ) +} + +/// 一根柱子多高 +pub(super) fn spark_height(s: Spark) -> f64 { + match s { + Spark::Empty => SPARK_STUB_H, + Spark::Past(f) | Spark::Now(f) => { + (f.clamp(0.0, 1.0) * SPARK_MAX_H).round().max(SPARK_MIN_H) + } + } +} + +// ------------------------------------------------------------------ 画 + +fn state_color(tone: StateTone) -> Retained { + match tone { + StateTone::Ok => NSColor::systemGreenColor(), + StateTone::Busy => NSColor::systemYellowColor(), + StateTone::Warn => NSColor::systemOrangeColor(), + StateTone::Bad => NSColor::systemRedColor(), + } +} + +/// 一行里的几种字色。高亮时一律白字:底色是强调色 +struct Ink { + label: Retained, + secondary: Retained, + /// 分隔用的「·」,比次要的字再淡一档 + tertiary: Retained, + hl: bool, +} + +impl Ink { + fn new(hl: bool) -> Self { + if hl { + let white = NSColor::whiteColor(); + Self { + secondary: white.colorWithAlphaComponent(0.8), + tertiary: white.colorWithAlphaComponent(0.6), + label: white, + hl, + } + } else { + Self { + label: NSColor::labelColor(), + secondary: NSColor::secondaryLabelColor(), + tertiary: NSColor::tertiaryLabelColor(), + hl, + } + } + } + + /// 要紧的颜色(失败的红、要处理的橙)。高亮时也是白的 + fn alert(&self, color: Retained) -> Retained { + if self.hl { self.label.clone() } else { color } + } +} + +pub(super) fn draw_row(row: &Row, b: NSRect, hl: bool) { + let w = b.size.width; + if hl { + NSColor::selectedContentBackgroundColor().setFill(); + NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( + rect(5.0, 0.0, w - 10.0, b.size.height), + 5.0, + 5.0, + ) + .fill(); + } + let ink = Ink::new(hl); + match row { + Row::Status { + state, + tone, + server, + reason, + } => draw_status(state, *tone, server.as_deref(), reason.as_deref(), w, &ink), + Row::Today(d) => draw_today(d, w, &ink), + Row::Section { title, right } => { + let font = sys(11.0, weight(Weight::Semibold)); + attributed(title, &font, &ink.secondary).drawAtPoint(NSPoint::new(PAD, 4.0)); + if let Some(r) = right { + let a = attributed(r, &font, &ink.secondary); + a.drawAtPoint(NSPoint::new(w - PAD - a.size().width, 4.0)); + } + } + Row::Notice { + level, title, body, .. + } => { + let (name, color) = match level { + Level::Critical => ("xmark.octagon.fill", NSColor::systemRedColor()), + Level::Warning => ( + "exclamationmark.triangle.fill", + NSColor::systemOrangeColor(), + ), + Level::Info => ("info.circle.fill", NSColor::systemBlueColor()), + }; + if let Some(img) = symbol(name, Some(&ink.alert(color))) { + img.drawInRect(rect(PAD, 5.0, 16.0, 16.0)); + } + let t = attributed(title, &NSFont::menuFontOfSize(0.0), &ink.label); + draw_clipped(&t, TEXT_X, 3.0, w - TEXT_X - PAD); + let bd = attributed(body, &sys(11.5, weight(Weight::Regular)), &ink.secondary); + draw_clipped(&bd, TEXT_X, 21.0, w - TEXT_X - PAD); + } + Row::Quota { + provider, window, .. + } => draw_quota(provider, window, w, &ink), + Row::Live { + app, + model, + elapsed, + .. + } => { + if let Some(img) = symbol("circle.dashed", Some(&*ink.secondary)) { + img.drawInRect(rect(PAD, 4.0, 16.0, 16.0)); + } + let el = attributed( + elapsed, + &mono(12.0, weight(Weight::Regular)), + &ink.secondary, + ); + let ew = el.size().width; + el.drawAtPoint(NSPoint::new(w - PAD - ew, 4.5)); + // 应用名和模型都在时长左边。放不下先截模型;应用名(认不出应用时是密钥名, + // 多长都有)再长也截在时长前面 + let right = w - PAD - ew - 10.0; + let a = attributed(app, &NSFont::menuFontOfSize(0.0), &ink.label); + let aw = match fit(&a, right - TEXT_X) { + Some(a) => { + a.drawAtPoint(NSPoint::new(TEXT_X, 3.5)); + a.size().width + } + None => 0.0, + }; + let mx = TEXT_X + aw + 8.0; + let m = attributed(model, &sys(12.0, weight(Weight::Regular)), &ink.secondary); + draw_clipped(&m, mx, 4.5, right - mx); + } + Row::Separator | Row::Item(_) => {} + } +} + +/// 网关不在运行:点在图标那一列,状态和标准菜单项的字对齐,服务器的名字在右边;下面是原因 +fn draw_status( + state: &str, + tone: StateTone, + server: Option<&str>, + reason: Option<&str>, + w: f64, + ink: &Ink, +) { + fill_oval(PAD + 4.0, 8.0, 8.0, &state_color(tone)); + let menu = NSFont::menuFontOfSize(0.0); + let right = server.map_or(0.0, |name| { + let a = attributed(name, &menu, &ink.secondary); + // 名字多长都有:最多占一半,截断 + match fit(&a, (w - TEXT_X - PAD) / 2.0) { + Some(a) => { + let aw = a.size().width; + a.drawAtPoint(NSPoint::new(w - PAD - aw, 3.5)); + aw + TEXT_GAP + } + None => 0.0, + } + }); + let st = attributed(state, &menu, &ink.label); + draw_clipped(&st, TEXT_X, 3.5, w - TEXT_X - PAD - right); + if let Some(reason) = reason { + let font = reason_font(); + let r = attributed(reason, &font, &ink.alert(NSColor::systemOrangeColor())); + draw_wrapped( + &r, + &font, + TEXT_X, + STATUS_ROW + REASON_GAP - 3.0, + w - TEXT_X - PAD, + ); + } +} + +fn draw_today(d: &Dash, w: f64, ink: &Ink) { + // 上面一行:左边「今日」,右边点和状态。状态太长时截服务器的名字:状态和速率要留着 + let (title_font, top) = (top_title_font(), top_font()); + let title = attributed(&d.title, &title_font, &ink.secondary); + at_baseline(&title, &title_font, PAD, TODAY_TOP_BASELINE); + let room = w - PAD * 2.0 - title.size().width - TEXT_GAP - TODAY_DOT - 5.0; + let line = state_text_fitting(d, &top, room); + let st = attributed(&line, &top, &ink.secondary); + let st = fit(&st, room).unwrap_or(st); + let sw = st.size().width; + at_baseline(&st, &top, w - PAD - sw, TODAY_TOP_BASELINE); + let dot_y = TODAY_TOP_BASELINE - top.capHeight() / 2.0 - TODAY_DOT / 2.0; + fill_oval( + w - PAD - sw - 5.0 - TODAY_DOT, + dot_y, + TODAY_DOT, + &state_color(d.tone), + ); + + // 大字和单位,基线对齐 + let (hero_font, unit_font, meta_font) = (hero_font(), unit_font(), meta_font()); + let hero = attributed(&d.value, &hero_font, &ink.label); + let unit = attributed(&d.unit, &unit_font, &ink.secondary); + + // 下面那一行:几段用「 · 」连起来,失败数是红的 + let sep = " · "; + let mut parts: Vec<(&str, Retained)> = Vec::new(); + for (i, m) in d.meta.iter().enumerate() { + if i > 0 { + parts.push((sep, ink.tertiary.clone())); + } + let color = if m.failed { + ink.alert(NSColor::systemRedColor()) + } else { + ink.secondary.clone() + }; + parts.push((&m.text, color)); + } + let meta = joined( + &parts + .iter() + .map(|(t, c)| (*t, &*meta_font, &**c)) + .collect::>(), + ); + let note = d.note.as_deref().map(|n| { + joined(&[ + (sep, &meta_font, &ink.tertiary), + (n, &meta_font, &ink.alert(NSColor::systemOrangeColor())), + ]) + }); + // 和 `row_width` 量的是同一份:菜单为它撑开多少,这里就按多少排 + let (text_w, note_w) = today_text(d); + let layout = today_fit(text_w, note_w, d.spark.as_ref().map(Vec::len), w); + + let spark_w = match (layout.spark, &d.spark) { + (Some(size), Some(bars)) => { + draw_spark(bars, size, w - PAD, TODAY_META_BASELINE, ink); + size.width(bars.len()) + SPARK_GAP + } + _ => 0.0, + }; + let left_w = w - PAD * 2.0 - spark_w; + at_baseline(&hero, &hero_font, PAD, TODAY_HERO_BASELINE); + at_baseline( + &unit, + &unit_font, + PAD + hero.size().width + UNIT_GAP, + TODAY_HERO_BASELINE, + ); + let meta = fit(&meta, left_w).unwrap_or(meta); + at_baseline(&meta, &meta_font, PAD, TODAY_META_BASELINE); + if let (true, Some(note)) = (layout.note, note) { + at_baseline( + ¬e, + &meta_font, + PAD + meta.size().width, + TODAY_META_BASELINE, + ); + } + + // 监听设置没换成:最后一行橙字 + if let Some(warn) = &d.warn { + let font = reason_font(); + let r = attributed(warn, &font, &ink.alert(NSColor::systemOrangeColor())); + draw_wrapped(&r, &font, PAD, TODAY_WARN_Y, w - PAD * 2.0); + } +} + +/// 右上角那一句放得下的写法:放不下先把服务器的名字截短,状态和速率留着 +fn state_text_fitting(d: &Dash, font: &NSFont, room: f64) -> String { + let full = d.state_line(); + let Some(server) = &d.server else { + return full; + }; + if text_width(&full, font) <= room { + return full; + } + let without = Dash { + server: None, + ..d.clone() + } + .state_line(); + // 名字那一段能占的宽:去掉名字之后剩下的,再去掉它前后的「 · 」 + let name_room = room - text_width(&without, font) - text_width(" · ", font); + let name = attributed(server, font, &NSColor::labelColor()); + match fit(&name, name_room) { + Some(cut) => Dash { + server: Some(cut.string().to_string()), + ..d.clone() + } + .state_line(), + None => without, + } +} + +/// 小柱子:右边缘对齐到 `right`,底落在 `baseline` 上 +fn draw_spark(bars: &[Spark], size: SparkSize, right: f64, baseline: f64, ink: &Ink) { + // **`colorWithAlphaComponent` 是换掉透明度,不是乘上去**:系统的语义色本身就带着 + // 透明度(深色下次要的字是白色 55%),这里给的就是画出来的透明度 + let (past, now, empty) = if ink.hl { + let white = NSColor::whiteColor(); + ( + white.colorWithAlphaComponent(0.55), + white.clone(), + white.colorWithAlphaComponent(0.25), + ) + } else { + ( + NSColor::secondaryLabelColor().colorWithAlphaComponent(0.34), + NSColor::labelColor(), + NSColor::quaternaryLabelColor(), + ) + }; + let mut x = right - size.width(bars.len()); + for b in bars { + let h = spark_height(*b); + let color = match b { + Spark::Empty => &empty, + Spark::Past(_) => &past, + Spark::Now(_) => &now, + }; + color.setFill(); + let r = (size.bar / 3.0).min(1.0); + NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( + rect(x, baseline - h, size.bar, h), + r, + r, + ) + .fill(); + x += size.bar + size.gap; + } +} + +/// 一个额度窗口:上面一行左边「ChatGPT · 每周」,右边「31% · 4 天后重置」;下面一根通栏的条 +fn draw_quota(provider: &str, wr: &WindowRow, w: f64, ink: &Ink) { + let (font, right_font) = (quota_font(), quota_right_font()); + let tone = match wr.tone { + Tone::Normal => ink.label.clone(), + Tone::Warn => ink.alert(NSColor::systemOrangeColor()), + Tone::Full => ink.alert(NSColor::systemRedColor()), + }; + // 右边:百分比是主色(紧张、用完时变色),重置时刻是次要的 + let right = match wr.percent { + Some(p) => { + let pct = format!("{}%", p.round() as i64); + let rest = wr.right()[pct.len()..].to_string(); + joined(&[ + (&pct, &right_font, &tone), + (&rest, &right_font, &ink.secondary), + ]) + } + None => attributed(&wr.reset, &right_font, &ink.secondary), + }; + let rw = right.size().width; + at_baseline(&right, &right_font, w - PAD - rw, QUOTA_BASELINE); + // 左边:上游的名字多长都有,放不下截它,窗口名留着 + let label = attributed(&format!(" · {}", wr.label), &font, &ink.secondary); + let lw = label.size().width; + let name = attributed(provider, &font, &ink.label); + let name_room = w - PAD * 2.0 - rw - TEXT_GAP - lw; + let nw = match fit(&name, name_room) { + Some(n) => { + at_baseline(&n, &font, PAD, QUOTA_BASELINE); + n.size().width + } + None => 0.0, + }; + if let Some(label) = fit(&label, w - PAD * 2.0 - rw - TEXT_GAP - nw) { + at_baseline(&label, &font, PAD + nw, QUOTA_BASELINE); + } + // 通栏的条 + let bar_w = w - PAD * 2.0; + let track = if ink.hl { + NSColor::whiteColor().colorWithAlphaComponent(0.3) + } else { + NSColor::quaternaryLabelColor() + }; + let radius = QUOTA_BAR_H / 2.0; + track.setFill(); + NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( + rect(PAD, QUOTA_BAR_Y, bar_w, QUOTA_BAR_H), + radius, + radius, + ) + .fill(); + if let Some(p) = wr.percent { + let fw = (bar_w * p / 100.0).max(if p > 0.0 { QUOTA_BAR_H } else { 0.0 }); + if fw > 0.0 { + tone.setFill(); + NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( + rect(PAD, QUOTA_BAR_Y, fw, QUOTA_BAR_H), + radius, + radius, + ) + .fill(); + } + } + // 还剩多少:贴在条的正下方 + if let Some(detail) = &wr.detail { + let d = attributed(detail, "a_detail_font(), &ink.secondary); + draw_clipped(&d, PAD, QUOTA_BAR_Y + QUOTA_BAR_H + 2.0, bar_w); + } +} + +/// 画一段字,**放不下就截断**:自定义行不会自己换行,超出的部分会画到别的格子上 +fn draw_clipped(s: &NSAttributedString, x: f64, y: f64, max_w: f64) { + if let Some(s) = fit(s, max_w) { + s.drawAtPoint(NSPoint::new(x, y)); + } +} + +/// 列宽是「字宽 + 空」再减去空算回来的,和量出来的字宽之间差一点浮点误差。这点差不算 +/// 放不下 —— 否则正好放得下的字会被截成「4 fai…」 +const FIT_SLACK: f64 = 0.001; + +/// 放得下就是它本身;放不下就从尾巴上去字、末尾加「…」,留下放得下的最多的字。连一个 +/// 「…」都放不下是 None。**要右对齐的先拿它,再按它的宽定位置** +fn fit(s: &NSAttributedString, max_w: f64) -> Option> { + if max_w <= 0.0 { + return None; + } + if s.size().width <= max_w + FIT_SLACK { + return Some(s.retain()); + } + let text = s.string().to_string(); + // SAFETY: 第 0 个字符一定在(上面已经量过它比 max_w 宽),不要范围就传空指针 + let attrs = unsafe { s.attributesAtIndex_effectiveRange(0, std::ptr::null_mut()) }; + let chars: Vec = text.chars().collect(); + // 留前 n 个字再接「…」 + let cut = |n: usize| { + let candidate: String = chars[..n].iter().collect::() + "…"; + unsafe { + NSAttributedString::initWithString_attributes( + NSAttributedString::alloc(), + &NSString::from_str(&candidate), + Some(&attrs), + ) + } + }; + let fits = |a: &NSAttributedString| a.size().width <= max_w + FIT_SLACK; + // 留的字越多越宽,所以**二分找放得下的最多的那个 n**。一个字一个字地往回退、每退 + // 一个量一次,长的服务器名、提醒正文要在主线程上量上百次,菜单开着时每秒一遍。 + // 整段放不下,所以至多留 len - 1 个字 + let mut best = cut(0); + if !fits(&best) { + return None; + } + let (mut lo, mut hi) = (0, chars.len().saturating_sub(1)); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + let a = cut(mid); + if fits(&a) { + (lo, best) = (mid, a); + } else { + hi = mid - 1; + } + } + Some(best) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::{Lang, with_lang}; + use crate::menubar::model::{self, Gateway, Hourly, Snapshot, Today}; + + /// 量字的几条测试一条一条来:AppKit 排字不必经得起几个线程同时量 + static APPKIT: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// 一条挂了不连累别的:锁被挂掉的那条带走了也照样拿 + fn appkit() -> std::sync::MutexGuard<'static, ()> { + APPKIT.lock().unwrap_or_else(|e| e.into_inner()) + } + + const NOW: u64 = 1_800_000_000_000; + + /// 截图里那一份菜单的「今日」:128 次、失败几次、$3.47,有每小时的柱子 + fn today(lang: Lang, t: Today) -> Dash { + let s = Snapshot { + gateway: Gateway::Running, + today: Some(t), + hourly: Some(Hourly { + from_ms: NOW as i64 - 21 * model::HOUR_MS, + tokens: (0..24).map(|h| h * 10_000).collect(), + }), + rate: Some(42), + now_ms: NOW, + ..Default::default() + }; + with_lang(lang, || { + model::build(&s, model::Style::Full) + .1 + .into_iter() + .find_map(|r| match r { + Row::Today(d) => Some(*d), + _ => None, + }) + .expect("「今日」那一块不见了") + }) + } + + fn busy() -> Today { + Today { + requests: 128, + failed: 2, + tokens: 1_240_000, + cost_micros: 3_470_000, + ..Default::default() + } + } + + /// 设计稿那一份:中文在菜单的最小宽度里就放得下;英文的那一行长,菜单宽一点。两种都是 + /// 左边的字整段放下、柱子照设计稿的粗细画 + #[test] + fn the_busy_day_fits_beside_a_full_sparkline_in_both_languages() { + let _appkit = appkit(); + for lang in [Lang::Zh, Lang::En] { + let d = today(lang, busy()); + let w = row_width(&Row::Today(Box::new(d.clone()))); + let (text, note) = today_text(&d); + let fit = today_fit(text, note, d.spark.as_ref().map(Vec::len), w); + assert_eq!( + fit, + TodayFit { + spark: Some(SPARK_FULL), + note: false + }, + "{lang:?} {} 宽 {w}", + d.meta_line() + ); + if lang == Lang::Zh { + assert_eq!(w, MENU_WIDTH, "中文不用撑宽菜单"); + } else { + assert!(w > MENU_WIDTH && w < MENU_WIDTH + 30.0, "{w}"); + } + } + // 别的行跟着菜单走 + let section = Row::Section { + title: "提醒".into(), + right: None, + }; + assert_eq!(row_width(§ion), MENU_WIDTH); + } + + /// **费用不截**:那一行很长时(英文、上千次、上百刀),先把柱子收窄,再不画柱子 + #[test] + fn a_long_line_narrows_the_sparkline_before_cutting_the_cost() { + let _appkit = appkit(); + let d = today( + Lang::En, + Today { + requests: 12_345, + failed: 1_234, + tokens: 999_000_000, + cost_micros: 123_456_000, + unpriced: 3, + ..Default::default() + }, + ); + let (text, note) = today_text(&d); + let w = row_width(&Row::Today(Box::new(d.clone()))); + assert_eq!(w, MAX_WIDTH, "撑到头了"); + let fit = today_fit(text, note, Some(24), w); + assert!(!fit.note, "放不下的小字该进悬停提示"); + assert_ne!(fit.spark, Some(SPARK_FULL), "{}", d.meta_line()); + let spark = fit.spark.map_or(0.0, |s| s.width(24) + SPARK_GAP); + assert!(text + spark <= w - PAD * 2.0, "{}", d.meta_line()); + } + + #[test] + fn the_layout_gives_way_in_order() { + let avail = MENU_WIDTH - PAD * 2.0; + let full = SPARK_FULL.width(24) + SPARK_GAP; + let compact = SPARK_COMPACT.width(24) + SPARK_GAP; + let fit = |text: f64, note: Option, bars: Option| { + today_fit(text, note, bars, MENU_WIDTH) + }; + let at = |spark, note| TodayFit { spark, note }; + assert_eq!(fit(100.0, Some(40.0), Some(24)), at(Some(SPARK_FULL), true)); + // 小字先让 + assert_eq!( + fit(avail - full - 10.0, Some(40.0), Some(24)), + at(Some(SPARK_FULL), false) + ); + // 再收窄柱子 + assert_eq!( + fit(avail - compact - 1.0, Some(40.0), Some(24)), + at(Some(SPARK_COMPACT), false) + ); + // 再不画柱子;没有柱子时小字放得下就跟着 + assert_eq!(fit(avail - 30.0, Some(20.0), Some(24)), at(None, true)); + assert_eq!(fit(avail - 30.0, Some(40.0), Some(24)), at(None, false)); + // 什么都放不下:只剩截字 + assert_eq!(fit(avail + 50.0, None, Some(24)), at(None, false)); + // 没问到每小时的数:本来就没有柱子 + assert_eq!(fit(100.0, None, None), at(None, false)); + assert_eq!(SPARK_FULL.width(24), 24.0 * 3.0 + 23.0 * 1.5); + assert_eq!(SPARK_FULL.width(0), 0.0); + } + + /// 没有用量的小时是一截短线;有用量的再少也比短线高一截,满格是 26 + #[test] + fn bars_never_look_like_the_empty_stub() { + assert_eq!(spark_height(Spark::Empty), SPARK_STUB_H); + assert_eq!(spark_height(Spark::Past(0.001)), SPARK_MIN_H); + const { assert!(SPARK_MIN_H > SPARK_STUB_H) }; + assert_eq!(spark_height(Spark::Now(1.0)), SPARK_MAX_H); + assert_eq!(spark_height(Spark::Past(0.5)), 13.0); + assert_eq!(spark_height(Spark::Past(2.0)), SPARK_MAX_H, "不画出格"); + } + + /// 英文的原因比一行长:折成两行,那一块跟着高一截,不截成「Startup failed several…」 + #[test] + fn a_long_reason_wraps_and_makes_the_status_taller() { + let _appkit = appkit(); + let status = |lang: Lang| { + let s = Snapshot { + gateway: Gateway::SafeMode, + ..Default::default() + }; + with_lang(lang, || model::build(&s, model::Style::Full).1) + .into_iter() + .find(|r| matches!(r, Row::Status { .. })) + .unwrap() + }; + let line = line_height(&reason_font()); + let zh = row_height(&status(Lang::Zh)); + let en = row_height(&status(Lang::En)); + assert!( + (zh - (STATUS_ROW + REASON_GAP + line.ceil() + STATUS_BOTTOM)).abs() <= 1.0, + "{zh}" + ); + assert!(en > zh + line * 0.5, "英文 {en},中文 {zh}"); + assert!(en <= STATUS_ROW + REASON_GAP + (line * REASON_LINES).ceil() + STATUS_BOTTOM); + // 正在启动:没有原因,就是一行 + let starting = Row::Status { + state: "正在启动".into(), + tone: StateTone::Busy, + server: None, + reason: None, + }; + assert_eq!(row_height(&starting), STATUS_ROW + STATUS_BOTTOM); + } + + /// 积分制套餐的窗口在条下面多一行小字:那一行要高出这一截,不然被下一项压住 + #[test] + fn a_line_under_a_bar_makes_the_quota_row_taller() { + let row = |detail: Option<&str>| Row::Quota { + provider: "glm".into(), + window: WindowRow { + label: "5 小时".into(), + percent: Some(1.0), + reset: "3 小时后重置".into(), + tone: Tone::Normal, + detail: detail.map(str::to_string), + }, + action: model::Action::Open("upstreams"), + }; + let plain = row_height(&row(None)); + assert_eq!(plain, QUOTA_BAR_Y + QUOTA_BAR_H + QUOTA_BOTTOM); + assert_eq!( + row_height(&row(Some("剩余 1,976 / 2,000 积分"))), + plain + QUOTA_DETAIL + ); + } + + /// 监听没换成的那一行橙字让「今日」那一块高一截 + #[test] + fn a_listen_error_makes_today_taller() { + let _appkit = appkit(); + let mut d = today(Lang::Zh, busy()); + let plain = row_height(&Row::Today(Box::new(d.clone()))); + assert_eq!(plain, TODAY_META_BASELINE + TODAY_BOTTOM); + d.warn = Some("端口 18790 已被占用。".into()); + assert!(row_height(&Row::Today(Box::new(d))) > plain + 10.0); + } + + /// 服务器的名字很长:截它,状态和速率留着 + #[test] + fn a_long_server_name_is_cut_before_the_rate() { + let _appkit = appkit(); + let mut d = today(Lang::Zh, busy()); + d.server = Some("a-very-long-remote-server-name-that-never-ends.example.com".into()); + let font = top_font(); + let line = state_text_fitting(&d, &font, 200.0); + assert!(line.starts_with("运行中 · a-very"), "{line}"); + assert!(line.ends_with("… · 42 token/秒"), "{line}"); + assert!(text_width(&line, &font) <= 200.0 + 0.01); + // 放得下就原样 + d.server = Some("office-mac".into()); + assert_eq!( + state_text_fitting(&d, &font, 200.0), + "运行中 · office-mac · 42 token/秒" + ); + } + + #[test] + fn text_that_does_not_fit_is_cut_with_an_ellipsis() { + let _appkit = appkit(); + let s = attributed( + "Resets in 3 days", + "a_right_font(), + &NSColor::labelColor(), + ); + let whole = fit(&s, 1000.0).unwrap(); + assert_eq!(whole.string().to_string(), "Resets in 3 days"); + // 列宽差一点浮点误差,照样算放得下 + let exact = fit(&s, s.size().width - 1e-9).unwrap(); + assert_eq!(exact.string().to_string(), "Resets in 3 days"); + let cut = fit(&s, 50.0).unwrap(); + assert!(cut.string().to_string().ends_with('…'), "{}", cut.string()); + assert!(cut.size().width <= 50.0); + assert!(fit(&s, 0.0).is_none()); + } + + /// 截断留下的是放得下的最多的字:和一个字一个字往回退、第一个放得下的那一截一样。 + /// 二分只是找得快,每一种宽度下结果都不变 + #[test] + fn cutting_keeps_as_many_characters_as_fit() { + let _appkit = appkit(); + let font = sys(11.5, weight(Weight::Regular)); + let body = "5 小时额度已用完,42 分钟后重置。Requests through this upstream are refused."; + let s = attributed(body, &font, &NSColor::labelColor()); + let chars: Vec = body.chars().collect(); + let whole = s.size().width; + let mut w = 1.0; + while w < whole + 10.0 { + let one_by_one = (0..chars.len()).rev().find_map(|n| { + let t = chars[..n].iter().collect::() + "…"; + let a = attributed(&t, &font, &NSColor::labelColor()); + (a.size().width <= w + FIT_SLACK).then_some(t) + }); + let want = if whole <= w + FIT_SLACK { + Some(body.to_string()) + } else { + one_by_one + }; + assert_eq!(fit(&s, w).map(|a| a.string().to_string()), want, "宽 {w}"); + w += 3.0; + } + } +} diff --git a/src-tauri/src/menubar/macos/preview.rs b/src-tauri/src/menubar/macos/preview.rs new file mode 100644 index 00000000..dd6b286b --- /dev/null +++ b/src-tauri/src/menubar/macos/preview.rs @@ -0,0 +1,261 @@ +//! 开发用:给 `examples/menubar_*.rs` 看样子的几样 —— 离屏把菜单画成图、写成 PNG, +//! 把挂出来的菜单点开、收起、逐项打出来。 +//! +//! **只在开发构建里**(`debug_assertions`),同 `memcheck`:发布包里用不着,预览程序 +//! 也只用 `cargo run`(开发构建)跑。 + +use block2::RcBlock; +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, Bool}; +use objc2::{AnyThread, MainThreadMarker, msg_send, sel}; +use objc2_app_kit::{ + NSAttributedStringNSStringDrawing, NSBezierPath, NSColor, NSControlStateValueOn, NSFont, + NSImage, NSMenu, +}; +use objc2_foundation::{NSDictionary, NSPoint, NSRect, NSSize}; + +use super::info::{draw_row, row_height, row_width}; +use super::{MENU_WIDTH, OPEN, PAD, TEXT_X, UI, attributed, bar_image, rect, symbol, thickness}; +use crate::menubar::model::{Bar, Item, Row}; + +/// 菜单开着吗(预览程序等它被点开) +pub fn is_open() -> bool { + OPEN.with(|o| o.get()) +} + +/// 开发用:过 `delay` 秒像用户点了一下那样把菜单打开(预览程序用)。 +/// +/// **走 run loop 的延时调用,不走 GCD。**菜单的跟踪循环要是嵌在一个主队列任务里, +/// CFRunLoop 为了不让主队列重入,那期间不再处理主队列 —— 投递过来的更新全都要等 +/// 菜单关上。用户真点菜单栏时跟踪循环是从事件分发开始的,没有这个问题 +pub fn open_menu_later(mtm: MainThreadMarker, delay: f64) { + let button = UI.with(|ui| ui.borrow().as_ref().and_then(|ui| ui.item.button(mtm))); + if let Some(button) = button { + let none: Option<&AnyObject> = None; + let _: () = unsafe { + msg_send![&*button, performSelector: sel!(performClick:), withObject: none, afterDelay: delay] + }; + } +} + +/// 开发用:把开着的菜单收起来 +pub fn close_menu() { + UI.with(|ui| { + if let Some(ui) = ui.borrow().as_ref() { + ui.menu.cancelTracking(); + } + }); +} + +/// 此刻菜单里有什么,一行一项,子菜单缩进、勾选的打 ✓、分隔线写成 `---`。 +/// **给预览程序用**(`menubar_preview -- --dump`):没有屏幕录制权限时,核对原生菜单 +/// 真长什么样只能靠它 +pub fn describe_menu() -> Vec { + fn walk(menu: &NSMenu, depth: usize, out: &mut Vec) { + for i in 0..menu.numberOfItems() { + let Some(item) = menu.itemAtIndex(i) else { + continue; + }; + let pad = " ".repeat(depth); + if item.isSeparatorItem() { + out.push(format!("{pad}---")); + continue; + } + let title = item + .attributedTitle() + .map(|t| t.string().to_string()) + .unwrap_or_else(|| item.title().to_string()); + let check = if item.state() == NSControlStateValueOn { + "✓ " + } else { + "" + }; + let title = if title.is_empty() && item.view().is_some() { + "[自绘]".to_string() + } else { + title + }; + out.push(format!("{pad}{check}{title}")); + if let Some(sub) = item.submenu() { + walk(&sub, depth + 1, out); + } + } + } + UI.with(|ui| { + let mut out = Vec::new(); + if let Some(ui) = ui.borrow().as_ref() { + walk(&ui.menu, 0, &mut out); + } + out + }) +} + +/// 开发用:把菜单栏那一块和整份菜单离屏画成一张图,用来看样子 +/// (`examples/menubar_preview.rs`)。**标准菜单项画的是近似的样子** —— 真的由 +/// AppKit 画;自定义的几行和菜单栏那一块是真的。 +pub fn preview_image(bar: &Bar, rows: &[Row], dark: bool) -> Retained { + use objc2_app_kit::{ + NSAffineTransformNSAppKitAdditions, NSAppearance, NSAppearanceNameAqua, + NSAppearanceNameDarkAqua, NSGraphicsContext, + }; + use objc2_foundation::NSAffineTransform; + + let heights: Vec = rows + .iter() + .map(|r| match r { + Row::Separator => 11.0, + Row::Item(_) => 24.0, + other => row_height(other), + }) + .collect(); + // 菜单按最宽的那一项撑开(见 `info::row_width`),和真的一样 + let menu_w = rows + .iter() + .filter(|r| !matches!(r, Row::Separator | Row::Item(_))) + .map(row_width) + .fold(MENU_WIDTH, f64::max); + let bar_img = bar_image(bar); + let bar_h = thickness() + 8.0; + let total = bar_h + 10.0 + heights.iter().sum::() + 10.0; + let width = menu_w + 24.0; + let rows = rows.to_vec(); + let block = RcBlock::new(move |_r: NSRect| -> Bool { + let name = unsafe { + if dark { + NSAppearanceNameDarkAqua + } else { + NSAppearanceNameAqua + } + }; + let Some(appearance) = NSAppearance::appearanceNamed(name) else { + return Bool::NO; + }; + let rows = rows.clone(); + let heights = heights.clone(); + let bar_img = bar_img.clone(); + let draw = RcBlock::new(move || { + // 桌面和菜单栏 + let desk = if dark { + (0.11, 0.12, 0.16) + } else { + (0.86, 0.89, 0.94) + }; + NSColor::colorWithSRGBRed_green_blue_alpha(desk.0, desk.1, desk.2, 1.0).setFill(); + NSBezierPath::fillRect(rect(0.0, 0.0, width, total)); + let sz = bar_img.size(); + let at = rect(width - 12.0 - sz.width, 4.0, sz.width, sz.height); + if bar_img.isTemplate() { + // 模板图在真菜单栏上由系统着色:这里照样染一遍,深色菜单栏上是白字 + let src = bar_img.clone(); + let paint = RcBlock::new(move |r: NSRect| -> Bool { + src.drawInRect(r); + let tint = if dark { + NSColor::whiteColor() + } else { + NSColor::blackColor() + }; + tint.setFill(); + objc2_app_kit::NSRectFillUsingOperation( + r, + objc2_app_kit::NSCompositingOperation::SourceAtop, + ); + Bool::YES + }); + NSImage::imageWithSize_flipped_drawingHandler(sz, true, &paint).drawInRect(at); + } else { + bar_img.drawInRect(at); + } + // 菜单的底 + let menu_bg = if dark { + (0.17, 0.17, 0.19) + } else { + (0.96, 0.96, 0.97) + }; + NSColor::colorWithSRGBRed_green_blue_alpha(menu_bg.0, menu_bg.1, menu_bg.2, 1.0) + .setFill(); + NSBezierPath::bezierPathWithRoundedRect_xRadius_yRadius( + rect(12.0, bar_h, menu_w, total - bar_h - 4.0), + 12.0, + 12.0, + ) + .fill(); + let mut y = bar_h + 6.0; + for (row, h) in rows.iter().zip(heights.iter()) { + let Some(ctx) = NSGraphicsContext::currentContext() else { + return; + }; + ctx.saveGraphicsState(); + let t = NSAffineTransform::transform(); + t.translateXBy_yBy(12.0, y); + t.concat(); + let r = rect(0.0, 0.0, menu_w, *h); + match row { + Row::Separator => { + NSColor::separatorColor().setFill(); + NSBezierPath::fillRect(rect(PAD, 5.0, menu_w - PAD * 2.0, 1.0)); + } + Row::Item(i) => preview_item(i, r), + other => draw_row(other, r, false), + } + ctx.restoreGraphicsState(); + y += h; + } + }); + appearance.performAsCurrentDrawingAppearance(&draw); + Bool::YES + }); + NSImage::imageWithSize_flipped_drawingHandler(NSSize::new(width, total), true, &block) +} + +/// 标准菜单项的近似样子:图标、标题、右边的淡色字或快捷键 +fn preview_item(i: &Item, b: NSRect) { + let color = if i.enabled { + NSColor::labelColor() + } else { + NSColor::tertiaryLabelColor() + }; + if let Some(icon) = i.icon.filter(|s| !s.is_empty()) + && let Some(img) = symbol( + icon, + Some(&*if i.accent { + NSColor::controlAccentColor() + } else { + color.clone() + }), + ) + { + img.drawInRect(rect(PAD, 4.0, 16.0, 16.0)); + } + let font = NSFont::menuFontOfSize(0.0); + attributed(&i.title, &font, &color).drawAtPoint(NSPoint::new(TEXT_X, 3.0)); + let right = i + .key + .map(|k| format!("⌘{}", k.to_uppercase())) + .or_else(|| i.right.clone()) + .or_else(|| (!i.submenu.is_empty()).then(|| "›".to_string())); + if let Some(r) = right { + let a = attributed(&r, &font, &NSColor::secondaryLabelColor()); + a.drawAtPoint(NSPoint::new(b.size.width - PAD - a.size().width, 3.0)); + } +} + +/// 开发用:把一张图写成 PNG +pub fn write_png(image: &NSImage, path: &std::path::Path) -> bool { + use objc2_app_kit::{NSBitmapImageFileType, NSBitmapImageRep}; + // 按 2 倍画,和 Retina 上看到的一样 + let size = image.size(); + let mut proposed = rect(0.0, 0.0, size.width * 2.0, size.height * 2.0); + let Some(cg) = + (unsafe { image.CGImageForProposedRect_context_hints(&mut proposed, None, None) }) + else { + return false; + }; + let rep = NSBitmapImageRep::initWithCGImage(NSBitmapImageRep::alloc(), &cg); + let props = NSDictionary::new(); + let Some(data) = + (unsafe { rep.representationUsingType_properties(NSBitmapImageFileType::PNG, &props) }) + else { + return false; + }; + std::fs::write(path, data.to_vec()).is_ok() +} diff --git a/src-tauri/src/menubar/mod.rs b/src-tauri/src/menubar/mod.rs index 24c08686..9eaf51e2 100644 --- a/src-tauri/src/menubar/mod.rs +++ b/src-tauri/src/menubar/mod.rs @@ -273,17 +273,60 @@ fn next_reset_in(snap: &Snapshot, day_end_ms: Option) -> Option(tz: &Tz, now_ms: u64) -> tw_api::Window { - tw_api::Window { +/// 那一天差一个小时。终点不给,就是到现在。 +/// +/// **今天的合计和每小时的柱子出自这同一问**(`/summary/buckets/by`,概览的趋势图也是它): +/// 它和 `/summary` 是同一个时间窗、同一个筛选条件(`at_ms` 在窗里、不是本地应答),每一格 +/// 带着次数、失败、费用三态和四类 token,各格加起来就是 `/summary` 的那几个数。问一次 +/// 而不是两次,大数字和柱子也就永远对得上。按密钥分组:一小时里通常只有一两把密钥, +/// 回来的行最少 +fn today_query(tz: &Tz, now_ms: u64) -> tw_api::BucketGroupQuery { + tw_api::BucketGroupQuery { from_ms: Some(day_of(tz, now_ms).0), to_ms: None, + bucket_ms: Some(model::HOUR_MS), + dim: tw_api::CostDim::Client, + } +} + +/// 把那一问的回答加成今天的合计和每小时的 token。`day` 是问的那一天([`day_of`]):格数按 +/// 它有几个小时定(夏令时切换的那天 23 或 25 格)。**落在这一天之外的格子合计里也不算**: +/// 连着的 core 时钟快几分钟、这边快到零点时,它会回一格明天的 +fn today_from( + groups: &[tw_api::CostBucketGroup], + day: (i64, i64), +) -> (model::Today, model::Hourly) { + let (from, to) = day; + let slots = usize::try_from((to - from + model::HOUR_MS - 1) / model::HOUR_MS).unwrap_or(0); + let mut today = model::Today::default(); + let mut tokens = vec![0; slots]; + for g in groups { + let k = (g.at_ms - from).div_euclid(model::HOUR_MS); + let Some(slot) = usize::try_from(k).ok().and_then(|k| tokens.get_mut(k)) else { + continue; + }; + let t = g.input_tokens + g.output_tokens + g.cache_read_tokens + g.cache_write_tokens; + *slot += t; + today.requests += g.requests; + today.failed += g.failed; + today.tokens += t; + today.cost_micros += g.cost_micros_exact + g.cost_micros_estimated; + today.estimated_micros += g.cost_micros_estimated; + today.unpriced += g.unpriced_requests; + today.no_usage += g.no_usage_requests; } + ( + today, + model::Hourly { + from_ms: from, + tokens, + }, + ) } /// `now_ms` 所在的那一天,按 `tz` 的日历:`[零点, 下一个零点)`,Unix 毫秒。 @@ -374,32 +417,34 @@ async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits return snap; } let c = &state.control; - let today = today_window(&chrono::Local, now_ms); + let day = day_of(&chrono::Local, now_ms); + let today = today_query(&chrono::Local, now_ms); // 每一问各自限时:一问不回,别的照常画,没问到的那几样是「不知道」 - let (status, quota, summary, overview, live) = tokio::join!( + let (status, quota, buckets, overview, live) = tokio::join!( ask(ASK, c.status()), ask(ASK, c.call::(&[], &())), - ask(ASK, c.call::(&[], &today)), + ask(ASK, c.call::(&[], &today)), ask(ASK, c.call::(&[], &())), ask(ASK, c.call::(&[], &())) ); if let Ok(l) = live { apply_live(&mut snap, l); } - if let Ok(s) = status { - snap.addr = s.gateway_addr; - snap.listen_error = s.listen_error.map(|e| crate::core_text::text(&e)); + if let Ok(s) = &status { + snap.listen_error = s.listen_error.clone().map(|e| crate::core_text::text(&e)); } - if let Ok(s) = summary { - snap.today = Some(model::Today { - requests: s.requests, - failed: s.failed, - tokens: s.input_tokens + s.output_tokens + s.cache_read_tokens + s.cache_write_tokens, - cost_micros: s.cost_micros_exact + s.cost_micros_estimated, - estimated_micros: s.cost_micros_estimated, - unpriced: s.unpriced_requests, - no_usage: s.no_usage_requests, - }); + // 右边写的是「复制网关地址」复制的那一个:主机按连接(本机是 127.0.0.1),端口按配置。 + // 网关没在监听时不写 + if let (Ok(s), Ok(o)) = (&status, &overview) + && s.gateway_addr.is_some() + { + let url = crate::clients::base_url(&crate::clients::gateway_host(state), o.listen.port); + snap.addr = Some(url.trim_start_matches("http://").to_string()); + } + if let Ok(groups) = buckets { + let (t, hourly) = today_from(&groups, day); + snap.today = Some(t); + snap.hourly = Some(hourly); } let accounts: Vec = overview .as_ref() @@ -984,17 +1029,101 @@ mod tests { fn today_starts_at_this_machines_midnight() { let now = utc_ms(2026, 9, 26, 23, 30) as u64; let east8 = FixedOffset::east_opt(8 * 3600).unwrap(); - let w = today_window(&east8, now); - assert_eq!(w.from_ms, Some(utc_ms(2026, 9, 26, 16, 0))); - assert_eq!(w.to_ms, None, "终点不给,就是到现在"); + let q = today_query(&east8, now); + assert_eq!(q.from_ms, Some(utc_ms(2026, 9, 26, 16, 0))); + assert_eq!(q.to_ms, None, "终点不给,就是到现在"); + assert_eq!(q.bucket_ms, Some(model::HOUR_MS), "一小时一格"); // 同一刻在西五区是 9/26 的傍晚 let west5 = FixedOffset::west_opt(5 * 3600).unwrap(); assert_eq!( - today_window(&west5, now).from_ms, + today_query(&west5, now).from_ms, Some(utc_ms(2026, 9, 26, 5, 0)) ); } + /// core 回的一格里的一把密钥 + fn group(at_ms: i64, name: &str, requests: i64, tokens: [i64; 4]) -> tw_api::CostBucketGroup { + tw_api::CostBucketGroup { + at_ms, + name: name.into(), + requests, + failed: 0, + cost_micros_exact: 0, + cost_micros_estimated: 0, + unpriced_requests: 0, + no_usage_requests: 0, + input_tokens: tokens[0], + output_tokens: tokens[1], + cache_read_tokens: tokens[2], + cache_write_tokens: tokens[3], + } + } + + /// 各格各把密钥加起来就是今天的合计(`/summary` 的那几个数),每一格的四类 token 加起来 + /// 落在它那一小时上;**一个请求都没有的小时是 0**,core 不回空格子 + #[test] + fn todays_total_and_hours_come_from_the_same_answer() { + const H: i64 = model::HOUR_MS; + let from = utc_ms(2026, 10, 8, 16, 0); + let day = (from, from + 24 * H); + let mut a = group(from, "default", 3, [1_000, 200, 5_000, 100]); + a.failed = 1; + a.cost_micros_exact = 1_500_000; + a.unpriced_requests = 1; + let mut b = group(from, "codex", 1, [10, 20, 0, 0]); + b.cost_micros_estimated = 250_000; + b.no_usage_requests = 2; + let c = group(from + 9 * H, "default", 2, [400, 600, 0, 0]); + let (today, hourly) = today_from(&[a, b, c], day); + assert_eq!( + today, + model::Today { + requests: 6, + failed: 1, + tokens: 6_300 + 30 + 1_000, + cost_micros: 1_750_000, + estimated_micros: 250_000, + unpriced: 1, + no_usage: 2, + } + ); + assert_eq!(hourly.from_ms, from); + assert_eq!(hourly.tokens.len(), 24); + assert_eq!(hourly.tokens[0], 6_330); + assert_eq!(hourly.tokens[9], 1_000); + assert_eq!( + hourly.tokens.iter().sum::(), + today.tokens, + "柱子加起来就是大数字" + ); + assert!(hourly.tokens[1..9].iter().all(|t| *t == 0)); + // 一个请求都没有的一天:问到了,是 0,不是「不知道」 + let (quiet, hours) = today_from(&[], day); + assert_eq!(quiet, model::Today::default()); + assert_eq!(hours.tokens, vec![0; 24]); + } + + /// 夏令时切换的那一天不是 24 小时:纽约 3 月 8 日只有 23 小时,11 月 1 日有 25 小时。 + /// 格数跟着这一天走,最后一小时的请求不会因为「只有 24 格」被丢掉 + #[test] + fn a_dst_day_has_as_many_hours_as_it_really_has() { + const H: i64 = model::HOUR_MS; + let spring = shift(utc_ms(2026, 3, 8, 7, 0), -5, -4); + let day = day_of(&spring, utc_ms(2026, 3, 8, 16, 0) as u64); + assert_eq!(today_from(&[], day).1.tokens.len(), 23); + let fall = shift(utc_ms(2026, 11, 1, 6, 0), -4, -5); + let day = day_of(&fall, utc_ms(2026, 11, 1, 16, 0) as u64); + let last = group(day.0 + 24 * H + 60_000, "default", 1, [7, 0, 0, 0]); + let (_, hourly) = today_from(&[last], day); + assert_eq!(hourly.tokens.len(), 25); + assert_eq!(hourly.tokens[24], 7); + // 落在这一天之外的格子:明天的那一格不算进今天,也不画到别的小时上 + let tomorrow = group(day.1, "default", 1, [9, 0, 0, 0]); + let (today, hourly) = today_from(&[tomorrow], day); + assert_eq!(today, model::Today::default()); + assert!(hourly.tokens.iter().all(|t| *t == 0)); + } + /// 夏令时开始的那一天(纽约 2026-03-08,两点拨到三点)中午:零点是拨表之前的那个 /// (-05:00)。拿此刻的偏移(-04:00)往回推,会早一个小时 #[test] diff --git a/src-tauri/src/menubar/model.rs b/src-tauri/src/menubar/model.rs index a21eb2f5..a6e3004c 100644 --- a/src-tauri/src/menubar/model.rs +++ b/src-tauri/src/menubar/model.rs @@ -41,12 +41,16 @@ impl Gateway { #[derive(Debug, Clone, Default)] pub struct Snapshot { pub gateway: Gateway, - /// 网关此刻在哪儿监听,`127.0.0.1:18790` + /// 客户端该连的网关地址(主机:端口,`127.0.0.1:18790`),和「复制网关地址」复制的是同一个。 + /// 网关没在监听、问不到时是 None pub addr: Option, /// 监听设置没换成的原因,已经是给人看的一句话。旧地址还在服务 pub listen_error: Option, /// 今天的用量。**没问到是 None,不是 0** —— 0 是一个值 pub today: Option, + /// 今天每个本地小时的 token,「今日」那一块右边的小柱子照它画。和 `today` 出自同一问, + /// 同样是没问到就 None + pub hourly: Option, /// 报过额度的上游 pub quotas: Vec, /// 进行中的请求,开始得早的在前 @@ -63,7 +67,7 @@ pub struct Snapshot { pub update: Option, /// 连接列表,本机在最前。「连接」子菜单照它列 pub connections: Vec, - /// 连着远程时是那台服务器的名字。状态头写它,不写应用名 + /// 连着远程时是那台服务器的名字。跟在状态后面写:菜单里的数字、提醒都是那台的 pub remote: Option, pub now_ms: u64, } @@ -123,6 +127,65 @@ impl Today { } } +/// 一小时,毫秒 +pub const HOUR_MS: i64 = 3_600_000; + +/// 今天按本地小时分的 token:第 k 格是 `[零点 + k 小时, 零点 + k + 1 小时)`,和 core 分格的 +/// 算法一样(从起点数,一格一个桶宽) +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Hourly { + /// 今天的本地零点,Unix 毫秒 + pub from_ms: i64, + /// 每一格的 token,铺满今天:一般 24 格,夏令时切换的那天 23 或 25 格。还没到的那几格是 0 + pub tokens: Vec, +} + +/// 小柱子按多少 token 算满格,**至少这么多**:按当天最高的那一小时算满格的话,一天里只有 +/// 一个零星的请求时,那一小时就是满满一格,看着像用得很凶。两万差不多是一轮带着上下文的 +/// 编程助手对话 +pub const SPARK_MIN_PEAK: i64 = 20_000; + +/// 小柱子里的一根 +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Spark { + /// 这一小时没有用量,或者还没到:画一截淡淡的短线,不画成一根柱子 + Empty, + /// 过去的一小时。高度是满格的几分之几(`0..=1`) + Past(f64), + /// 此刻所在的这一小时,颜色最重 + Now(f64), +} + +/// 今天每小时的 token 画成几根小柱子。 +/// +/// **满格是当天最高的那一小时,但不低于 [`SPARK_MIN_PEAK`]。**哪一格是「此刻」按 `now_ms` +/// 现算:菜单开着时每秒重画一次,过了整点就挪到下一格,不用等下一次收数。时钟走在 core +/// 后面时(连着远程,那边快几分钟),「将来」的格子里也可能已经有数:那是真的数,照样画 +fn spark(h: &Hourly, now_ms: u64) -> Vec { + let peak = h + .tokens + .iter() + .copied() + .max() + .unwrap_or(0) + .max(SPARK_MIN_PEAK) as f64; + let now = (now_ms as i64 - h.from_ms).div_euclid(HOUR_MS); + h.tokens + .iter() + .enumerate() + .map(|(k, &t)| { + let height = (t as f64 / peak).min(1.0); + if t <= 0 { + Spark::Empty + } else if k as i64 == now { + Spark::Now(height) + } else { + Spark::Past(height) + } + }) + .collect() +} + #[derive(Debug, Clone, Default, PartialEq)] pub struct Quota { pub provider: String, @@ -243,7 +306,7 @@ pub struct Bar { pub tooltip: String, } -/// 状态头右上角那个点的颜色 +/// 状态前面那个点的颜色 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StateTone { Ok, @@ -255,7 +318,7 @@ pub enum StateTone { #[derive(Debug, Clone, PartialEq)] pub struct WindowRow { pub label: String, - /// None:重置时刻已经过了,手上的百分比不再是现状,不画条 + /// None:重置时刻已经过了,手上的百分比不再是现状,条是空的 pub percent: Option, pub reset: String, pub tone: Tone, @@ -264,12 +327,97 @@ pub struct WindowRow { pub detail: Option, } +impl WindowRow { + /// 右边那一段:「31% · 4 天后重置」。重置过的只有「已重置」,不知道几时重置的只有百分比 + pub fn right(&self) -> String { + match (self.percent, self.reset.is_empty()) { + (Some(p), false) => format!("{}% · {}", p.round() as i64, self.reset), + (Some(p), true) => format!("{}%", p.round() as i64), + (None, _) => self.reset.clone(), + } + } + + /// 只能写一行字的地方(别的平台的托盘):「chatgpt 每周 31% · 4 天后重置」,积分制套餐 + /// 的窗口在后面括上还剩多少 + pub fn line(&self, provider: &str) -> String { + let line = format!("{provider} {} {}", self.label, self.right()); + match &self.detail { + Some(d) => tr!(format!("{line}({d})"), format!("{line} ({d})")), + None => line, + } + } +} + +/// 「今日」那一块大字下面那一行里的一段 #[derive(Debug, Clone, PartialEq, Eq)] -pub struct StatCell { +pub struct Meta { + pub text: String, + /// 失败数:用红字 + pub failed: bool, +} + +/// 「今日」那一块:网关在运行时,菜单里打开主界面之后的第一样东西。上面一行左边「今日」、 +/// 右边网关的状态;中间是今天的 token,大字;下面一行是次数、失败、费用,和菜单栏上两行 +/// 「token 在上、费用在下」同一个次序;右边是今天每小时的 token +#[derive(Debug, Clone, PartialEq)] +pub struct Dash { + pub title: String, + /// 「运行中」,和它前面那个点的颜色 + pub state: String, + pub tone: StateTone, + /// 连着远程时那台服务器的名字,跟在状态后面。原来写在状态头上,状态头没了 + pub server: Option, + /// 最近一分钟的生成速度「42 token/秒」,知道时才有 + pub rate: Option, + /// 今天的 token,大字。**没问到是破折号**,不是 0 pub value: String, - pub label: String, - /// 标签旁的橙色小字(失败数、算不出钱的请求数) + pub unit: String, + /// 大字下面那一行,分段给:画的时候用「 · 」连起来,失败数是红的。没问到是空的 + pub meta: Vec, + /// 金额里缺着什么(「3 条无法计价」)。放得下才跟在那一行后面,放不下就只在悬停提示里 pub note: Option, + /// 今天每小时的 token。没问到不画 + pub spark: Option>, + /// 监听设置没换成的原因:这一块最后一行橙字 + pub warn: Option, + /// 悬停提示:大字是取整过的,这里写准数,和那一行可能放不下的小字 + pub tooltip: Option, + pub action: Action, +} + +impl Dash { + /// 右上角那一句:「运行中 · office-mac · 42 token/秒」 + pub fn state_line(&self) -> String { + [Some(&self.state), self.server.as_ref(), self.rate.as_ref()] + .into_iter() + .flatten() + .cloned() + .collect::>() + .join(" · ") + } + + /// 大字下面那一行:「128 次 · 失败 2 · $3.47」 + pub fn meta_line(&self) -> String { + self.meta + .iter() + .map(|m| m.text.as_str()) + .collect::>() + .join(" · ") + } + + /// 只能写一行字的地方(别的平台的托盘):「今日 1.2M token · 128 次 · $3.47」。那边没有 + /// 悬停提示,金额缺着什么括在后面 + pub fn line(&self) -> String { + let mut line = format!("{} {} {}", self.title, self.value, self.unit); + if !self.meta.is_empty() { + line.push_str(" · "); + line.push_str(&self.meta_line()); + } + match &self.note { + Some(n) => tr!(format!("{line}({n})"), format!("{line} ({n})")), + None => line, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -329,14 +477,16 @@ impl Item { #[derive(Debug, Clone, PartialEq)] pub enum Row { - Header { - title: String, + /// 网关不在运行:点和状态,下面一行橙字说为什么 + Status { state: String, tone: StateTone, - line2: String, - /// 第二行说的是一件要处理的事(监听没换成、安全模式) - line2_warn: bool, + /// 连着远程时那台服务器的名字 + server: Option, + reason: Option, }, + /// 网关在运行:「今日」那一块 + Today(Box), Separator, Section { title: String, @@ -348,13 +498,10 @@ pub enum Row { body: String, action: Action, }, + /// 一个额度窗口一行:上面是上游、窗口名、用了多少、几时重置,下面一根通栏的条 Quota { provider: String, - windows: Vec, - action: Action, - }, - Stats { - cells: Vec, + window: WindowRow, action: Action, }, Live { @@ -370,21 +517,20 @@ impl Row { /// 行的样子:种类加稳定的名字。**样子没变就就地改**,变了才重建菜单 pub fn shape(&self) -> String { match self { - Row::Header { .. } => "header".into(), + // 原因可能折成两行,行高跟着字走:字变了就重建(只在状态变了、换了语言时) + Row::Status { reason, .. } => format!("status:{}", reason.as_deref().unwrap_or("")), + Row::Today(d) => format!("today:{}", d.warn.as_deref().unwrap_or("")), Row::Separator => "sep".into(), Row::Section { title, .. } => format!("section:{title}"), Row::Notice { action, .. } => format!("notice:{action:?}"), - // 带小字的窗口高一截:哪几个窗口带着小字也是样子的一部分 + // 带小字的窗口高一截:带不带小字也是样子的一部分 Row::Quota { - provider, windows, .. + provider, window, .. } => format!( - "quota:{provider}:{}", - windows - .iter() - .map(|w| if w.detail.is_some() { '+' } else { '-' }) - .collect::() + "quota:{provider}:{}:{}", + window.label, + if window.detail.is_some() { '+' } else { '-' } ), - Row::Stats { cells, .. } => format!("stats:{}", cells.len()), Row::Live { action, .. } => format!("live:{action:?}"), Row::Item(i) => format!("item:{}:{}", i.id, i.submenu.len()), } @@ -533,40 +679,57 @@ fn live_count(n: usize) -> String { ) } +/// 菜单从上到下。**「打开主界面」永远是第一项**,每一种状态都是:用户点开菜单最常做的就是 +/// 这一件,它不该压在一堆数字下面。 +/// +/// 网关在运行时:打开主界面 ─ 提醒 ─ 今日、额度 ─ 进行中 ─ 策略组、复制地址和密钥 ─ 更新、 +/// 设置、连接 ─ 退出。不在运行时:打开主界面 ─ 状态和能做的事 ─ 更新、设置、连接 ─ 退出 fn rows(s: &Snapshot) -> Vec { - let mut out = vec![header(s), Row::Separator]; + let mut out = vec![ + Row::Item(Item::new( + "open", + "macwindow", + tr!("打开主界面", "Open ThinkWatch Lite"), + Action::OpenMain, + )), + Row::Separator, + ]; if !s.gateway.running() { - if s.gateway == Gateway::Unlinked { - out.push(Row::Item(Item::new( - "retry", - "arrow.clockwise", - tr!("立即重试", "Retry Now"), - Action::RetryConnection, - ))); - out.push(Row::Item(Item::new( - "why", - "info.circle", - tr!("查看原因…", "Show Details…"), - Action::OpenMain, - ))); - out.push(Row::Separator); - } else if !matches!(s.gateway, Gateway::Starting) { - out.push(Row::Item(Item::new( - "restart", - "arrow.clockwise", - tr!("重新启动网关", "Restart Gateway"), - Action::RestartGateway, - ))); - out.push(Row::Item(Item::new( - "why", - "info.circle", - tr!("查看原因…", "Show Details…"), - Action::OpenMain, - ))); - out.push(Row::Separator); + out.push(status(s)); + // 地址、密钥、策略组都要问运行着的网关:这里只有让它重新跑起来的那几样 + match s.gateway { + Gateway::Starting => {} + Gateway::Unlinked => { + out.push(Row::Item(Item::new( + "retry", + "arrow.clockwise", + tr!("立即重试", "Retry Now"), + Action::RetryConnection, + ))); + out.push(Row::Item(Item::new( + "why", + "info.circle", + tr!("查看原因…", "Show Details…"), + Action::OpenMain, + ))); + } + _ => { + out.push(Row::Item(Item::new( + "restart", + "arrow.clockwise", + tr!("重新启动网关", "Restart Gateway"), + Action::RestartGateway, + ))); + out.push(Row::Item(Item::new( + "why", + "info.circle", + tr!("查看原因…", "Show Details…"), + Action::OpenMain, + ))); + } } - // 地址、密钥、策略组都要问运行着的网关:动作区只剩「打开主界面」 - app_items(s, Vec::new(), &mut out); + out.push(Row::Separator); + app_items(s, &mut out); return out; } @@ -594,59 +757,33 @@ fn rows(s: &Snapshot) -> Vec { out.push(Row::Separator); } - if let Some(t) = &s.today { - out.push(section(tr!("今日", "Today"), None)); - out.push(Row::Stats { - cells: vec![ - StatCell { - value: grouped(t.requests), - label: tr!("请求", "Requests").to_string(), - note: (t.failed > 0) - .then(|| tr!(format!("失败 {}", t.failed), format!("{} failed", t.failed))), - }, - StatCell { - value: tokens_short(t.tokens), - label: tr!("token", "Tokens").to_string(), - note: None, - }, - StatCell { - value: t.cost(cost_long), - label: tr!("费用", "Cost").to_string(), - note: t.cost_note(), - }, - ], - action: Action::Open("dashboard"), - }); - out.push(Row::Separator); - } - - let quotas: Vec<&Quota> = s.quotas.iter().filter(|q| !q.windows.is_empty()).collect(); - if !quotas.is_empty() { - out.push(section(tr!("额度", "Quota"), None)); - for q in quotas { + out.push(Row::Today(Box::new(dash(s)))); + // **报了几个窗口就画几行**:ChatGPT 有时只报每周的那一个,不假定总有 5 小时的 + for q in s.quotas.iter().filter(|q| !q.windows.is_empty()) { + for w in &q.windows { out.push(Row::Quota { provider: q.provider.clone(), - windows: q.windows.iter().map(|w| window_row(w, s.now_ms)).collect(), + window: window_row(w, s.now_ms), action: Action::Open("upstreams"), }); - let used_up = q - .windows - .iter() - .any(|w| current(w, s.now_ms) && exhausted(w)); - if let Some(n) = q.reset_credits.filter(|n| used_up && *n > 0) { - out.push(Row::Item( - Item::new( - &format!("resets:{}", q.provider), - "arrow.counterclockwise.circle", - tr!("额度重置卡…", "Quota Reset Credits…"), - Action::Open("upstreams"), - ) - .right(tr!(format!("可用 {n} 张"), format!("{n} available"))), - )); - } } - out.push(Row::Separator); + let used_up = q + .windows + .iter() + .any(|w| current(w, s.now_ms) && exhausted(w)); + if let Some(n) = q.reset_credits.filter(|n| used_up && *n > 0) { + out.push(Row::Item( + Item::new( + &format!("resets:{}", q.provider), + "arrow.counterclockwise.circle", + tr!("额度重置卡…", "Quota Reset Credits…"), + Action::Open("upstreams"), + ) + .right(tr!(format!("可用 {n} 张"), format!("{n} available"))), + )); + } } + out.push(Row::Separator); if !s.live.is_empty() { out.push(section( @@ -681,108 +818,163 @@ fn rows(s: &Snapshot) -> Vec { out.push(Row::Separator); } - let mut actions: Vec = s - .groups - .iter() - .map(|g| { - let mut item = Item::new( - &format!("group:{}", g.name), - "arrow.left.arrow.right", - g.name.clone(), - Action::OpenMain, - ); - item.action = None; - item.right = g.selected.clone(); - item.submenu = g - .members - .iter() - .map(|m| SubItem { - title: m.clone(), - checked: g.selected.as_deref() == Some(m), - action: Action::SelectGroup { - group: g.name.clone(), - provider: m.clone(), - }, - sep_before: false, - }) - .collect(); - Row::Item(item) - }) - .collect(); + for g in &s.groups { + let mut item = Item::new( + &format!("group:{}", g.name), + "arrow.left.arrow.right", + g.name.clone(), + Action::OpenMain, + ); + item.action = None; + item.right = g.selected.clone(); + item.submenu = g + .members + .iter() + .map(|m| SubItem { + title: m.clone(), + checked: g.selected.as_deref() == Some(m), + action: Action::SelectGroup { + group: g.name.clone(), + provider: m.clone(), + }, + sep_before: false, + }) + .collect(); + out.push(Row::Item(item)); + } + // 右边写着要复制的那个地址:点之前就看得到 let copy = Item::new( "copy-address", "doc.on.doc", tr!("复制网关地址", "Copy Gateway Address"), Action::CopyAddress, ); - actions.push(Row::Item(if s.addr.is_some() { - copy - } else { - copy.disabled() + out.push(Row::Item(match &s.addr { + Some(addr) => copy.right(addr.clone()), + None => copy.disabled(), })); - actions.push(Row::Item(Item::new( + out.push(Row::Item(Item::new( "copy-key", "key", tr!("复制默认密钥", "Copy Default Key"), Action::CopyKey, ))); - app_items(s, actions, &mut out); + out.push(Row::Separator); + app_items(s, &mut out); out } -fn header(s: &Snapshot) -> Row { - let (tone, line2, line2_warn) = match &s.gateway { - Gateway::Running => match &s.listen_error { - Some(why) => (StateTone::Ok, why.clone(), true), - None => { - let addr = s.addr.clone().unwrap_or_else(|| "—".to_string()); - let line = match s.rate { - Some(r) => tr!(format!("{addr} · {r} token/秒"), format!("{addr} · {r} tokens/s")), - None => addr, - }; - (StateTone::Ok, line, false) - } - }, - Gateway::Starting => (StateTone::Busy, s.addr.clone().unwrap_or_default(), false), +/// 网关不在运行时的那一块:点、状态(连着远程时跟着服务器的名字),下面一行说为什么 +fn status(s: &Snapshot) -> Row { + let (tone, reason) = match &s.gateway { + Gateway::Running | Gateway::Starting => (StateTone::Busy, None), Gateway::SafeMode => ( StateTone::Warn, - tr!( + Some(tr!( "已连续启动失败,当前只有配置和历史可用。", "Startup failed several times in a row. Only configuration and history are available." - ) - .to_string(), - true, + )), ), Gateway::Failed => ( StateTone::Bad, - tr!( + Some(tr!( "core 程序未能运行,转发已停止。", "The core program could not run, so forwarding has stopped." - ) - .to_string(), - true, + )), ), Gateway::Stopped => ( StateTone::Bad, - tr!("转发已停止。", "Forwarding has stopped.").to_string(), - true, - ), - Gateway::Unlinked => ( - StateTone::Bad, - tr!("正在重新连接。", "Reconnecting.").to_string(), - true, + Some(tr!("转发已停止。", "Forwarding has stopped.")), ), + Gateway::Unlinked => (StateTone::Bad, Some(tr!("正在重新连接。", "Reconnecting."))), }; - Row::Header { - // 连着远程时写服务器的名字:菜单里的数字、提醒都是那台的 - title: s - .remote - .clone() - .unwrap_or_else(|| "ThinkWatch Lite".to_string()), + Row::Status { state: state_text(&s.gateway).to_string(), tone, - line2, - line2_warn, + server: s.remote.clone(), + reason: reason.map(str::to_string), + } +} + +/// 「今日」那一块。**没问到时大字是破折号、下面那一行空着**:不写 0,0 是一个值 +fn dash(s: &Snapshot) -> Dash { + let t = s.today.as_ref(); + let mut meta = Vec::new(); + if let Some(t) = t { + let n = grouped(t.requests); + meta.push(Meta { + text: tr!( + format!("{n} 次"), + if t.requests == 1 { + "1 request".to_string() + } else { + format!("{n} requests") + } + ), + failed: false, + }); + if t.failed > 0 { + meta.push(Meta { + text: tr!(format!("失败 {}", t.failed), format!("{} failed", t.failed)), + failed: true, + }); + } + meta.push(Meta { + text: t.cost(cost_long), + failed: false, + }); + } + Dash { + title: tr!("今日", "Today").to_string(), + state: state_text(&s.gateway).to_string(), + tone: StateTone::Ok, + server: s.remote.clone(), + rate: s + .rate + .map(|r| tr!(format!("{r} token/秒"), format!("{r} tokens/s"))), + value: t.map_or_else(|| "—".to_string(), |t| tokens_short(t.tokens)), + unit: tr!( + "token", + if t.is_some_and(|t| t.tokens == 1) { + "token" + } else { + "tokens" + } + ) + .to_string(), + meta, + note: t.and_then(Today::cost_note), + spark: t.and(s.hourly.as_ref()).map(|h| spark(h, s.now_ms)), + warn: s.listen_error.clone(), + tooltip: t.map(|t| { + let tokens = grouped(t.tokens); + let requests = grouped(t.requests); + let cost = t.cost(cost_long); + let mut tip = tr!( + format!("今日 {tokens} token,{requests} 次请求"), + format!( + "{tokens} {} today, {requests} {}", + if t.tokens == 1 { "token" } else { "tokens" }, + if t.requests == 1 { + "request" + } else { + "requests" + } + ) + ); + if t.failed > 0 { + tip.push_str(&tr!( + format!(",失败 {} 次", t.failed), + format!(", {} failed", t.failed) + )); + } + tip.push_str(&tr!(format!(",费用 {cost}"), format!(", {cost} cost"))); + if let Some(note) = t.cost_note() { + tip.push_str(&tr!(format!("({note})"), format!(" ({note})"))); + } + tip + }), + action: Action::Open("dashboard"), } } @@ -793,17 +985,20 @@ fn section(title: &str, right: Option) -> Row { } } -/// 菜单的后半截,每种状态都是这个样子:**「打开主界面」是动作区的第一项**,后面跟着 -/// 这个状态下能做的事(`actions`);隔一条线是设置、连接、更新,最后隔一条线退出 -fn app_items(s: &Snapshot, actions: Vec, out: &mut Vec) { - out.push(Row::Item(Item::new( - "open", - "macwindow", - tr!("打开主界面", "Open ThinkWatch Lite"), - Action::OpenMain, - ))); - out.extend(actions); - out.push(Row::Separator); +/// 菜单的后半截,每种状态都是这个样子:更新、设置、连接,隔一条线退出。**有新版本时「安装 +/// 新版本」在这一组最前**,带强调色;没有时「检查更新」排在这一组最后 +fn app_items(s: &Snapshot, out: &mut Vec) { + if let Some(v) = &s.update { + out.push(Row::Item(Item { + accent: true, + ..Item::new( + "update", + "arrow.down.circle", + tr!(format!("安装新版本 {v}…"), format!("Install Version {v}…")), + Action::InstallUpdate, + ) + })); + } out.push(Row::Item( Item::new( "settings", @@ -814,23 +1009,14 @@ fn app_items(s: &Snapshot, actions: Vec, out: &mut Vec) { .key(","), )); out.push(Row::Item(connections(s))); - out.push(Row::Item(match &s.update { - Some(v) => Item { - accent: true, - ..Item::new( - "update", - "arrow.down.circle", - tr!(format!("安装新版本 {v}…"), format!("Install Version {v}…")), - Action::InstallUpdate, - ) - }, - None => Item::new( + if s.update.is_none() { + out.push(Row::Item(Item::new( "update", "arrow.down.circle", tr!("检查更新…", "Check for Updates…"), Action::CheckUpdates, - ), - })); + ))); + } out.push(Row::Separator); out.push(Row::Item( Item::new( @@ -1078,811 +1264,4 @@ pub fn app_label(hint: &str) -> &str { } #[cfg(test)] -mod tests { - use super::*; - use crate::i18n::{Lang, with_lang}; - - const NOW: u64 = 1_800_000_000_000; - - fn running() -> Snapshot { - Snapshot { - gateway: Gateway::Running, - addr: Some("127.0.0.1:18790".into()), - today: Some(Today { - requests: 186, - failed: 0, - tokens: 3_100_000, - cost_micros: 41_200_000, - ..Default::default() - }), - notices_on: true, - now_ms: NOW, - ..Default::default() - } - } - - fn window(window: &str, used: f64, reset_in_ms: u64) -> Window { - Window { - window: window.into(), - used_percent: used, - resets_at_ms: Some(NOW + reset_in_ms), - status: None, - credits: None, - } - } - - fn items(rows: &[Row]) -> Vec<&Item> { - rows.iter() - .filter_map(|r| match r { - Row::Item(i) => Some(i), - _ => None, - }) - .collect() - } - - fn item<'a>(rows: &'a [Row], id: &str) -> Option<&'a Item> { - items(rows).into_iter().find(|i| i.id == id) - } - - /// 菜单从上到下:状态头、分隔线(`---`)、节的标题(`[今日]`)、各行,菜单项写 id - fn outline(rows: &[Row]) -> Vec { - rows.iter() - .map(|r| match r { - Row::Header { .. } => "header".to_string(), - Row::Separator => "---".to_string(), - Row::Section { title, .. } => format!("[{title}]"), - Row::Notice { .. } => "notice".to_string(), - Row::Quota { provider, .. } => format!("quota:{provider}"), - Row::Stats { .. } => "stats".to_string(), - Row::Live { .. } => "live".to_string(), - Row::Item(i) => i.id.clone(), - }) - .collect() - } - - #[test] - fn a_full_menu_has_today_above_quota_and_open_first_among_the_actions() { - let mut s = running(); - s.notices = vec![NoticeLine { - key: "k".into(), - level: Level::Warning, - title: "t".into(), - body: "b".into(), - }]; - s.quotas = vec![Quota { - provider: "chatgpt".into(), - windows: vec![window("5h", 42.0, 3_600_000)], - reset_credits: None, - }]; - s.live = vec![Live { - id: 1, - started_ms: NOW - 1_000, - ..Default::default() - }]; - s.groups = vec![Group { - name: "主力".into(), - members: vec!["chatgpt".into(), "openai".into()], - selected: Some("chatgpt".into()), - }]; - assert_eq!( - outline(&rows(&s)), - [ - "header", - "---", - "[提醒]", - "notice", - "---", - "[今日]", - "stats", - "---", - "[额度]", - "quota:chatgpt", - "---", - "[进行中]", - "live", - "---", - "open", - "group:主力", - "copy-address", - "copy-key", - "---", - "settings", - "connections", - "update", - "---", - "quit", - ] - ); - // 各节都空着:动作区紧跟在状态头后面 - s.notices.clear(); - s.today = None; - s.quotas.clear(); - s.live.clear(); - s.groups.clear(); - assert_eq!( - outline(&rows(&s)), - [ - "header", - "---", - "open", - "copy-address", - "copy-key", - "---", - "settings", - "connections", - "update", - "---", - "quit", - ] - ); - } - - /// 网关不在运行的每一种状态:「打开主界面」照样在,照样是动作区的第一项;设置、 - /// 连接、更新的顺序和运行时一样。今天的用量不代表现在,不列 - #[test] - fn every_state_keeps_open_first_and_the_app_items_in_one_order() { - let fix = ["restart", "why", "---"]; - for (gateway, head) in [ - (Gateway::Starting, &[][..]), - (Gateway::SafeMode, &fix[..]), - (Gateway::Failed, &fix[..]), - (Gateway::Stopped, &fix[..]), - (Gateway::Unlinked, &["retry", "why", "---"][..]), - ] { - let s = Snapshot { - gateway: gateway.clone(), - ..running() - }; - let want: Vec<&str> = ["header", "---"] - .iter() - .chain(head) - .chain(&[ - "open", - "---", - "settings", - "connections", - "update", - "---", - "quit", - ]) - .copied() - .collect(); - assert_eq!(outline(&rows(&s)), want, "{gateway:?}"); - } - } - - #[test] - fn the_bar_shows_todays_tokens_over_todays_cost() { - let (bar, _) = build(&running(), Style::Full); - assert_eq!(bar.numbers, Some(("3.1M".into(), "$41.20".into()))); - assert!(!bar.dim && !bar.alert && !bar.dot); - } - - /// **估算不能冒充实测,算不出钱的也不能当成零**:和界面上同一套记号,菜单栏上 - /// 那个数是下限的写「≥」,含估算的带「~」,缺着什么写在费用那一格下面 - #[test] - fn todays_cost_says_when_it_is_estimated_or_only_a_lower_bound() { - use crate::i18n::{Lang, with_lang}; - let cost = |t: Today| { - let mut s = running(); - s.today = Some(t); - let (bar, rows) = build(&s, Style::Full); - let cell = rows - .iter() - .find_map(|r| match r { - Row::Stats { cells, .. } => cells.get(2).cloned(), - _ => None, - }) - .unwrap(); - (bar.numbers.unwrap().1, cell.value, cell.note, bar.tooltip) - }; - let base = Today { - requests: 10, - tokens: 1_000, - cost_micros: 2_500_000, - ..Default::default() - }; - with_lang(Lang::En, || { - let (short, long, note, _) = cost(base.clone()); - assert_eq!( - (short.as_str(), long.as_str(), note), - ("$2.50", "$2.50", None) - ); - - let (short, long, note, tip) = cost(Today { - estimated_micros: 500_000, - ..base.clone() - }); - assert_eq!( - (short.as_str(), long.as_str(), note), - ("~$2.50", "~$2.50", None) - ); - assert!(tip.contains("~$2.50 cost"), "{tip}"); - - let (short, _, note, _) = cost(Today { - unpriced: 3, - ..base.clone() - }); - assert_eq!(short, "≥$2.50"); - assert_eq!(note.as_deref(), Some("3 unpriced")); - - // 一条都没算出钱:不是「今天花了 $0」 - let (short, _, note, _) = cost(Today { - cost_micros: 0, - no_usage: 2, - ..base.clone() - }); - assert_eq!(short, "≥$0.00"); - assert_eq!(note.as_deref(), Some("2 with no usage")); - }); - } - - #[test] - fn an_unknown_day_is_two_dashes_and_a_quiet_day_is_zero() { - // 0 是一个值,破折号不是:问到了、今天还没有请求,就写 0 - let mut s = running(); - s.today = None; - assert_eq!(build(&s, Style::Full).0.numbers, None); - s.today = Some(Today::default()); - assert_eq!( - build(&s, Style::Full).0.numbers, - Some(("0".into(), "$0.00".into())) - ); - } - - #[test] - fn numbers_are_written_the_way_the_overview_writes_them() { - for (n, want) in [ - (0, "0"), - (845, "845"), - (9_800, "9.8k"), - (123_400, "123k"), - (3_100_000, "3.1M"), - (1_240_000_000, "1.2B"), - ] { - assert_eq!(tokens_short(n), want, "{n}"); - } - for (micros, want) in [ - (0, "$0.00"), - (3_420_000, "$3.42"), - (99_990_000, "$99.99"), - (123_400_000, "$123"), - (1_234_000_000, "$1.2k"), - (12_345_000_000, "$12k"), - ] { - assert_eq!(cost_short(micros), want, "{micros}"); - } - assert_eq!(cost_long(1_234_567_000_000), "$1,234,567"); - assert_eq!(grouped(1234), "1,234"); - assert_eq!(elapsed(102_000), "1:42"); - assert_eq!(elapsed(3_800_000), "1:03:20"); - } - - /// **取整之后再定单位。**先定单位再取整,就会写出「10.0k」「1000k」「$10.0k」「60 分钟」 - /// 这样多出一位、或者该进位却没进的数 - #[test] - fn a_number_that_rounds_up_to_the_next_unit_is_written_in_that_unit() { - for (n, want) in [ - (9_949, "9.9k"), - (9_960, "10k"), - (9_999, "10k"), - (999_499, "999k"), - (999_500, "1.0M"), - (999_999, "1.0M"), - (999_949_999, "999.9M"), - (999_950_001, "1.0B"), - ] { - assert_eq!(tokens_short(n), want, "{n}"); - } - for (micros, want) in [ - (99_994_999, "$99.99"), - (99_996_000, "$100"), - (999_499_999, "$999"), - (999_600_000, "$1.0k"), - (9_949_000_000, "$9.9k"), - (9_960_000_000, "$10k"), - ] { - assert_eq!(cost_short(micros), want, "{micros}"); - } - assert_eq!(cost_long(999_994_000), "$999.99"); - assert_eq!(cost_long(999_996_000), "$1,000"); - // 59.5 分钟往上是「1 小时」,23.5 小时往上是「1 天」,和上游页一样 - assert_eq!(resets_in(3_569), "59 分钟后重置"); - assert_eq!(resets_in(3_570), "1 小时后重置"); - assert_eq!(resets_in(84_599), "23 小时后重置"); - assert_eq!(resets_in(84_600), "1 天后重置"); - with_lang(Lang::En, || { - assert_eq!(resets_in(3_570), "Resets in 1 h"); - assert_eq!(resets_in(84_600), "Resets in 1 day"); - }); - } - - #[test] - fn a_tight_quota_turns_the_numbers_orange_and_a_used_up_one_red() { - let mut s = running(); - s.quotas = vec![Quota { - provider: "chatgpt".into(), - windows: vec![ - window("5h", 42.0, 3_600_000), - window("weekly", 18.0, 86_400_000), - ], - reset_credits: None, - }]; - assert_eq!(build(&s, Style::Full).0.tone, Tone::Normal); - s.quotas[0].windows[0].used_percent = 91.0; - assert_eq!(build(&s, Style::Full).0.tone, Tone::Warn); - // 上游说快到了的,不管用了多少都算 - s.quotas[0].windows[0].used_percent = 50.0; - s.quotas[0].windows[0].status = Some("allowed_warning".into()); - assert_eq!(build(&s, Style::Full).0.tone, Tone::Warn); - s.quotas[0].windows[0].status = Some("rejected".into()); - assert_eq!(build(&s, Style::Full).0.tone, Tone::Full); - } - - /// 颜色看**最要紧**的那个窗口,不是用得最多的那个:每周的用了 95%,5 小时的上游已经 - /// 拒绝了,菜单栏是红的;用得最多的那个没事、另一家上游说快到了,是橙的。悬停提示说的 - /// 也是定了颜色的那个窗口 - #[test] - fn the_bar_takes_its_color_from_the_most_severe_window_not_the_fullest() { - let mut s = running(); - s.quotas = vec![Quota { - provider: "chatgpt".into(), - windows: vec![ - window("weekly", 95.0, 86_400_000), - Window { - status: Some("rejected".into()), - ..window("5h", 40.0, 3_600_000) - }, - ], - reset_credits: None, - }]; - let (bar, _) = build(&s, Style::Full); - assert_eq!(bar.tone, Tone::Full); - assert!( - bar.tooltip.ends_with("chatgpt 5 小时额度已用 40%"), - "{}", - bar.tooltip - ); - - s.quotas = vec![ - Quota { - provider: "chatgpt".into(), - windows: vec![window("5h", 60.0, 3_600_000)], - reset_credits: None, - }, - Quota { - provider: "glm".into(), - windows: vec![Window { - status: Some("allowed_warning".into()), - ..window("weekly", 30.0, 86_400_000) - }], - reset_credits: None, - }, - ]; - let (bar, _) = build(&s, Style::Full); - assert_eq!(bar.tone, Tone::Warn); - assert!( - bar.tooltip.ends_with("glm 每周额度已用 30%"), - "{}", - bar.tooltip - ); - } - - #[test] - fn a_window_past_its_reset_is_not_the_current_state() { - let mut s = running(); - s.quotas = vec![Quota { - provider: "chatgpt".into(), - windows: vec![Window { - window: "5h".into(), - used_percent: 100.0, - resets_at_ms: Some(NOW - 1), - status: Some("rejected".into()), - credits: None, - }], - reset_credits: Some(2), - }]; - let (bar, rows) = build(&s, Style::Full); - assert_eq!(bar.tone, Tone::Normal, "已经重置过的窗口还在让菜单栏变红"); - let Some(Row::Quota { windows, .. }) = rows.iter().find(|r| matches!(r, Row::Quota { .. })) - else { - panic!("额度那一块不见了"); - }; - assert_eq!(windows[0].percent, None, "重置过的窗口还在画条"); - assert_eq!(windows[0].reset, "已重置"); - assert!( - item(&rows, "resets:chatgpt").is_none(), - "额度没用完却给了重置卡" - ); - } - - #[test] - fn a_used_up_quota_offers_the_reset_credits_only_when_there_are_some() { - let mut s = running(); - s.quotas = vec![Quota { - provider: "chatgpt".into(), - windows: vec![Window { - status: Some("rejected".into()), - ..window("5h", 100.0, 42 * 60_000) - }], - reset_credits: Some(2), - }]; - let (_, rows) = build(&s, Style::Full); - let credits = item(&rows, "resets:chatgpt").expect("用完了、也有卡,却没给入口"); - assert_eq!(credits.right.as_deref(), Some("可用 2 张")); - assert_eq!( - credits.action, - Some(Action::Open("upstreams")), - "菜单里不直接用卡" - ); - s.quotas[0].reset_credits = Some(0); - assert!(item(&build(&s, Style::Full).1, "resets:chatgpt").is_none()); - } - - #[test] - fn quota_bars_turn_orange_at_eighty_percent() { - let mut s = running(); - s.quotas = vec![Quota { - provider: "chatgpt".into(), - windows: vec![ - window("5h", 79.0, 3_600_000), - window("weekly", 80.0, 86_400_000), - ], - reset_credits: None, - }]; - let rows = rows(&s); - let Some(Row::Quota { windows, .. }) = rows.iter().find(|r| matches!(r, Row::Quota { .. })) - else { - panic!(); - }; - assert_eq!( - (windows[0].tone, windows[1].tone), - (Tone::Normal, Tone::Warn) - ); - assert_eq!(windows[0].label, "5 小时"); - assert_eq!(windows[0].reset, "1 小时后重置"); - assert_eq!(windows[1].label, "每周"); - } - - /// `5h`、`weekly` 有自己的叫法;core 按长度起的名字(ChatGPT 账号的 `30d`)按长度说; - /// 认不出来的原样显示 - #[test] - fn windows_named_by_their_length_are_said_in_words() { - let names = [ - "5h", "weekly", "30d", "1d", "7d", "3h", "1h", "45m", "1m", "monthly", "d", "+5h", - "5 h", "", - ]; - assert_eq!( - names.map(window_label), - [ - "5 小时", - "每周", - "30 天", - "1 天", - "7 天", - "3 小时", - "1 小时", - "45 分钟", - "1 分钟", - "monthly", - "d", - "+5h", - "5 h", - "", - ] - ); - with_lang(Lang::En, || { - assert_eq!( - names.map(window_label), - [ - "5h", - "Weekly", - "30 days", - "1 day", - "7 days", - "3 hours", - "1 hour", - "45 minutes", - "1 minute", - "monthly", - "d", - "+5h", - "5 h", - "", - ] - ); - }); - } - - #[test] - fn requests_in_progress_put_a_dot_on_the_mark_and_list_at_most_five() { - let mut s = running(); - s.live = (1..=7) - .map(|i| Live { - id: i, - app: (i == 1).then(|| "codex".to_string()), - key: "default".into(), - model: "gpt-5-codex".into(), - started_ms: NOW - 102_000, - }) - .collect(); - let (bar, rows) = build(&s, Style::Full); - assert!(bar.dot); - let live: Vec<_> = rows - .iter() - .filter_map(|r| match r { - Row::Live { app, elapsed, .. } => Some((app.as_str(), elapsed.as_str())), - _ => None, - }) - .collect(); - assert_eq!(live.len(), MAX_LIVE); - assert_eq!(live[0], ("Codex", "1:42")); - // 认不出应用的用密钥名 - assert_eq!(live[1].0, "default"); - assert_eq!(item(&rows, "live-more").unwrap().title, "另有 2 个"); - } - - #[test] - fn notices_come_first_and_stop_at_three() { - let mut s = running(); - s.notices = (0..4) - .map(|i| NoticeLine { - key: format!("k{i}"), - level: Level::Warning, - title: format!("t{i}"), - body: "b".into(), - }) - .collect(); - let rows = rows(&s); - // 状态头、分隔线之后就是提醒 - assert!(matches!(&rows[2], Row::Section { title, .. } if title == "提醒")); - let shown = rows - .iter() - .filter(|r| matches!(r, Row::Notice { .. })) - .count(); - assert_eq!(shown, MAX_NOTICES); - assert_eq!(item(&rows, "all-notices").unwrap().title, "全部提醒(4)…"); - // 提醒关掉了,这一节就不出现 - s.notices_on = false; - assert!( - !super::rows(&s) - .iter() - .any(|r| matches!(r, Row::Notice { .. })) - ); - } - - #[test] - fn a_gateway_that_is_not_running_says_why_and_offers_a_restart() { - let mut s = running(); - s.gateway = Gateway::Failed; - s.live = vec![Live::default()]; - let (bar, rows) = build(&s, Style::Full); - assert_eq!(bar.numbers, None, "不在运行时还显示一个凝固的旧数字"); - assert!(bar.dim && bar.alert && !bar.dot); - assert!( - matches!(&rows[0], Row::Header { state, tone: StateTone::Bad, .. } if state == "无法启动") - ); - assert_eq!( - item(&rows, "restart").unwrap().action, - Some(Action::RestartGateway) - ); - assert!( - !rows - .iter() - .any(|r| matches!(r, Row::Stats { .. } | Row::Live { .. })) - ); - // 正在启动时没有什么可重启的,也不画红色角标 - s.gateway = Gateway::Starting; - let (bar, rows) = build(&s, Style::Full); - assert!(bar.dim && !bar.alert); - assert!(item(&rows, "restart").is_none()); - } - - #[test] - fn a_listen_setting_that_did_not_take_replaces_the_address_line() { - let mut s = running(); - s.rate = Some(52); - let Row::Header { - line2, line2_warn, .. - } = &rows(&s)[0] - else { - panic!() - }; - assert_eq!( - (line2.as_str(), *line2_warn), - ("127.0.0.1:18790 · 52 token/秒", false) - ); - s.listen_error = Some("端口 18790 已被占用。".into()); - let Row::Header { - line2, line2_warn, .. - } = &rows(&s)[0] - else { - panic!() - }; - assert_eq!( - (line2.as_str(), *line2_warn), - ("端口 18790 已被占用。", true) - ); - } - - #[test] - fn a_new_version_replaces_the_check() { - let mut s = running(); - assert_eq!( - item(&rows(&s), "update").unwrap().action, - Some(Action::CheckUpdates) - ); - s.update = Some("2026.9.8".into()); - let rows = rows(&s); - let update = item(&rows, "update").unwrap(); - assert_eq!(update.title, "安装新版本 2026.9.8…"); - assert_eq!(update.action, Some(Action::InstallUpdate)); - assert!(update.accent); - } - - #[test] - fn a_select_group_is_a_submenu_with_the_current_member_checked() { - let mut s = running(); - s.groups = vec![Group { - name: "主力".into(), - members: vec!["chatgpt".into(), "openai".into()], - selected: Some("chatgpt".into()), - }]; - let rows = rows(&s); - let g = item(&rows, "group:主力").unwrap(); - assert_eq!(g.right.as_deref(), Some("chatgpt")); - assert_eq!( - g.submenu.iter().map(|m| m.checked).collect::>(), - [true, false] - ); - assert_eq!( - g.submenu[1].action, - Action::SelectGroup { - group: "主力".into(), - provider: "openai".into() - } - ); - } - - #[test] - fn the_menu_follows_the_interface_language() { - with_lang(Lang::En, || { - let mut s = running(); - s.quotas = vec![Quota { - provider: "chatgpt".into(), - windows: vec![window("weekly", 18.0, 4 * 86_400_000)], - reset_credits: None, - }]; - let rows = rows(&s); - assert!(matches!(&rows[0], Row::Header { state, .. } if state == "Running")); - let Some(Row::Quota { windows, .. }) = - rows.iter().find(|r| matches!(r, Row::Quota { .. })) - else { - panic!() - }; - assert_eq!( - (windows[0].label.as_str(), windows[0].reset.as_str()), - ("Weekly", "Resets in 4 days") - ); - assert_eq!(item(&rows, "quit").unwrap().title, "Quit ThinkWatch Lite…"); - let (bar, _) = build(&s, Style::Full); - assert!( - bar.tooltip - .starts_with("ThinkWatch Lite, Running, 3.1M tokens today") - ); - }); - } - - #[test] - fn the_tooltip_says_the_whole_state_in_one_sentence() { - let mut s = running(); - s.quotas = vec![Quota { - provider: "chatgpt".into(), - windows: vec![window("5h", 42.0, 3 * 3_600_000)], - reset_credits: None, - }]; - assert_eq!( - build(&s, Style::Full).0.tooltip, - "ThinkWatch Lite,运行中,今日 3.1M token,费用 $41.20,chatgpt 5 小时额度已用 42%" - ); - } - - #[test] - fn a_row_keeps_its_shape_while_only_its_text_changes() { - // 菜单开着时,秒数、倒计时每秒在变 —— 那不该让菜单整个重建 - let mut s = running(); - s.live = vec![Live { - id: 7, - started_ms: NOW - 1_000, - ..Default::default() - }]; - let a: Vec = rows(&s).iter().map(Row::shape).collect(); - s.now_ms += 5_000; - let b: Vec = rows(&s).iter().map(Row::shape).collect(); - assert_eq!(a, b); - } - - /// GLM Coding Plan 积分制套餐:5 小时和每周两个窗口,各带总额、已用、剩余 - fn glm() -> Quota { - let credits = |total: f64, used: f64, remaining: f64| { - Some(QuotaCredits { - total, - used, - remaining, - }) - }; - Quota { - provider: "glm".into(), - windows: vec![ - Window { - credits: credits(2_000.0, 23.0, 1_976.0), - ..window("5h", 1.0, 3_600_000) - }, - Window { - credits: credits(10_000.0, 268.0, 9_731.0), - ..window("weekly", 2.0, 3 * 86_400_000) - }, - ], - reset_credits: None, - } - } - - fn quota_rows(s: &Snapshot) -> Vec { - rows(s) - .into_iter() - .find_map(|r| match r { - Row::Quota { windows, .. } => Some(windows), - _ => None, - }) - .expect("额度那一块不见了") - } - - #[test] - fn a_credit_plan_says_what_is_left_under_each_bar() { - let mut s = running(); - s.quotas = vec![glm()]; - let w = quota_rows(&s); - assert_eq!(w[0].label, "5 小时"); - // **剩余照上游说的写**:2000 − 23 是 1977,上游说的是 1976 - assert_eq!(w[0].detail.as_deref(), Some("剩余 1,976 / 2,000 积分")); - assert_eq!(w[1].detail.as_deref(), Some("剩余 9,731 / 10,000 积分")); - with_lang(Lang::En, || { - assert_eq!( - quota_rows(&s)[0].detail.as_deref(), - Some("1,976 / 2,000 credits left") - ); - }); - // 按百分比报的窗口没有这一行 - s.quotas[0].windows[0].credits = None; - assert_eq!(quota_rows(&s)[0].detail, None); - } - - #[test] - fn what_was_left_before_a_reset_is_not_shown_after_it() { - let mut s = running(); - s.quotas = vec![glm()]; - s.now_ms = NOW + 3_600_000; - let w = quota_rows(&s); - assert_eq!((w[0].percent, w[0].detail.as_deref()), (None, None)); - assert!(w[1].detail.is_some(), "没到重置时刻的窗口照常写"); - } - - #[test] - fn a_line_under_a_bar_changes_the_shape_of_the_row() { - // 带小字的窗口高一截:菜单开着时多了、少了这一行,要重建,不能就地改 - let mut s = running(); - s.quotas = vec![glm()]; - let shape = |s: &Snapshot| -> Vec { rows(s).iter().map(Row::shape).collect() }; - let a = shape(&s); - assert!(a.contains(&"quota:glm:++".to_string()), "{a:?}"); - s.quotas[0].windows[1].credits = None; - assert!(shape(&s).contains(&"quota:glm:+-".to_string())); - // 只是数变了:样子不变 - s.quotas[0].windows[0].credits = Some(QuotaCredits { - total: 2_000.0, - used: 500.0, - remaining: 1_500.0, - }); - assert!(shape(&s).contains(&"quota:glm:+-".to_string())); - } -} +mod tests; diff --git a/src-tauri/src/menubar/model/tests.rs b/src-tauri/src/menubar/model/tests.rs new file mode 100644 index 00000000..ff2dc4c6 --- /dev/null +++ b/src-tauri/src/menubar/model/tests.rs @@ -0,0 +1,1186 @@ +use super::*; +use crate::i18n::{Lang, with_lang}; + +const NOW: u64 = 1_800_000_000_000; + +fn running() -> Snapshot { + Snapshot { + gateway: Gateway::Running, + addr: Some("127.0.0.1:18790".into()), + today: Some(Today { + requests: 186, + failed: 0, + tokens: 3_100_000, + cost_micros: 41_200_000, + ..Default::default() + }), + notices_on: true, + now_ms: NOW, + ..Default::default() + } +} + +fn window(window: &str, used: f64, reset_in_ms: u64) -> Window { + Window { + window: window.into(), + used_percent: used, + resets_at_ms: Some(NOW + reset_in_ms), + status: None, + credits: None, + } +} + +fn items(rows: &[Row]) -> Vec<&Item> { + rows.iter() + .filter_map(|r| match r { + Row::Item(i) => Some(i), + _ => None, + }) + .collect() +} + +fn item<'a>(rows: &'a [Row], id: &str) -> Option<&'a Item> { + items(rows).into_iter().find(|i| i.id == id) +} + +/// 菜单从上到下:分隔线(`---`)、节的标题(`[提醒]`)、各行,菜单项写 id +fn outline(rows: &[Row]) -> Vec { + rows.iter() + .map(|r| match r { + Row::Status { .. } => "status".to_string(), + Row::Today(_) => "today".to_string(), + Row::Separator => "---".to_string(), + Row::Section { title, .. } => format!("[{title}]"), + Row::Notice { .. } => "notice".to_string(), + Row::Quota { + provider, window, .. + } => format!("quota:{provider}:{}", window.label), + Row::Live { .. } => "live".to_string(), + Row::Item(i) => i.id.clone(), + }) + .collect() +} + +/// 「今日」那一块 +fn dash(rows: &[Row]) -> &Dash { + rows.iter() + .find_map(|r| match r { + Row::Today(d) => Some(&**d), + _ => None, + }) + .expect("「今日」那一块不见了") +} + +/// 所有额度行,按菜单里的次序 +fn windows(rows: &[Row]) -> Vec<(&str, &WindowRow)> { + rows.iter() + .filter_map(|r| match r { + Row::Quota { + provider, window, .. + } => Some((provider.as_str(), window)), + _ => None, + }) + .collect() +} + +/// 网关在运行、什么都有:打开主界面在最上面;提醒;今日、额度连在一起;进行中;策略组和 +/// 两个复制;有新版本时「安装新版本」在设置前面;退出 +#[test] +fn a_busy_menu_opens_with_open_and_puts_today_and_quota_together() { + let mut s = running(); + s.notices = vec![NoticeLine { + key: "k".into(), + level: Level::Warning, + title: "t".into(), + body: "b".into(), + }]; + s.quotas = vec![Quota { + provider: "chatgpt".into(), + windows: vec![ + window("5h", 42.0, 3_600_000), + window("weekly", 31.0, 4 * 86_400_000), + ], + reset_credits: None, + }]; + s.live = vec![ + Live { + id: 1, + started_ms: NOW - 1_000, + ..Default::default() + }, + Live { + id: 2, + started_ms: NOW - 9_000, + ..Default::default() + }, + ]; + s.groups = vec![Group { + name: "主力".into(), + members: vec!["chatgpt".into(), "openai".into()], + selected: Some("chatgpt".into()), + }]; + s.update = Some("2026.10.9".into()); + assert_eq!( + outline(&rows(&s)), + [ + "open", + "---", + "[提醒]", + "notice", + "---", + "today", + "quota:chatgpt:5 小时", + "quota:chatgpt:每周", + "---", + "[进行中]", + "live", + "live", + "---", + "group:主力", + "copy-address", + "copy-key", + "---", + "update", + "settings", + "connections", + "---", + "quit", + ] + ); +} + +/// 闲着的时候:没有提醒、没有在跑的、没有新版本。「今日」照样在(今天还没有请求就是 0), +/// 「检查更新」排在这一组最后 +#[test] +fn an_idle_menu_keeps_today_and_checks_for_updates_last() { + let mut s = running(); + s.today = Some(Today::default()); + assert_eq!( + outline(&rows(&s)), + [ + "open", + "---", + "today", + "---", + "copy-address", + "copy-key", + "---", + "settings", + "connections", + "update", + "---", + "quit", + ] + ); + let d = dash(&rows(&s)).clone(); + assert_eq!(d.value, "0"); + assert_eq!(d.meta_line(), "0 次 · $0.00"); + // 还没问到:那一块照样在(状态写在它上面),大字是破折号,下面那一行空着 + s.today = None; + let rows = rows(&s); + let d = dash(&rows); + assert_eq!((d.value.as_str(), d.meta.is_empty()), ("—", true)); + assert_eq!(d.spark, None); + assert_eq!(d.tooltip, None); + assert_eq!(d.state, "运行中"); +} + +/// 「今日」那一块:今天的 token 是大字,下面一行次数、失败、费用 —— 和菜单栏上「token 在上、 +/// 费用在下」一个次序。失败数只在有失败时出现,用红字 +#[test] +fn today_leads_with_tokens_and_puts_the_cost_last_in_the_small_line() { + let mut s = running(); + s.today = Some(Today { + requests: 128, + failed: 2, + tokens: 1_240_000, + cost_micros: 3_470_000, + ..Default::default() + }); + s.rate = Some(42); + let rows = rows(&s); + let d = dash(&rows); + assert_eq!( + (d.title.as_str(), d.state_line()), + ("今日", "运行中 · 42 token/秒".into()) + ); + assert_eq!(d.tone, StateTone::Ok); + assert_eq!((d.value.as_str(), d.unit.as_str()), ("1.2M", "token")); + assert_eq!(d.meta_line(), "128 次 · 失败 2 · $3.47"); + assert_eq!( + d.meta.iter().map(|m| m.failed).collect::>(), + [false, true, false] + ); + assert_eq!(d.action, Action::Open("dashboard")); + assert_eq!(d.line(), "今日 1.2M token · 128 次 · 失败 2 · $3.47"); + // 大字是取整过的:悬停提示里写准数 + assert_eq!( + d.tooltip.as_deref(), + Some("今日 1,240,000 token,128 次请求,失败 2 次,费用 $3.47") + ); + // 不知道速率时不写 + let mut s = s.clone(); + s.rate = None; + s.today.as_mut().unwrap().failed = 0; + let rows = super::rows(&s); + assert_eq!(dash(&rows).state_line(), "运行中"); + assert_eq!(dash(&rows).meta_line(), "128 次 · $3.47"); +} + +/// 连着远程:状态头没了,服务器的名字跟在「运行中」后面 —— 菜单里的数字都是那台的 +#[test] +fn a_remote_core_is_named_next_to_the_state() { + let mut s = running(); + s.remote = Some("office-mac".into()); + s.rate = Some(42); + assert_eq!( + dash(&rows(&s)).state_line(), + "运行中 · office-mac · 42 token/秒" + ); + // 连不上时:状态那一块写它 + s.gateway = Gateway::Unlinked; + let rows = rows(&s); + let Some(Row::Status { + state, + server, + reason, + tone, + }) = rows.iter().find(|r| matches!(r, Row::Status { .. })) + else { + panic!("状态那一块不见了") + }; + assert_eq!(state, "未连接"); + assert_eq!(server.as_deref(), Some("office-mac")); + assert_eq!(reason.as_deref(), Some("正在重新连接。")); + assert_eq!(*tone, StateTone::Bad); +} + +/// 网关不在运行的每一种状态:「打开主界面」照样在最上面;下面是状态和能做的事;更新、设置、 +/// 连接和运行时一个次序。今天的用量不代表现在,不列。正在启动时没有可做的事 +#[test] +fn every_state_opens_with_open_and_says_what_is_wrong() { + let fix = ["restart", "why"]; + for (gateway, head, has_update) in [ + (Gateway::Starting, &[][..], false), + (Gateway::SafeMode, &fix[..], false), + (Gateway::Failed, &fix[..], true), + (Gateway::Stopped, &fix[..], false), + (Gateway::Unlinked, &["retry", "why"][..], true), + ] { + let s = Snapshot { + gateway: gateway.clone(), + update: has_update.then(|| "2026.10.9".to_string()), + ..running() + }; + let app: &[&str] = if has_update { + &["update", "settings", "connections"] + } else { + &["settings", "connections", "update"] + }; + let want: Vec<&str> = ["open", "---", "status"] + .iter() + .chain(head) + .chain(&["---"]) + .chain(app) + .chain(&["---", "quit"]) + .copied() + .collect(); + assert_eq!(outline(&rows(&s)), want, "{gateway:?}"); + } +} + +/// 状态那一块:点的颜色和下面那一行橙字,和原来的状态头一样 +#[test] +fn the_status_block_says_why_the_gateway_is_not_running() { + let status = |g: Gateway| { + let s = Snapshot { + gateway: g, + ..running() + }; + match rows(&s) + .into_iter() + .find(|r| matches!(r, Row::Status { .. })) + { + Some(Row::Status { + state, + tone, + reason, + .. + }) => (state, tone, reason), + _ => panic!(), + } + }; + assert_eq!( + status(Gateway::Starting), + ("正在启动".into(), StateTone::Busy, None) + ); + assert_eq!( + status(Gateway::SafeMode), + ( + "未在转发".into(), + StateTone::Warn, + Some("已连续启动失败,当前只有配置和历史可用。".into()) + ) + ); + assert_eq!( + status(Gateway::Failed), + ( + "无法启动".into(), + StateTone::Bad, + Some("core 程序未能运行,转发已停止。".into()) + ) + ); + assert_eq!( + status(Gateway::Stopped), + ("未运行".into(), StateTone::Bad, Some("转发已停止。".into())) + ); + with_lang(Lang::En, || { + assert_eq!( + status(Gateway::Stopped), + ( + "Not Running".into(), + StateTone::Bad, + Some("Forwarding has stopped.".into()) + ) + ); + }); +} + +#[test] +fn the_bar_shows_todays_tokens_over_todays_cost() { + let (bar, _) = build(&running(), Style::Full); + assert_eq!(bar.numbers, Some(("3.1M".into(), "$41.20".into()))); + assert!(!bar.dim && !bar.alert && !bar.dot); +} + +/// **估算不能冒充实测,算不出钱的也不能当成零**:和界面上同一套记号,菜单栏上 +/// 那个数是下限的写「≥」,含估算的带「~」,缺着什么写在费用那一格下面 +#[test] +fn todays_cost_says_when_it_is_estimated_or_only_a_lower_bound() { + use crate::i18n::{Lang, with_lang}; + let cost = |t: Today| { + let mut s = running(); + s.today = Some(t); + let (bar, rows) = build(&s, Style::Full); + let d = dash(&rows); + // 费用是那一行的最后一段 + let long = d.meta.last().unwrap().text.clone(); + (bar.numbers.unwrap().1, long, d.note.clone(), bar.tooltip) + }; + let base = Today { + requests: 10, + tokens: 1_000, + cost_micros: 2_500_000, + ..Default::default() + }; + with_lang(Lang::En, || { + let (short, long, note, _) = cost(base.clone()); + assert_eq!( + (short.as_str(), long.as_str(), note), + ("$2.50", "$2.50", None) + ); + + let (short, long, note, tip) = cost(Today { + estimated_micros: 500_000, + ..base.clone() + }); + assert_eq!( + (short.as_str(), long.as_str(), note), + ("~$2.50", "~$2.50", None) + ); + assert!(tip.contains("~$2.50 cost"), "{tip}"); + + let (short, _, note, _) = cost(Today { + unpriced: 3, + ..base.clone() + }); + assert_eq!(short, "≥$2.50"); + assert_eq!(note.as_deref(), Some("3 unpriced")); + // 那一行放不下小字时它只在悬停提示里:提示里总有它 + let mut s = running(); + s.today = Some(Today { + unpriced: 3, + ..base.clone() + }); + let rows = rows(&s); + let d = dash(&rows); + assert_eq!( + d.tooltip.as_deref(), + Some("1,000 tokens today, 10 requests, ≥$2.50 cost (3 unpriced)") + ); + assert_eq!( + d.line(), + "Today 1.0k tokens · 10 requests · ≥$2.50 (3 unpriced)" + ); + + // 一条都没算出钱:不是「今天花了 $0」 + let (short, _, note, _) = cost(Today { + cost_micros: 0, + no_usage: 2, + ..base.clone() + }); + assert_eq!(short, "≥$0.00"); + assert_eq!(note.as_deref(), Some("2 with no usage")); + }); +} + +#[test] +fn an_unknown_day_is_two_dashes_and_a_quiet_day_is_zero() { + // 0 是一个值,破折号不是:问到了、今天还没有请求,就写 0 + let mut s = running(); + s.today = None; + assert_eq!(build(&s, Style::Full).0.numbers, None); + s.today = Some(Today::default()); + assert_eq!( + build(&s, Style::Full).0.numbers, + Some(("0".into(), "$0.00".into())) + ); +} + +#[test] +fn numbers_are_written_the_way_the_overview_writes_them() { + for (n, want) in [ + (0, "0"), + (845, "845"), + (9_800, "9.8k"), + (123_400, "123k"), + (3_100_000, "3.1M"), + (1_240_000_000, "1.2B"), + ] { + assert_eq!(tokens_short(n), want, "{n}"); + } + for (micros, want) in [ + (0, "$0.00"), + (3_420_000, "$3.42"), + (99_990_000, "$99.99"), + (123_400_000, "$123"), + (1_234_000_000, "$1.2k"), + (12_345_000_000, "$12k"), + ] { + assert_eq!(cost_short(micros), want, "{micros}"); + } + assert_eq!(cost_long(1_234_567_000_000), "$1,234,567"); + assert_eq!(grouped(1234), "1,234"); + assert_eq!(elapsed(102_000), "1:42"); + assert_eq!(elapsed(3_800_000), "1:03:20"); +} + +/// **取整之后再定单位。**先定单位再取整,就会写出「10.0k」「1000k」「$10.0k」「60 分钟」 +/// 这样多出一位、或者该进位却没进的数 +#[test] +fn a_number_that_rounds_up_to_the_next_unit_is_written_in_that_unit() { + for (n, want) in [ + (9_949, "9.9k"), + (9_960, "10k"), + (9_999, "10k"), + (999_499, "999k"), + (999_500, "1.0M"), + (999_999, "1.0M"), + (999_949_999, "999.9M"), + (999_950_001, "1.0B"), + ] { + assert_eq!(tokens_short(n), want, "{n}"); + } + for (micros, want) in [ + (99_994_999, "$99.99"), + (99_996_000, "$100"), + (999_499_999, "$999"), + (999_600_000, "$1.0k"), + (9_949_000_000, "$9.9k"), + (9_960_000_000, "$10k"), + ] { + assert_eq!(cost_short(micros), want, "{micros}"); + } + assert_eq!(cost_long(999_994_000), "$999.99"); + assert_eq!(cost_long(999_996_000), "$1,000"); + // 59.5 分钟往上是「1 小时」,23.5 小时往上是「1 天」,和上游页一样 + assert_eq!(resets_in(3_569), "59 分钟后重置"); + assert_eq!(resets_in(3_570), "1 小时后重置"); + assert_eq!(resets_in(84_599), "23 小时后重置"); + assert_eq!(resets_in(84_600), "1 天后重置"); + with_lang(Lang::En, || { + assert_eq!(resets_in(3_570), "Resets in 1 h"); + assert_eq!(resets_in(84_600), "Resets in 1 day"); + }); +} + +#[test] +fn a_tight_quota_turns_the_numbers_orange_and_a_used_up_one_red() { + let mut s = running(); + s.quotas = vec![Quota { + provider: "chatgpt".into(), + windows: vec![ + window("5h", 42.0, 3_600_000), + window("weekly", 18.0, 86_400_000), + ], + reset_credits: None, + }]; + assert_eq!(build(&s, Style::Full).0.tone, Tone::Normal); + s.quotas[0].windows[0].used_percent = 91.0; + assert_eq!(build(&s, Style::Full).0.tone, Tone::Warn); + // 上游说快到了的,不管用了多少都算 + s.quotas[0].windows[0].used_percent = 50.0; + s.quotas[0].windows[0].status = Some("allowed_warning".into()); + assert_eq!(build(&s, Style::Full).0.tone, Tone::Warn); + s.quotas[0].windows[0].status = Some("rejected".into()); + assert_eq!(build(&s, Style::Full).0.tone, Tone::Full); +} + +/// 颜色看**最要紧**的那个窗口,不是用得最多的那个:每周的用了 95%,5 小时的上游已经 +/// 拒绝了,菜单栏是红的;用得最多的那个没事、另一家上游说快到了,是橙的。悬停提示说的 +/// 也是定了颜色的那个窗口 +#[test] +fn the_bar_takes_its_color_from_the_most_severe_window_not_the_fullest() { + let mut s = running(); + s.quotas = vec![Quota { + provider: "chatgpt".into(), + windows: vec![ + window("weekly", 95.0, 86_400_000), + Window { + status: Some("rejected".into()), + ..window("5h", 40.0, 3_600_000) + }, + ], + reset_credits: None, + }]; + let (bar, _) = build(&s, Style::Full); + assert_eq!(bar.tone, Tone::Full); + assert!( + bar.tooltip.ends_with("chatgpt 5 小时额度已用 40%"), + "{}", + bar.tooltip + ); + + s.quotas = vec![ + Quota { + provider: "chatgpt".into(), + windows: vec![window("5h", 60.0, 3_600_000)], + reset_credits: None, + }, + Quota { + provider: "glm".into(), + windows: vec![Window { + status: Some("allowed_warning".into()), + ..window("weekly", 30.0, 86_400_000) + }], + reset_credits: None, + }, + ]; + let (bar, _) = build(&s, Style::Full); + assert_eq!(bar.tone, Tone::Warn); + assert!( + bar.tooltip.ends_with("glm 每周额度已用 30%"), + "{}", + bar.tooltip + ); +} + +#[test] +fn a_window_past_its_reset_is_not_the_current_state() { + let mut s = running(); + s.quotas = vec![Quota { + provider: "chatgpt".into(), + windows: vec![Window { + window: "5h".into(), + used_percent: 100.0, + resets_at_ms: Some(NOW - 1), + status: Some("rejected".into()), + credits: None, + }], + reset_credits: Some(2), + }]; + let (bar, rows) = build(&s, Style::Full); + assert_eq!(bar.tone, Tone::Normal, "已经重置过的窗口还在让菜单栏变红"); + let w = windows(&rows)[0].1; + assert_eq!(w.percent, None, "重置过的窗口还在画条"); + assert_eq!(w.reset, "已重置"); + assert_eq!(w.right(), "已重置", "百分比是重置之前的,不写"); + assert!( + item(&rows, "resets:chatgpt").is_none(), + "额度没用完却给了重置卡" + ); +} + +#[test] +fn a_used_up_quota_offers_the_reset_credits_only_when_there_are_some() { + let mut s = running(); + s.quotas = vec![Quota { + provider: "chatgpt".into(), + windows: vec![Window { + status: Some("rejected".into()), + ..window("5h", 100.0, 42 * 60_000) + }], + reset_credits: Some(2), + }]; + let (_, rows) = build(&s, Style::Full); + let credits = item(&rows, "resets:chatgpt").expect("用完了、也有卡,却没给入口"); + assert_eq!(credits.right.as_deref(), Some("可用 2 张")); + assert_eq!( + credits.action, + Some(Action::Open("upstreams")), + "菜单里不直接用卡" + ); + s.quotas[0].reset_credits = Some(0); + assert!(item(&build(&s, Style::Full).1, "resets:chatgpt").is_none()); +} + +#[test] +fn quota_bars_turn_orange_at_eighty_percent() { + let mut s = running(); + s.quotas = vec![Quota { + provider: "chatgpt".into(), + windows: vec![ + window("5h", 79.0, 3_600_000), + window("weekly", 80.0, 86_400_000), + ], + reset_credits: None, + }]; + let rows = rows(&s); + let w = windows(&rows); + assert_eq!((w[0].1.tone, w[1].1.tone), (Tone::Normal, Tone::Warn)); + assert_eq!(w[0].1.label, "5 小时"); + assert_eq!(w[0].1.reset, "1 小时后重置"); + assert_eq!(w[1].1.label, "每周"); +} + +/// **报了几个窗口就画几行**:ChatGPT 有时只报每周的那一个,菜单里就只有那一行,不凭空补一个 +/// 5 小时的。每一行说清是哪家的哪个窗口,右边「31% · 4 天后重置」;点了去上游页 +#[test] +fn each_quota_window_reported_gets_its_own_row() { + let mut s = running(); + s.quotas = vec![Quota { + provider: "ChatGPT".into(), + windows: vec![window("weekly", 31.0, 4 * 86_400_000)], + reset_credits: None, + }]; + let rows = rows(&s); + let w = windows(&rows); + assert_eq!(w.len(), 1); + assert_eq!(w[0].0, "ChatGPT"); + assert_eq!(w[0].1.label, "每周"); + assert_eq!(w[0].1.right(), "31% · 4 天后重置"); + assert_eq!(w[0].1.line("ChatGPT"), "ChatGPT 每周 31% · 4 天后重置"); + assert!(rows.iter().any(|r| matches!( + r, + Row::Quota { action, .. } if *action == Action::Open("upstreams") + ))); + // 紧跟在「今日」后面,之后一条分隔线 + let at = rows + .iter() + .position(|r| matches!(r, Row::Today(_))) + .unwrap(); + assert!(matches!(rows[at + 1], Row::Quota { .. })); + assert_eq!(rows[at + 2], Row::Separator); + // 两家各报两个:四行,一家的挨在一起 + s.quotas.push(glm()); + s.quotas[0] + .windows + .insert(0, window("5h", 58.0, 42 * 60_000)); + let rows = super::rows(&s); + assert_eq!( + windows(&rows) + .iter() + .map(|(p, w)| format!("{p}:{}", w.label)) + .collect::>(), + ["ChatGPT:5 小时", "ChatGPT:每周", "glm:5 小时", "glm:每周"] + ); + // 没有重置时刻的窗口只写百分比 + let w = Window { + resets_at_ms: None, + ..window("30d", 12.4, 0) + }; + assert_eq!(window_row(&w, NOW).right(), "12%"); +} + +/// `5h`、`weekly` 有自己的叫法;core 按长度起的名字(ChatGPT 账号的 `30d`)按长度说; +/// 认不出来的原样显示 +#[test] +fn windows_named_by_their_length_are_said_in_words() { + let names = [ + "5h", "weekly", "30d", "1d", "7d", "3h", "1h", "45m", "1m", "monthly", "d", "+5h", "5 h", + "", + ]; + assert_eq!( + names.map(window_label), + [ + "5 小时", + "每周", + "30 天", + "1 天", + "7 天", + "3 小时", + "1 小时", + "45 分钟", + "1 分钟", + "monthly", + "d", + "+5h", + "5 h", + "", + ] + ); + with_lang(Lang::En, || { + assert_eq!( + names.map(window_label), + [ + "5h", + "Weekly", + "30 days", + "1 day", + "7 days", + "3 hours", + "1 hour", + "45 minutes", + "1 minute", + "monthly", + "d", + "+5h", + "5 h", + "", + ] + ); + }); +} + +#[test] +fn requests_in_progress_put_a_dot_on_the_mark_and_list_at_most_five() { + let mut s = running(); + s.live = (1..=7) + .map(|i| Live { + id: i, + app: (i == 1).then(|| "codex".to_string()), + key: "default".into(), + model: "gpt-5-codex".into(), + started_ms: NOW - 102_000, + }) + .collect(); + let (bar, rows) = build(&s, Style::Full); + assert!(bar.dot); + let live: Vec<_> = rows + .iter() + .filter_map(|r| match r { + Row::Live { app, elapsed, .. } => Some((app.as_str(), elapsed.as_str())), + _ => None, + }) + .collect(); + assert_eq!(live.len(), MAX_LIVE); + assert_eq!(live[0], ("Codex", "1:42")); + // 认不出应用的用密钥名 + assert_eq!(live[1].0, "default"); + assert_eq!(item(&rows, "live-more").unwrap().title, "另有 2 个"); +} + +#[test] +fn notices_come_first_and_stop_at_three() { + let mut s = running(); + s.notices = (0..4) + .map(|i| NoticeLine { + key: format!("k{i}"), + level: Level::Warning, + title: format!("t{i}"), + body: "b".into(), + }) + .collect(); + let rows = rows(&s); + // 状态头、分隔线之后就是提醒 + assert!(matches!(&rows[2], Row::Section { title, .. } if title == "提醒")); + let shown = rows + .iter() + .filter(|r| matches!(r, Row::Notice { .. })) + .count(); + assert_eq!(shown, MAX_NOTICES); + assert_eq!(item(&rows, "all-notices").unwrap().title, "全部提醒(4)…"); + // 提醒关掉了,这一节就不出现 + s.notices_on = false; + assert!( + !super::rows(&s) + .iter() + .any(|r| matches!(r, Row::Notice { .. })) + ); +} + +#[test] +fn a_gateway_that_is_not_running_says_why_and_offers_a_restart() { + let mut s = running(); + s.gateway = Gateway::Failed; + s.live = vec![Live::default()]; + let (bar, rows) = build(&s, Style::Full); + assert_eq!(bar.numbers, None, "不在运行时还显示一个凝固的旧数字"); + assert!(bar.dim && bar.alert && !bar.dot); + assert!( + matches!(&rows[2], Row::Status { state, tone: StateTone::Bad, .. } if state == "无法启动") + ); + assert_eq!( + item(&rows, "restart").unwrap().action, + Some(Action::RestartGateway) + ); + assert!( + !rows + .iter() + .any(|r| matches!(r, Row::Today(_) | Row::Quota { .. } | Row::Live { .. })) + ); + // 正在启动时没有什么可重启的,也不画红色角标 + s.gateway = Gateway::Starting; + let (bar, rows) = build(&s, Style::Full); + assert!(bar.dim && !bar.alert); + assert!(item(&rows, "restart").is_none()); +} + +/// 监听设置没换成:「今日」那一块最后多一行橙字。行高跟着变,所以它是样子的一部分 +#[test] +fn a_listen_setting_that_did_not_take_shows_inside_today() { + let mut s = running(); + s.rate = Some(52); + let shape = |s: &Snapshot| rows(s).iter().map(Row::shape).collect::>(); + let before = shape(&s); + assert_eq!(dash(&rows(&s)).warn, None); + s.listen_error = Some("端口 18790 已被占用。".into()); + let rows = rows(&s); + assert_eq!(dash(&rows).warn.as_deref(), Some("端口 18790 已被占用。")); + assert_ne!(shape(&s), before); +} + +/// 「复制网关地址」右边写着要复制的那个地址;网关没在监听、不知道地址时点不了 +#[test] +fn copy_address_shows_the_address_it_copies() { + let mut s = running(); + let rows = rows(&s); + let copy = item(&rows, "copy-address").unwrap(); + assert_eq!(copy.right.as_deref(), Some("127.0.0.1:18790")); + assert!(copy.enabled); + s.addr = None; + let rows = super::rows(&s); + let copy = item(&rows, "copy-address").unwrap(); + assert_eq!(copy.right, None); + assert!(!copy.enabled); +} + +#[test] +fn a_new_version_replaces_the_check() { + let mut s = running(); + assert_eq!( + item(&rows(&s), "update").unwrap().action, + Some(Action::CheckUpdates) + ); + s.update = Some("2026.9.8".into()); + let rows = rows(&s); + let update = item(&rows, "update").unwrap(); + assert_eq!(update.title, "安装新版本 2026.9.8…"); + assert_eq!(update.action, Some(Action::InstallUpdate)); + assert!(update.accent); + // 只有一项,不是安装和检查各一项 + assert_eq!(items(&rows).iter().filter(|i| i.id == "update").count(), 1); +} + +#[test] +fn a_select_group_is_a_submenu_with_the_current_member_checked() { + let mut s = running(); + s.groups = vec![Group { + name: "主力".into(), + members: vec!["chatgpt".into(), "openai".into()], + selected: Some("chatgpt".into()), + }]; + let rows = rows(&s); + let g = item(&rows, "group:主力").unwrap(); + assert_eq!(g.right.as_deref(), Some("chatgpt")); + assert_eq!( + g.submenu.iter().map(|m| m.checked).collect::>(), + [true, false] + ); + assert_eq!( + g.submenu[1].action, + Action::SelectGroup { + group: "主力".into(), + provider: "openai".into() + } + ); +} + +#[test] +fn the_menu_follows_the_interface_language() { + with_lang(Lang::En, || { + let mut s = running(); + s.quotas = vec![Quota { + provider: "chatgpt".into(), + windows: vec![window("weekly", 18.0, 4 * 86_400_000)], + reset_credits: None, + }]; + s.rate = Some(42); + s.today = Some(Today { + requests: 128, + failed: 2, + tokens: 1_240_000, + cost_micros: 3_470_000, + ..Default::default() + }); + let rows = rows(&s); + assert_eq!(item(&rows, "open").unwrap().title, "Open ThinkWatch Lite"); + let d = dash(&rows); + assert_eq!(d.title, "Today"); + assert_eq!(d.state_line(), "Running · 42 tokens/s"); + assert_eq!((d.value.as_str(), d.unit.as_str()), ("1.2M", "tokens")); + assert_eq!(d.meta_line(), "128 requests · 2 failed · $3.47"); + let w = windows(&rows)[0].1; + assert_eq!( + (w.label.as_str(), w.right()), + ("Weekly", "18% · Resets in 4 days".to_string()) + ); + assert_eq!(w.line("chatgpt"), "chatgpt Weekly 18% · Resets in 4 days"); + assert_eq!(item(&rows, "quit").unwrap().title, "Quit ThinkWatch Lite…"); + let (bar, _) = build(&s, Style::Full); + assert!( + bar.tooltip + .starts_with("ThinkWatch Lite, Running, 1.2M tokens today") + ); + // 一次请求、一个 token:单数 + s.today = Some(Today { + requests: 1, + tokens: 1, + ..Default::default() + }); + let rows = super::rows(&s); + let d = dash(&rows); + assert_eq!( + (d.unit.as_str(), d.meta_line()), + ("token", "1 request · $0.00".into()) + ); + }); +} + +#[test] +fn the_tooltip_says_the_whole_state_in_one_sentence() { + let mut s = running(); + s.quotas = vec![Quota { + provider: "chatgpt".into(), + windows: vec![window("5h", 42.0, 3 * 3_600_000)], + reset_credits: None, + }]; + assert_eq!( + build(&s, Style::Full).0.tooltip, + "ThinkWatch Lite,运行中,今日 3.1M token,费用 $41.20,chatgpt 5 小时额度已用 42%" + ); +} + +#[test] +fn a_row_keeps_its_shape_while_only_its_text_changes() { + // 菜单开着时,秒数、倒计时每秒在变 —— 那不该让菜单整个重建 + let mut s = running(); + s.live = vec![Live { + id: 7, + started_ms: NOW - 1_000, + ..Default::default() + }]; + s.hourly = Some(Hourly { + from_ms: NOW as i64 - 10 * HOUR_MS, + tokens: vec![0; 24], + }); + s.quotas = vec![Quota { + provider: "chatgpt".into(), + windows: vec![window("weekly", 31.0, 4 * 86_400_000)], + reset_credits: None, + }]; + let a: Vec = rows(&s).iter().map(Row::shape).collect(); + s.now_ms += 5_000; + // 今天的数涨了、这一小时多了 token、额度多用了一点:都只是字和柱子变了 + s.today.as_mut().unwrap().tokens += 50_000; + s.hourly.as_mut().unwrap().tokens[10] += 50_000; + s.quotas[0].windows[0].used_percent = 32.0; + let b: Vec = rows(&s).iter().map(Row::shape).collect(); + assert_eq!(a, b); +} + +/// GLM Coding Plan 积分制套餐:5 小时和每周两个窗口,各带总额、已用、剩余 +fn glm() -> Quota { + let credits = |total: f64, used: f64, remaining: f64| { + Some(QuotaCredits { + total, + used, + remaining, + }) + }; + Quota { + provider: "glm".into(), + windows: vec![ + Window { + credits: credits(2_000.0, 23.0, 1_976.0), + ..window("5h", 1.0, 3_600_000) + }, + Window { + credits: credits(10_000.0, 268.0, 9_731.0), + ..window("weekly", 2.0, 3 * 86_400_000) + }, + ], + reset_credits: None, + } +} + +fn quota_rows(s: &Snapshot) -> Vec { + let rows = rows(s); + let w: Vec = windows(&rows).into_iter().map(|(_, w)| w.clone()).collect(); + assert!(!w.is_empty(), "额度那几行不见了"); + w +} + +#[test] +fn a_credit_plan_says_what_is_left_under_each_bar() { + let mut s = running(); + s.quotas = vec![glm()]; + let w = quota_rows(&s); + assert_eq!(w[0].label, "5 小时"); + // **剩余照上游说的写**:2000 − 23 是 1977,上游说的是 1976 + assert_eq!(w[0].detail.as_deref(), Some("剩余 1,976 / 2,000 积分")); + assert_eq!(w[1].detail.as_deref(), Some("剩余 9,731 / 10,000 积分")); + with_lang(Lang::En, || { + assert_eq!( + quota_rows(&s)[0].detail.as_deref(), + Some("1,976 / 2,000 credits left") + ); + }); + // 按百分比报的窗口没有这一行 + s.quotas[0].windows[0].credits = None; + assert_eq!(quota_rows(&s)[0].detail, None); +} + +#[test] +fn what_was_left_before_a_reset_is_not_shown_after_it() { + let mut s = running(); + s.quotas = vec![glm()]; + s.now_ms = NOW + 3_600_000; + let w = quota_rows(&s); + assert_eq!((w[0].percent, w[0].detail.as_deref()), (None, None)); + assert!(w[1].detail.is_some(), "没到重置时刻的窗口照常写"); +} + +#[test] +fn a_line_under_a_bar_changes_the_shape_of_the_row() { + // 带小字的窗口高一截:菜单开着时多了、少了这一行,要重建,不能就地改 + let mut s = running(); + s.quotas = vec![glm()]; + let shape = |s: &Snapshot| -> Vec { rows(s).iter().map(Row::shape).collect() }; + let a = shape(&s); + assert!(a.contains(&"quota:glm:每周:+".to_string()), "{a:?}"); + s.quotas[0].windows[1].credits = None; + assert!(shape(&s).contains(&"quota:glm:每周:-".to_string())); + // 只是数变了:样子不变 + s.quotas[0].windows[0].credits = Some(QuotaCredits { + total: 2_000.0, + used: 500.0, + remaining: 1_500.0, + }); + assert!(shape(&s).contains(&"quota:glm:5 小时:+".to_string())); + // 托盘那一行把还剩多少括在后面 + assert_eq!( + quota_rows(&s)[0].line("glm"), + "glm 5 小时 1% · 1 小时后重置(剩余 1,500 / 2,000 积分)" + ); +} + +/// 今天每小时的 token,零点在 `NOW` 之前十个半小时:此刻在第 10 格 +fn hourly(tokens: &[(usize, i64)]) -> Hourly { + let mut h = Hourly { + from_ms: NOW as i64 - 10 * HOUR_MS - HOUR_MS / 2, + tokens: vec![0; 24], + }; + for &(k, t) in tokens { + h.tokens[k] = t; + } + h +} + +/// 小柱子:过去的小时、此刻这一小时、没有用量的(和还没到的)分三种画。**满格是当天最高 +/// 的那一小时** +#[test] +fn the_sparkline_marks_the_current_hour_and_leaves_empty_hours_as_stubs() { + let h = hourly(&[(8, 100_000), (9, 50_000), (10, 25_000)]); + let bars = spark(&h, NOW); + assert_eq!(bars.len(), 24); + assert_eq!(bars[8], Spark::Past(1.0)); + assert_eq!(bars[9], Spark::Past(0.5)); + assert_eq!(bars[10], Spark::Now(0.25)); + // 没有用量的小时、还没到的小时:短线 + assert!(bars[..8].iter().all(|b| *b == Spark::Empty)); + assert!(bars[11..].iter().all(|b| *b == Spark::Empty)); + // 此刻这一小时还没有用量:也是短线,不是一根高度为 0 的「此刻」 + let quiet = hourly(&[(8, 100_000)]); + assert_eq!(spark(&quiet, NOW)[10], Spark::Empty); +} + +/// **满格不低于 [`SPARK_MIN_PEAK`]**:一天里只有一个零星的请求,那一小时不是满满一格 +#[test] +fn a_tiny_hour_does_not_fill_the_sparkline() { + let bars = spark(&hourly(&[(10, 2_000)]), NOW); + assert_eq!(bars[10], Spark::Now(2_000.0 / SPARK_MIN_PEAK as f64)); + // 到了下限,按当天最高的算 + let bars = spark( + &hourly(&[(3, SPARK_MIN_PEAK * 4), (10, SPARK_MIN_PEAK)]), + NOW, + ); + assert_eq!((bars[3], bars[10]), (Spark::Past(1.0), Spark::Now(0.25))); +} + +/// 「此刻」按现在的钟算:菜单开着过了整点,下一次重画就挪到下一格,前一格成了过去 +#[test] +fn the_current_hour_moves_on_with_the_clock() { + let h = hourly(&[(10, 40_000)]); + assert_eq!(spark(&h, NOW)[10], Spark::Now(1.0)); + let next = NOW + HOUR_MS as u64; + let bars = spark(&h, next); + assert_eq!((bars[10], bars[11]), (Spark::Past(1.0), Spark::Empty)); + // 时钟走在 core 后面(连着远程,那边快几分钟):「将来」那一格里已经有数,照样画 + let ahead = hourly(&[(11, 40_000)]); + assert_eq!(spark(&ahead, NOW)[11], Spark::Past(1.0)); +} + +/// 柱子和大数字出自同一问:今天的数没问到时不画柱子,问到了才画 +#[test] +fn the_sparkline_shows_only_with_todays_numbers() { + let mut s = running(); + s.hourly = Some(hourly(&[(10, 40_000)])); + let rows = rows(&s); + let bars = dash(&rows).spark.clone().expect("问到了却没画柱子"); + assert_eq!(bars[10], Spark::Now(1.0)); + s.today = None; + assert_eq!(dash(&super::rows(&s)).spark, None); + s.today = Some(Today::default()); + s.hourly = None; + assert_eq!(dash(&super::rows(&s)).spark, None); +} + +/// 每一种状态:「打开主界面」是第一项、只出现一次,后面跟一条分隔线;不留两条相邻的分隔线, +/// 也不以分隔线结尾。Linux 的托盘没有点击事件,菜单是唯一的入口,靠的就是这一条 +#[test] +fn open_is_the_first_item_once_in_every_state() { + for gateway in [ + Gateway::Starting, + Gateway::Running, + Gateway::SafeMode, + Gateway::Failed, + Gateway::Stopped, + Gateway::Unlinked, + ] { + for busy in [false, true] { + let mut s = Snapshot { + gateway: gateway.clone(), + ..running() + }; + if busy { + s.notices = vec![NoticeLine { + key: "k".into(), + level: Level::Info, + title: "t".into(), + body: "b".into(), + }]; + s.live = vec![Live::default()]; + s.update = Some("2026.10.9".into()); + } + let out = outline(&rows(&s)); + assert_eq!(&out[..2], ["open", "---"], "{gateway:?}"); + assert_eq!(out.iter().filter(|i| *i == "open").count(), 1); + assert!( + !out.windows(2).any(|w| w[0] == "---" && w[1] == "---"), + "{gateway:?} {out:?}" + ); + assert_eq!(out.last().map(String::as_str), Some("quit")); + } + } +} diff --git a/src-tauri/src/menubar/tray.rs b/src-tauri/src/menubar/tray.rs index c280bad4..b003c4aa 100644 --- a/src-tauri/src/menubar/tray.rs +++ b/src-tauri/src/menubar/tray.rs @@ -1,7 +1,8 @@ //! 不是 macOS 的平台:仍用 Tauri 的托盘,按同一份模型出一份纯文字菜单。 //! -//! 画不出额度条和三格数字,那几行写成文字。**判定都在模型里**,这里只是另一种画法 -//! —— 以后给 Windows 做一套像样的,换掉的只是这个文件。 +//! 画不出「今日」那一块和额度条,那几行写成文字(写法也在模型里:`Dash::line`、 +//! `WindowRow::line`)。**判定都在模型里**,这里只是另一种画法 —— 以后给 Windows 做一套 +//! 像样的,换掉的只是这个文件。次序和 macOS 一样:「打开主界面」永远是第一项。 use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -21,8 +22,7 @@ static GENERATION: AtomicU64 = AtomicU64::new(0); /// 上一次画的那一份:没变就不重建(开着的菜单会被收起来)。 /// /// Linux 上更要紧:tray-icon 的 `set_menu` 每次都把一份新的 GtkMenu 交给 -/// AppIndicator,整份菜单经 D-Bus 重新导出一遍,面板那边开着的菜单跟着重画。 -/// 比较的是模型,挪「打开主界面」那一步在比较之后,不影响这层判断 +/// AppIndicator,整份菜单经 D-Bus 重新导出一遍,面板那边开着的菜单跟着重画 static LAST: Mutex)>> = Mutex::new(None); pub fn install(app: &tauri::AppHandle) -> anyhow::Result<()> { @@ -36,7 +36,8 @@ pub fn install(app: &tauri::AppHandle) -> anyhow::Result<()> { // **Linux 上这一行和下面的点击回调都不起作用。**那边的托盘是 AppIndicator // (StatusNotifierItem),点击由面板处理、一律弹菜单,tray-icon 一个点击 // 事件也收不到(它的 `TrayIconEvent` 文档写明 Linux 不支持,gtk 那份实现里 - // 也没有发事件的地方)。所以 Linux 菜单的第一项是「打开主界面」,见 `build`。 + // 也没有发事件的地方)。菜单是那边唯一的入口 —— 模型把「打开主界面」排在 + // 每一种状态的第一项,那边第一眼看到的就是它。 .show_menu_on_left_click(false) .on_tray_icon_event(|app, event| { // **认「松开」不认「按下」**:按下就动作的话,用户按住想拖一下 @@ -87,34 +88,6 @@ pub fn apply(bar: &Bar, rows: &[Row]) { } } -/// Linux 上把「打开主界面」挪到最前面,后面跟一条分隔线。 -/// -/// 别处点一下图标就开窗口,这一项在动作区打头就够了,哪怕动作区排在今日、额度、 -/// 进行中几节下面;AppIndicator 不给点击事件,**菜单是唯一的入口**,它就得是第一眼 -/// 看到的那一项。是挪不是加:同一个菜单里出现两次「打开主界面」没有道理。 -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] -fn open_first(rows: &[Row]) -> Vec { - let is_open = |r: &Row| matches!(r, Row::Item(i) if i.id == "open"); - let Some(at) = rows.iter().position(is_open) else { - return rows.to_vec(); - }; - let mut out = Vec::with_capacity(rows.len() + 1); - out.push(rows[at].clone()); - out.push(Row::Separator); - out.extend(rows[..at].iter().cloned()); - // 挪走之后原处前后可能剩下两条相邻的分隔线,或者末尾一条 - for r in &rows[at + 1..] { - if matches!(r, Row::Separator) && matches!(out.last(), Some(Row::Separator)) { - continue; - } - out.push(r.clone()); - } - if matches!(out.last(), Some(Row::Separator)) { - out.pop(); - } - out -} - /// 第 `generation` 版菜单上第 `index` 个动作的那一项的 id fn item_id(generation: u64, index: usize) -> String { format!("a:{generation}:{index}") @@ -132,8 +105,6 @@ fn pick(id: &str, (generation, actions): &(u64, Vec)) -> Option } fn build(app: &tauri::AppHandle, rows: &[Row]) -> tauri::Result> { - #[cfg(target_os = "linux")] - let rows = &open_first(rows); let generation = GENERATION.fetch_add(1, Ordering::Relaxed) + 1; let mut actions = Vec::new(); let mut id = |a: &Action| { @@ -144,20 +115,39 @@ fn build(app: &tauri::AppHandle, rows: &[Row]) -> tauri::Result for row in rows { match row { Row::Separator => items.push(Box::new(PredefinedMenuItem::separator(app)?)), - Row::Header { - title, + Row::Status { state, - line2, + server, + reason, .. } => { + let text = match server { + Some(name) => format!("{state} · {name}"), + None => state.clone(), + }; + items.push(Box::new(MenuItem::new(app, text, false, None::<&str>)?)); + if let Some(reason) = reason { + items.push(Box::new(MenuItem::new(app, reason, false, None::<&str>)?)); + } + } + // 「今日」那一块:上面一行状态(灰的),下面一行今天的用量(点了去概览), + // 监听没换成时再一行原因 + Row::Today(d) => { items.push(Box::new(MenuItem::new( app, - format!("{title} {state}"), + d.state_line(), false, None::<&str>, )?)); - if !line2.is_empty() { - items.push(Box::new(MenuItem::new(app, line2, false, None::<&str>)?)); + items.push(Box::new(MenuItem::with_id( + app, + id(&d.action), + d.line(), + true, + None::<&str>, + )?)); + if let Some(warn) = &d.warn { + items.push(Box::new(MenuItem::new(app, warn, false, None::<&str>)?)); } } Row::Section { title, right } => { @@ -178,45 +168,13 @@ fn build(app: &tauri::AppHandle, rows: &[Row]) -> tauri::Result } Row::Quota { provider, - windows, + window, action, } => { - // 积分制套餐的窗口还剩多少积分(macOS 上是条下面那行小字),跟在百分比后面 - let text = windows - .iter() - .map(|w| { - let p = w.percent.map(|p| p.round() as i64); - match (p, &w.detail) { - (Some(p), Some(d)) => { - tr!( - format!("{} {p}%({d})", w.label), - format!("{} {p}% ({d})", w.label) - ) - } - (Some(p), None) => format!("{} {p}%", w.label), - (None, _) => format!("{} {}", w.label, w.reset), - } - }) - .collect::>() - .join(" · "); items.push(Box::new(MenuItem::with_id( app, id(action), - format!("{provider} {text}"), - true, - None::<&str>, - )?)); - } - Row::Stats { cells, action } => { - let text = cells - .iter() - .map(|c| format!("{} {}", c.label, c.value)) - .collect::>() - .join(" · "); - items.push(Box::new(MenuItem::with_id( - app, - id(action), - text, + window.line(provider), true, None::<&str>, )?)); @@ -293,111 +251,8 @@ fn build(app: &tauri::AppHandle, rows: &[Row]) -> tauri::Result #[cfg(test)] mod tests { - use super::super::model::{Gateway, Group, Snapshot, Style, Today, build}; use super::*; - fn ids(rows: &[Row]) -> Vec { - rows.iter() - .map(|r| match r { - Row::Item(i) => i.id.clone(), - Row::Separator => "---".to_string(), - Row::Header { .. } => "header".to_string(), - _ => "·".to_string(), - }) - .collect() - } - - /// 今日一节、一个手动选择的策略组:连着网关时动作区排在几节下面 - fn snapshot(gateway: Gateway) -> Snapshot { - Snapshot { - gateway, - addr: Some("127.0.0.1:18790".into()), - today: Some(Today::default()), - groups: vec![Group { - name: "主力".into(), - members: vec!["chatgpt".into(), "openai".into()], - selected: Some("chatgpt".into()), - }], - ..Default::default() - } - } - - /// AppIndicator 不给点击事件:「打开主界面」得是第一项,而且只出现一次。每一种状态都是 - #[test] - fn open_moves_to_the_top_once() { - for gateway in [ - Gateway::Starting, - Gateway::Running, - Gateway::SafeMode, - Gateway::Failed, - Gateway::Stopped, - Gateway::Unlinked, - ] { - let (_, rows) = build(&snapshot(gateway.clone()), Style::default()); - assert!(ids(&rows).contains(&"open".to_string()), "模型里得有这一项"); - let out = ids(&open_first(&rows)); - assert_eq!(out[0], "open", "{gateway:?}"); - assert_eq!(out[1], "---"); - assert_eq!(out.iter().filter(|i| *i == "open").count(), 1); - // 挪走之后不留两条相邻的分隔线,也不以分隔线结尾 - assert!( - !out.windows(2).any(|w| w[0] == "---" && w[1] == "---"), - "{gateway:?} {out:?}" - ); - assert_ne!(out.last().map(String::as_str), Some("---")); - } - } - - /// Linux 上最后的样子:「打开主界面」在最前,其余照模型的顺序 - #[test] - fn the_rest_keeps_the_order_of_the_model() { - let linux = |gateway| ids(&open_first(&build(&snapshot(gateway), Style::default()).1)); - assert_eq!( - linux(Gateway::Running), - [ - "open", - "---", - "header", - "---", - "·", - "·", - "---", - "group:主力", - "copy-address", - "copy-key", - "---", - "settings", - "connections", - "update", - "---", - "quit", - ] - ); - assert_eq!( - linux(Gateway::Failed), - [ - "open", - "---", - "header", - "---", - "restart", - "why", - "---", - "settings", - "connections", - "update", - "---", - "quit", - ] - ); - } - - #[test] - fn a_menu_without_it_is_left_alone() { - let rows = vec![Row::Separator]; - assert_eq!(open_first(&rows), rows); - } - /// 菜单换了一版之后,旧菜单上的点击不算数:按下标,它会落到新菜单的另一项上 #[test] fn a_click_on_a_replaced_menu_does_nothing() { diff --git a/src-tauri/src/notices/commands.rs b/src-tauri/src/notices/commands.rs index 85491f02..c16d3432 100644 --- a/src-tauri/src/notices/commands.rs +++ b/src-tauri/src/notices/commands.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use crate::error::Out; use crate::notices; /// 现在挂着的通知。**关窗期间发生的事也在里面** —— 判定在 Rust 侧,界面来取 @@ -12,20 +13,33 @@ pub fn notices_list(notices: tauri::State<'_, Arc>) -> Vec>, key: String) { - notices.mark_read(&key); +pub async fn mark_notice_read( + notices: tauri::State<'_, Arc>, + key: String, +) -> Out<()> { + off_main(¬ices, move |n| n.mark_read(&key)).await } /// 全部看过了 #[tauri::command] -pub fn mark_all_notices_read(notices: tauri::State<'_, Arc>) { - notices.mark_all_read(); +pub async fn mark_all_notices_read(notices: tauri::State<'_, Arc>) -> Out<()> { + off_main(¬ices, |n| n.mark_all_read()).await } /// 清空提醒列表 #[tauri::command] -pub fn clear_notices(notices: tauri::State<'_, Arc>) { - notices.clear_all(); +pub async fn clear_notices(notices: tauri::State<'_, Arc>) -> Out<()> { + off_main(¬ices, |n| n.clear_all()).await +} + +/// 改了列表就要落盘(`notices.json` 整份换上去之前先落盘,macOS 上一次好几毫秒): +/// **放在阻塞线程上**。同步命令跑在主线程上,那几毫秒里整个界面都不动 +async fn off_main( + notices: &Arc, + f: impl FnOnce(¬ices::Notices) + Send + 'static, +) -> Out<()> { + let n = notices.clone(); + crate::clients::blocking(move || f(&n)).await } /// 点通知新建的窗口挂上之后,来取要落的那一页 diff --git a/src-tauri/src/notices/mod.rs b/src-tauri/src/notices/mod.rs index b80311bb..93d9def1 100644 --- a/src-tauri/src/notices/mod.rs +++ b/src-tauri/src/notices/mod.rs @@ -1016,7 +1016,7 @@ pub fn open_from_notification(app: &tauri::AppHandle, key: &str) { // 新版本那一条不落在哪一页:它要的是更新窗口 if crate::updater::is_update_notice(key) { if let Some(n) = ¬ices { - n.mark_read(key); + read_off_main(n, key); } crate::updater::show_pending_update(app); return; @@ -1027,11 +1027,18 @@ pub fn open_from_notification(app: &tauri::AppHandle, key: &str) { .unwrap_or_else(|| rules::default_view(key).to_string()); // 点了通知就是看过了:应用里那一条不必再数 if let Some(n) = ¬ices { - n.mark_read(key); + read_off_main(n, key); } open_view(app, view); } +/// 标为已读,**在阻塞线程上**:标了要落盘(整份换上去之前先落盘,macOS 上一次好几毫秒), +/// 而点通知、点菜单里那一条都是在主线程上到这里的 +fn read_off_main(notices: &Arc, key: &str) { + let (n, key) = (notices.clone(), key.to_string()); + tauri::async_runtime::spawn_blocking(move || n.mark_read(&key)); +} + /// 点开一条通知的链接:`thinkwatch://notice/<百分号编码的键>`。 /// /// Windows 的 toast 点下去走的是**协议激活**:系统拉起 `thinkwatch://` 的处理程序, diff --git a/src-tauri/src/notices/rules.rs b/src-tauri/src/notices/rules.rs index c3b094c4..b7165cb1 100644 --- a/src-tauri/src/notices/rules.rs +++ b/src-tauri/src/notices/rules.rs @@ -920,6 +920,75 @@ pub fn wedged() -> Signal { .now() } +/// 应用常驻的那个图标在哪:macOS 的菜单栏、Windows 的通知区域、Linux 的系统托盘。 +/// 中文和英文各一份,英文是句中的写法(小写) +fn tray_place() -> (&'static str, &'static str) { + if cfg!(target_os = "macos") { + ("菜单栏", "menu bar") + } else if cfg!(windows) { + ("通知区域", "notification area") + } else { + ("系统托盘", "system tray") + } +} + +/// 启动时那个常驻图标没建起来([`tray_place`])。**应用照常跑**:网关、窗口都在,只是少了 +/// 那个入口。原因(系统的原话)只进日志,同 [`wedged`] 那几条:通知在锁屏上也看得见 +pub fn tray_failed() -> Signal { + let (zh, _) = tray_place(); + // 英文标题每个词首字母大写,见模块头上 + let title_en = if cfg!(target_os = "macos") { + "Menu Bar Icon Could Not Be Shown" + } else if cfg!(windows) { + "Notification Area Icon Could Not Be Shown" + } else { + "System Tray Icon Could Not Be Shown" + }; + Signal::raised( + "startup:tray", + Level::Warning, + tr!(format!("{zh}图标未能显示"), title_en.to_string()), + ) + .body( + tr!( + "网关照常运行。窗口关闭后,再次打开应用即可回到主界面。", + "The gateway keeps running. After the window is closed, opening the app again brings it back." + ) + .to_string(), + ) + .event() +} + +/// 启动时主窗口没开起来。网关、常驻图标照常,从图标那里可以再开一次 +pub fn window_failed() -> Signal { + let (zh, en) = tray_place(); + Signal::raised( + "startup:window", + Level::Warning, + tr!("主窗口未能打开", "Main Window Could Not Be Opened"), + ) + .body(tr!( + format!("网关照常运行。可以从{zh}再次打开主界面。"), + format!("The gateway keeps running. The window can be opened again from the {en}.") + )) + .event() +} + +/// 启动时要先选连接,连接选择却没开起来。这时什么都还没连;常驻图标的「连接」里照样选得了 +pub fn picker_failed() -> Signal { + let (zh, en) = tray_place(); + Signal::raised( + "startup:picker", + Level::Warning, + tr!("连接选择未能打开", "Connection Picker Could Not Be Opened"), + ) + .body(tr!( + format!("尚未连接 core。可以在{zh}的「连接」中选择要连接的 core。"), + format!("No core is connected yet. One can be chosen under Connection in the {en}.") + )) + .event() +} + /// 和远程 core 的连接断了。**只报一次**:总线按键去重,重连的每一次失败都不再说; /// 连上之后由 [`remote_back`] 收起 pub fn remote_lost(name: &str) -> Signal { diff --git a/src-tauri/src/notices/tests.rs b/src-tauri/src/notices/tests.rs index a0faa09b..6edc6538 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -592,6 +592,19 @@ async fn a_notice_still_on_hold_does_not_pop_after_switching_to_the_app() { assert_eq!(b.bus.list().len(), 1, "列表里照样留着"); } +/// 启动时图标、窗口没建起来:**当场就说**(应用刚打开,用户正看着),窗口没开起来的时候 +/// 系统通知是唯一说得出来的地方;点开落在设置页 +#[tokio::test(start_paused = true)] +async fn a_window_or_icon_that_failed_at_startup_is_said_at_once() { + let b = bed(); + b.bus.ingest(rules::tray_failed(), T0); + b.bus.ingest(rules::window_failed(), T0); + assert_eq!(b.titles().len(), 2, "{:?}", b.titles()); + for key in ["startup:tray", "startup:window", "startup:picker"] { + assert_eq!(b.bus.view_of(key), "settings", "{key}"); + } +} + /// 点开一条已经不在列表里的通知,落到能处理它的那一页 #[test] fn every_key_lands_on_the_page_that_handles_it() { @@ -1143,9 +1156,13 @@ fn in_english_no_rule_writes_a_chinese_word() { signals.extend(states.iter().flat_map(rules::from_core_state)); signals.push(rules::wedged()); signals.extend(rules::scan_alert(2)); + // 启动时图标、窗口没建起来 + signals.push(rules::tray_failed()); + signals.push(rules::window_failed()); + signals.push(rules::picker_failed()); assert_eq!( signals.len(), - events.len() + states.len() + 2, + events.len() + states.len() + 5, "每一件都该说一句" ); for s in &signals { diff --git a/src-tauri/src/portable.rs b/src-tauri/src/portable.rs index 29cae6c9..fa095057 100644 --- a/src-tauri/src/portable.rs +++ b/src-tauri/src/portable.rs @@ -684,16 +684,9 @@ mod tests { #[cfg(windows)] #[test] fn a_data_folder_of_another_account_is_recognised() { + use crate::wide; use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, SetFileSecurityW}; use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; - - fn wide(p: &Path) -> Vec { - use std::os::windows::ffi::OsStrExt; - p.as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect() - } /// 建一个只有 SYSTEM 能进的目录 fn locked(dir: &Path) { crate::private_dir::with_security_attributes("D:P(A;OICI;FA;;;SY)", |sa| { diff --git a/src-tauri/src/prefs.rs b/src-tauri/src/prefs.rs index a82bfa21..ec5b72f4 100644 --- a/src-tauri/src/prefs.rs +++ b/src-tauri/src/prefs.rs @@ -136,12 +136,31 @@ pub fn save(dir: &Path, p: &Prefs) -> anyhow::Result<()> { /// **不能拿一个只填了一项的 `Prefs` 去存。**那样改一个开关会把其他设置 /// 一起冲回出厂值 —— 加上语言这一项之前,存设置的地方正是这么写的。 pub fn update(dir: &Path, f: impl FnOnce(&mut Prefs)) -> anyhow::Result { + // **一次只改一处。**读、改、写之间别处也在改的话,后写完的那一份会把先写的那一项 + // 冲掉 —— 改设置的命令各在各的阻塞线程上跑([`change`]),界面上接连改两项就是两处 + // 同时在改 + static ONE_AT_A_TIME: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); let mut p = load(dir); f(&mut p); save(dir, &p)?; Ok(p) } +/// 设置页改一项([`update`]),写不进去就说成给人看的一句。 +/// +/// **在阻塞线程上写。**整份换上去之前要先落盘(`atomic_file`),macOS 上一次好几毫秒; +/// 同步命令跑在主线程上,那几毫秒里整个界面都不动 +pub async fn change(f: impl FnOnce(&mut Prefs) + Send + 'static) -> crate::error::Out { + let saved = crate::clients::blocking(move || update(&crate::data_dir(), f)).await?; + saved.map_err(|e| { + crate::error::CmdError::from(tr!( + format!("无法保存设置:{e:#}"), + format!("The setting could not be saved: {e:#}") + )) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -187,6 +206,27 @@ mod tests { assert_eq!(p.notices, Mode::Off); } + /// **几处同时改也不冲掉别的**:改设置的命令各在各的阻塞线程上跑,界面上接连点两下 + /// 就是两处同时在读、改、写 + #[test] + fn changes_made_at_the_same_time_all_stay() { + let (_tmp, dir) = tmp(); + for _ in 0..10 { + std::thread::scope(|s| { + s.spawn(|| update(&dir, |p| p.language = Some(Lang::En)).unwrap()); + s.spawn(|| update(&dir, |p| p.check_updates = false).unwrap()); + s.spawn(|| update(&dir, |p| p.notices = Mode::Off).unwrap()); + s.spawn(|| update(&dir, |p| p.theme = Some(Theme::Dark)).unwrap()); + }); + let p = load(&dir); + assert_eq!(p.language, Some(Lang::En)); + assert!(!p.check_updates); + assert_eq!(p.notices, Mode::Off); + assert_eq!(p.theme, Some(Theme::Dark)); + save(&dir, &Prefs::default()).unwrap(); + } + } + /// **每一项都不是出厂值**:哪一项没被读回来,这里就对不上 #[test] fn settings_survive_a_round_trip() { diff --git a/src-tauri/src/private_dir.rs b/src-tauri/src/private_dir.rs index 271dc42d..e2eb58f9 100644 --- a/src-tauri/src/private_dir.rs +++ b/src-tauri/src/private_dir.rs @@ -48,6 +48,7 @@ mod imp { mod imp { use std::path::Path; + use crate::wide; use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree}; use windows_sys::Win32::Security::Authorization::{ ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, @@ -78,14 +79,6 @@ mod imp { }) } - fn wide(p: &Path) -> Vec { - use std::os::windows::ffi::OsStrExt; - p.as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect() - } - /// 当前用户的 SID,写成 `S-1-5-21-…` 那种字符串。 /// /// **问令牌要,不写死任何东西。**SDDL 里有 `CO`(Creator Owner)这类 diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index 0067c44b..4b7f6b55 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -11,15 +11,9 @@ use std::path::Path; use std::sync::Arc; use crate::error::Out; +use crate::notices::now_ms; use crate::wire; -pub(crate) fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - /// 把一条发现变成给界面看的样子。 pub fn finding_view(f: &tw_scan::report::Finding) -> wire::ScanFinding { use tw_scan::report::Level; @@ -48,14 +42,22 @@ pub fn finding_view(f: &tw_scan::report::Finding) -> wire::ScanFinding { } } +/// 扫描用的规则:网关的内置规则全集。 +/// +/// **只用内置规则。**安全页上的规则只作用于经过网关的请求:在那边停用一条误报,不该让 +/// 这边悄悄少查一样东西。也因此它不会变,**编译一次**:二十来条正则每扫一遍都重新编译, +/// 比扫描本身还慢好几倍 +fn rules() -> &'static tw_guard::tools::rules::Rules { + static RULES: std::sync::LazyLock = + std::sync::LazyLock::new(tw_guard::tools::rules::scan_rules); + &RULES +} + /// 扫一遍用户级的配置面。 pub fn scan(home: &Path) -> wire::ScanReport { - // **只用内置规则。**安全页上的规则只作用于经过网关的请求:在那边停用 - // 一条误报,不该让这边悄悄少查一样东西 - let rules = tw_guard::tools::rules::scan_rules(); let sources = tw_scan::sources::user_level(home, &crate::clients::locations::moved(home)); let scanned = sources.len(); - let r = tw_scan::report::scan(&sources, &rules); + let r = tw_scan::report::scan(&sources, rules()); wire::ScanReport { conflicting: tw_scan::report::conflicting(&r.mcp), findings: r.findings.iter().map(finding_view).collect(), @@ -100,10 +102,11 @@ pub fn scan(home: &Path) -> wire::ScanReport { } } -/// MCP 页打开时扫的那一遍。 +/// MCP 页打开时扫的那一遍。**读文件放在阻塞线程上**:几十个文件,配置位置还可能换到了一个 +/// 离线的网络位置上 #[tauri::command] pub async fn scan_clients() -> Out { - Ok(scan(&crate::clients::home_dir())) + crate::clients::blocking(|| scan(&crate::clients::home_dir())).await } /// 此刻的来源和该盯的目录。位置按此刻的设置取:换过位置之后监视会重起 @@ -147,12 +150,10 @@ pub fn spawn_watcher( tauri::async_runtime::spawn(async move { let mut seen = tw_scan::watch::Seen::default(); - // 内置规则,和打开页面时扫的是同一套 - let rules = tw_guard::tools::rules::scan_rules(); - // 扫一遍:这次新出现的可疑内容,和此刻该盯的目录 + // 扫一遍:这次新出现的可疑内容,和此刻该盯的目录。规则和打开页面时扫的是同一套 let scan_now = |seen: &mut tw_scan::watch::Seen| { let (sources, wanted) = look(&home); - let fresh = seen.diff(&tw_scan::report::scan(&sources, &rules).findings); + let fresh = seen.diff(&tw_scan::report::scan(&sources, rules()).findings); (fresh, wanted) }; // 先垫一次底:把此刻已经存在的那些记下来,它们不算「新出现」 @@ -239,7 +240,7 @@ pub fn restart_client_watch(handle: &tauri::AppHandle) { if let wire::LocalEvent::ScanAlert { alerts, .. } = &ev && let Some(signal) = crate::notices::rules::scan_alert(alerts.len()) { - notices.ingest(signal, crate::notices::now_ms()); + notices.ingest(signal, now_ms()); } let _ = h.emit("local-event", ev); }; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 11268116..8ff72941 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -58,13 +58,8 @@ pub fn app_language() -> LanguageView { /// 换语言。**开着的窗口当场换,托盘菜单跟着重建**,不用重启应用。 #[tauri::command] -pub fn set_language(app: tauri::AppHandle, setting: Option) -> Out { - prefs::update(&data_dir(), |p| p.language = setting).map_err(|e| { - tr!( - format!("无法保存设置:{e:#}"), - format!("The setting could not be saved: {e:#}") - ) - })?; +pub async fn set_language(app: tauri::AppHandle, setting: Option) -> Out { + prefs::change(move |p| p.language = setting).await?; i18n::set(i18n::effective(setting)); // 菜单栏的文案跟着换,不等下一个事件 if let Some(state) = app.try_state::() { @@ -101,13 +96,8 @@ pub fn app_theme() -> ThemeView { /// 换外观。**当场生效** —— 换的是窗口的外观,网页里的 /// `prefers-color-scheme` 跟着翻,不用重启也不用重画。 #[tauri::command] -pub fn set_theme(app: tauri::AppHandle, setting: Option) -> Out { - prefs::update(&data_dir(), |p| p.theme = setting).map_err(|e| { - tr!( - format!("无法保存设置:{e:#}"), - format!("The setting could not be saved: {e:#}") - ) - })?; +pub async fn set_theme(app: tauri::AppHandle, setting: Option) -> Out { + prefs::change(move |p| p.theme = setting).await?; theme::apply(&app, setting); Ok(theme_view()) } @@ -201,18 +191,15 @@ pub fn notice_mode(notices: tauri::State<'_, Arc>) -> notices: /// 换一档。**先存盘再生效**:存不进去的话,界面弹回去,总线也还是原来那一档。 /// 工具栏的铃铛听 `notice-mode-changed` —— 关掉之后它不该还在那儿 #[tauri::command] -pub fn set_notice_mode( +pub async fn set_notice_mode( app: tauri::AppHandle, notices: tauri::State<'_, Arc>, mode: notices::Mode, ) -> Out { - prefs::update(&data_dir(), |p| p.notices = mode).map_err(|e| { - tr!( - format!("无法保存设置:{e:#}"), - format!("The setting could not be saved: {e:#}") - ) - })?; - notices.set_mode(mode); + prefs::change(move |p| p.notices = mode).await?; + // 关掉时要清空列表,清空也要落盘 + let n = notices.inner().clone(); + crate::clients::blocking(move || n.set_mode(mode)).await?; let _ = app.emit("notice-mode-changed", mode); Ok(mode) } @@ -225,17 +212,12 @@ pub fn menubar_style() -> menubar::Style { /// 换一档。**先存盘再生效**,和提醒那一档一样;改完菜单栏立刻重画 #[tauri::command] -pub fn set_menubar_style( +pub async fn set_menubar_style( app: tauri::AppHandle, state: tauri::State<'_, AppState>, style: menubar::Style, ) -> Out { - prefs::update(&data_dir(), |p| p.menubar = style).map_err(|e| { - tr!( - format!("无法保存设置:{e:#}"), - format!("The setting could not be saved: {e:#}") - ) - })?; + prefs::change(move |p| p.menubar = style).await?; menubar::set_style(style); state.menubar_now.notify_one(); let _ = app.emit("menubar-style-changed", style); diff --git a/src-tauri/src/single.rs b/src-tauri/src/single.rs index 21e0fe28..a94dadd6 100644 --- a/src-tauri/src/single.rs +++ b/src-tauri/src/single.rs @@ -188,12 +188,9 @@ mod imp { use super::*; use crate::dialog; + use crate::wide; use crate::winreg::plain_path; - fn wide(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() - } - /// 一个内核对象的句柄,离开作用域就关掉 pub(super) struct Handle(HANDLE); diff --git a/src-tauri/src/supervisor/mod.rs b/src-tauri/src/supervisor/mod.rs index 07091375..f729bbd0 100644 --- a/src-tauri/src/supervisor/mod.rs +++ b/src-tauri/src/supervisor/mod.rs @@ -412,6 +412,10 @@ impl Supervisor { } }; self.intentional.store(true, Ordering::SeqCst); + // 下一个 core 要的用户环境现在就开始读,和这一个收尾叠在一起(见 `user_env::read_ahead`) + if self.user_env { + tokio::spawn(user_env::read_ahead()); + } // 温和那一档就够:它自己退干净,守护循环看见就把它拉回来。 if !self.ask_to_exit(pid).await { // **没请动就把记号收回来。**Windows 上控制面不应时再没有别的温和办法,core @@ -744,6 +748,10 @@ impl Supervisor { in_ms: d.as_millis() as u64, }); if !d.is_zero() { + // 下一个要的用户环境趁退避的这几秒读 + if self.user_env { + tokio::spawn(user_env::read_ahead()); + } // 退避最长睡八秒。这中间被要求停,就不必睡完、更不该再起一个 tokio::select! { _ = tokio::time::sleep(d) => {} @@ -762,890 +770,4 @@ impl Supervisor { } #[cfg(test)] -mod tests { - use super::*; - - /// 控制面一问就答应 - fn always_ready() -> Probe { - probe(|| async { true }) - } - - fn sup() -> Supervisor { - Supervisor::new( - PathBuf::from("/nonexistent/twcore"), - Some(PathBuf::from("/tmp/c.yaml")), - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - ) - } - - /// 程序运行不了:**停在「无法启动」、带着原因**,不是停在「正在启动」。以前停在 - /// 后者,界面上一直说「请稍候」,而这件事等多久都不会好。 - #[tokio::test] - async fn a_binary_that_cannot_run_leaves_the_reason_in_the_state() { - let s = sup(); - assert!(s.run_once(false).await.is_err()); - match s.state() { - CoreState::Failed { reason } => { - assert!(reason.contains("/nonexistent/twcore"), "{reason}"); - assert!(!reason.contains("os error"), "{reason}"); - } - other => panic!("该是无法启动,实际 {other:?}"), - } - } - - #[test] - fn why_a_program_cannot_run_is_said_without_os_error_codes() { - use crate::i18n::{Lang, with_lang}; - let said = |e: std::io::Error| with_lang(Lang::Zh, || why_not(&e)); - assert_eq!( - said(std::io::ErrorKind::PermissionDenied.into()), - "没有执行权限" - ); - assert_eq!(said(std::io::ErrorKind::NotFound.into()), "文件不存在"); - assert_eq!( - said(std::io::Error::from_raw_os_error(libc::ENOEXEC)), - "不是这台电脑能运行的程序" - ); - let other = said(std::io::Error::from_raw_os_error(libc::EIO)); - assert!(!other.contains("os error"), "{other}"); - } - - #[test] - fn the_command_always_carries_our_pid() { - // 「让用户认为他俩就是一个程序」的落点:UI 没了 core 跟着退。 - let args = sup().command_args(false); - let i = args - .iter() - .position(|a| a == "--parent") - .expect("必须带 --parent"); - assert_eq!(args[i + 1], std::process::id().to_string()); - } - - #[test] - fn safe_mode_is_expressed_on_the_command_line() { - assert!(!sup().command_args(false).contains(&"--safe".to_string())); - assert!(sup().command_args(true).contains(&"--safe".to_string())); - } - - #[test] - fn an_explicit_config_path_is_passed_through() { - let args = sup().command_args(false); - let i = args.iter().position(|a| a == "--config").unwrap(); - assert_eq!(args[i + 1], "/tmp/c.yaml"); - } - - #[test] - fn without_a_config_we_let_core_pick_the_default() { - // 不要在这里重复一遍默认路径 —— 两处各写一遍就是两处会漂移。 - let s = Supervisor::new( - PathBuf::from("/x"), - None, - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - ); - assert!(!s.command_args(false).contains(&"--config".to_string())); - } - - #[tokio::test] - async fn a_binary_that_does_not_exist_fails_loudly_rather_than_looping() { - // 路径配错时要立刻报错,而不是安静地重试到进安全模式 —— 那会把 - // 一个「装错了」的问题伪装成「core 一直在崩」。 - let s = sup(); - assert!(s.run_once(false).await.is_err()); - } - - #[tokio::test] - async fn restarting_something_that_is_not_running_says_so() { - // 不该静默成功 —— 那会让「配置改了但没生效」多一种成因。 - let s = sup(); - assert!(s.request_restart().await.is_err()); - } - - #[tokio::test] - async fn state_starts_stopped() { - assert_eq!(sup().state(), CoreState::Stopped); - } - - /// 一个指向不存在之处的控制面。 - /// - /// 这些测试从不真的起 core,所以连不上正是对的 —— 请它退出那一步在 - /// unix 上走信号、根本不碰它,在 Windows 上会失败一次然后落到强杀, - /// 而强杀才是这些测试要看的那一步。 - fn test_address() -> tw_api::control::Address { - tw_api::control::Address::in_dir(std::path::Path::new("/tw-no-such-dir-xyz")) - } - - /// 等「迟早该发生的事」最多等多久:core 起来、停下、守护循环收尾。 - /// - /// **这是活性的上限,不是快慢的要求**:只为了出错时报错而不是挂住。平时几毫秒 - /// 的事,机器满载时慢上几秒不算错;「根本不会发生」的错,等多久都抓得到。 - /// 和被测代码自己的超时比快慢的断言(「没等到超时那一档」)比的是传进去的那个 - /// 超时,不是它 - const PATIENCE: Duration = Duration::from_secs(30); - - /// 假 core 认的第一个参数:带着它就当场退出,什么都不做(见 [`warmed`])。守护 - /// 起它时第一个参数是 `serve` - const WARM: &str = "warm"; - - /// 刚写出来的假 core 先空跑一次,再交给测试。 - /// - /// **新写出来的可执行文件,第一次运行要等系统先看过它。**macOS 上这类文件带着 - /// `com.apple.provenance`,第一次 exec 时子进程在跑到第一行之前停住:闲的时候 - /// 零点几秒,几个会话同时在编的时候实测停过 49 秒;同一个文件第二次运行不到 - /// 一毫秒。以前这一段算在测试「core 起来没有」的那五秒里,机器一忙,看 - /// 「起过几次」的两条(`a_core_that_is_still_starting_…`、 - /// `a_core_waiting_out_its_backoff_…`)就超时。慢的是系统、不是守护:放在 - /// 计时之前,不设上限。 - /// - /// 刚写完就运行在 Linux 上可能碰上「文本文件忙」,和守护自己一样等一下再试 - /// (见 `run_once`) - fn warmed(bin: PathBuf) -> PathBuf { - let t0 = Instant::now(); - loop { - let ran = std::process::Command::new(&bin) - .arg(WARM) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - match ran { - Ok(status) => { - assert!(status.success(), "假 core 空跑失败:{status}"); - return bin; - } - Err(e) - if e.kind() == std::io::ErrorKind::ExecutableFileBusy - && t0.elapsed() < PATIENCE => - { - std::thread::sleep(Duration::from_millis(20)); - } - Err(e) => panic!("假 core 起不来:{e}"), - } - } - } - - /// 放假 core 的目录,测试结束(过了、没过)就删掉。 - /// - /// **拿着它的变量要比守护活得久**:守护起的假 core 就在这里面,计数的那种每起一次 - /// 还往里写一行。所以每条测试在守护之前声明它(在守护之后析构);测试走到结尾时 - /// 守护循环都已经跑完、假 core 都已经退出。测试没过、提前 panic 时它先被删掉,但 - /// 单线程的运行时不会再往下跑守护循环,不会再起一个、把目录写回来 - fn fake_core_dir(name: &str) -> tempfile::TempDir { - tempfile::Builder::new() - .prefix(&format!("tw-sup-{name}-")) - .tempdir() - .unwrap() - } - - /// 一个不管参数、一直跑到被杀掉的「core」(除了 [`WARM`])。返回它所在的目录 - /// (见 [`fake_core_dir`])和它自己。 - /// - /// **两个平台各写一份。**shebang 和执行位是 unix 的东西;Windows 上 - /// 写一个 `.cmd`,`std::process::Command` 认得它。 - fn long_runner(name: &str) -> (tempfile::TempDir, PathBuf) { - let tmp = fake_core_dir(name); - let dir = tmp.path(); - #[cfg(unix)] - let bin = { - let bin = dir.join("fake-core"); - let script = format!("#!/bin/sh\n[ \"$1\" = {WARM} ] && exit 0\nexec sleep 30\n"); - std::fs::write(&bin, script).unwrap(); - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); - bin - }; - #[cfg(windows)] - let bin = { - let bin = dir.join("fake-core.cmd"); - // `timeout` 要一个控制台,测试进程里没有;`ping` 不要。 - let script = - format!("@if \"%~1\"==\"{WARM}\" exit /b 0\r\n@ping -n 30 127.0.0.1 >nul\r\n"); - std::fs::write(&bin, script).unwrap(); - bin - }; - (tmp, warmed(bin)) - } - - async fn until_running(s: &Supervisor) -> u32 { - let mut rx = s.watch(); - loop { - if let CoreState::Running { pid } = *rx.borrow_and_update() { - return pid; - } - tokio::time::timeout(PATIENCE, rx.changed()) - .await - .expect("core 迟迟没起来") - .unwrap(); - } - } - - /// 这一条是 `stop_and_wait` 存在的理由:为了重启应用而停的 core,**守护 - /// 循环不能把它当成一次崩溃**。当成崩溃的话,要么立刻再起一个去抢锁, - /// 要么进安全模式、把主窗口弹到正在重启的应用上。 - #[tokio::test] - async fn a_core_stopped_for_exit_is_not_restarted() { - let (_dir, bin) = long_runner("stop"); - let s = Arc::new(Supervisor::new( - bin, - None, - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - )); - let looped = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await.unwrap() }) - }; - until_running(&s).await; - - let t0 = Instant::now(); - s.stop_and_wait(Duration::from_secs(5)).await; - // **温和那一档该管用,不用等到强杀** —— 但这句话只在 unix 上成立: - // 这里的假 core 是个脚本,没有控制面,所以温和那一档只能落到信号上, - // 而 Windows 没有信号。真的 core 在两个平台上都答应 `POST /shutdown`, - // 那条路由 tests/control_plane.rs 去证明。 - #[cfg(unix)] - assert!( - t0.elapsed() < Duration::from_secs(5), - "SIGTERM 就该让它退出,不用等到强杀" - ); - #[cfg(not(unix))] - let _ = t0; - // 两个平台都要成立的是这一条:它确实停了,而且没被再拉起来。 - assert_eq!(s.state(), CoreState::Stopped); - assert_eq!(looped.await.unwrap(), Next::Stop, "停下之后不再起"); - } - - /// **安全模式里的 core 答应了控制面,也还是安全模式。**报成「运行中」的话,界面、 - /// 菜单栏都说网关好好的,「网关未在转发」那条提醒也被撤掉 —— 而这时网关根本不 - /// 转发。停它、在安全模式里点「重新启动」,都要够得着这个 core - #[tokio::test] - async fn a_safe_mode_core_stays_in_safe_mode_until_restarted() { - let (_dir, bin) = long_runner("safe"); - let s = Arc::new(Supervisor::new( - bin, - None, - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - )); - let looped = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(true).await.unwrap() }) - }; - let mut rx = s.watch(); - let mut began = false; - loop { - match *rx.borrow_and_update() { - CoreState::SafeMode { pid: Some(_) } => break, - CoreState::SafeMode { pid: None } => began = true, - // 那个任务还没跑到第一行 - CoreState::Stopped if !began => {} - ref other => panic!("安全模式里不该出现 {other:?}"), - } - tokio::time::timeout(PATIENCE, rx.changed()) - .await - .expect("迟迟没起来") - .unwrap(); - } - - #[cfg(unix)] - { - // 点「重新启动」:这个 core 退出,守护按正常模式再起(温和那一档在这里只能 - // 走信号,Windows 上没有,所以只在 unix 上看) - s.request_restart().await.unwrap(); - assert_eq!(looped.await.unwrap(), Next::Again); - } - #[cfg(not(unix))] - { - s.stop_and_wait(Duration::from_secs(5)).await; - assert_eq!(looped.await.unwrap(), Next::Stop); - } - } - - /// 没在跑就当场返回,**一点都不等**。 - /// - /// 用暂停的时钟量:它只在运行时无事可做、等着计时器的时候才往前跳,等了那个超时 - /// 就量出整整五秒,一点没等就是零。以前看墙上时钟、限一百毫秒,机器满载时线程 - /// 被晾上一会儿就会误报 - #[tokio::test(start_paused = true)] - async fn stopping_what_is_not_running_returns_at_once() { - let s = sup(); - let t0 = tokio::time::Instant::now(); - s.stop_and_wait(Duration::from_secs(5)).await; - assert_eq!(t0.elapsed(), Duration::ZERO); - } - - /// **控制面答应之前是「启动中」,不是「运行中」。**界面见到「运行中」就去 - /// 取数,而那时 socket 还没建好,拿回来的是一句「连不上」 - #[tokio::test] - async fn a_core_is_starting_until_its_control_plane_answers() { - let asked = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let third_time = { - let asked = asked.clone(); - probe(move || { - let n = asked.fetch_add(1, Ordering::SeqCst); - async move { n >= 3 } - }) - }; - let (_dir, bin) = long_runner("ready"); - let s = Arc::new(Supervisor::new( - bin, - None, - third_time, - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - )); - let mut rx = s.watch(); - let looped = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await }) - }; - let mut seen = Vec::new(); - loop { - tokio::time::timeout(PATIENCE, rx.changed()) - .await - .expect("迟迟没有就绪") - .unwrap(); - let now = rx.borrow_and_update().clone(); - if let CoreState::Running { .. } = now { - break; - } - seen.push(now); - } - assert_eq!(seen, vec![CoreState::Starting]); - assert!(asked.load(Ordering::SeqCst) >= 4, "问过几次才答应的"); - s.stop_and_wait(Duration::from_secs(5)).await; - looped.await.unwrap().unwrap(); - } - - /// 进程在、控制面一直不答应:**不能永远停在「启动中」**。按一次失败算, - /// 换掉它再起 —— 和启动就崩一个待遇,连着几次就进安全模式 - #[tokio::test(start_paused = true)] - async fn a_core_that_never_answers_is_replaced() { - let (_dir, bin) = long_runner("never"); - let s = Supervisor::new( - bin, - None, - probe(|| async { false }), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - ); - let next = s.run_once(false).await.unwrap(); - assert_eq!(next, Next::Again); - assert!( - matches!(s.state(), CoreState::Restarting { attempt: 1, .. }), - "{:?}", - s.state() - ); - } - - /// 停过一次、又接回来之后,core 再死就是崩溃,**不是「按要求停止」**。 - /// - /// Windows 上更新没装成(UAC 点了「否」)走的就是这条:为了装更新停了 - /// core,没装成又接回来。记号要是留着,从那以后网关崩了不会再起。 - #[tokio::test] - async fn after_resuming_a_crash_is_a_crash_again() { - let (_dir, bin) = long_runner("resume"); - let s = Arc::new(Supervisor::new( - bin, - None, - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - )); - let first = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await.unwrap() }) - }; - until_running(&s).await; - s.stop_and_wait(Duration::from_secs(5)).await; - assert_eq!(first.await.unwrap(), Next::Stop); - - s.resume(); - let second = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await.unwrap() }) - }; - let pid = until_running(&s).await; - s.kill_now(pid); - assert_ne!( - second.await.unwrap(), - Next::Stop, - "接回来之后的崩溃被当成了按要求停止" - ); - } - - /// 对照组:改配置那种重启照旧立刻再起 —— 两个标志没有串。 - /// - /// **只在 unix 上跑**:这个假 core 没有控制面,请它退只能靠信号。Windows 上没有信号 - /// 这条退路,重启本来就请不动(见 `a_restart_that_could_not_be_asked_for_leaves_no_mark`) - /// —— 以前那里「请」了个空也照样说成了,这一条是等假 core 自己跑完三十秒才过的 - #[cfg(unix)] - #[tokio::test] - async fn a_requested_restart_still_comes_back() { - let (_dir, bin) = long_runner("restart"); - let s = Arc::new(Supervisor::new( - bin, - None, - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - )); - let looped = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await.unwrap() }) - }; - until_running(&s).await; - s.request_restart().await.unwrap(); - assert_eq!(looped.await.unwrap(), Next::Again); - } - - /// 假 core 起来之后做什么 - #[derive(Clone, Copy)] - enum Acts { - /// 一直跑到被杀掉 - Stays, - /// 一直跑,请它退出也不理(unix 上不理 SIGTERM;Windows 上本来就只能强杀) - IgnoresTheAsk, - /// 当场退出:启动就崩 - Crashes, - } - - /// 一个每起一次就往 `starts` 那个文件里记一行的「core」(unix 上记的是它的 pid)。 - /// 返回它所在的目录(见 [`fake_core_dir`])、程序和那个文件。空跑([`warmed`])不记 - fn counting_core(name: &str, acts: Acts) -> (tempfile::TempDir, PathBuf, PathBuf) { - let tmp = fake_core_dir(name); - let dir = tmp.path(); - let starts = dir.join("starts"); - #[cfg(unix)] - let bin = { - let bin = dir.join("fake-core"); - let then = match acts { - Acts::Stays => "exec sleep 30", - // 忽略掉的信号在 exec 之后还是忽略的 - Acts::IgnoresTheAsk => "trap '' TERM\nexec sleep 30", - Acts::Crashes => "exit 1", - }; - let script = format!( - "#!/bin/sh\n[ \"$1\" = {WARM} ] && exit 0\necho $$ >> '{}'\n{then}\n", - starts.display() - ); - std::fs::write(&bin, script).unwrap(); - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); - bin - }; - #[cfg(windows)] - let bin = { - let bin = dir.join("fake-core.cmd"); - let then = match acts { - Acts::Stays | Acts::IgnoresTheAsk => "@ping -n 30 127.0.0.1 >nul", - Acts::Crashes => "@exit /b 1", - }; - let script = format!( - "@if \"%~1\"==\"{WARM}\" exit /b 0\r\n@echo x>> \"{}\"\r\n{then}\r\n", - starts.display() - ); - std::fs::write(&bin, script).unwrap(); - bin - }; - (tmp, warmed(bin), starts) - } - - /// **安全模式里的 core 也退出了,界面拿到的是它最后说的那句话**,不是一句「已停止」 - #[tokio::test] - async fn a_safe_mode_core_that_exits_reports_what_it_said() { - let tmp = fake_core_dir("exits"); - let dir = tmp.path(); - #[cfg(unix)] - let bin = { - let bin = dir.join("fake-core"); - let script = format!( - "#!/bin/sh\n[ \"$1\" = {WARM} ] && exit 0\necho 'Error: loading /x/config.yaml' >&2\necho '' >&2\necho 'Caused by:' >&2\necho ' 已有 twcore 实例正在运行(pid 7)。' >&2\nexit 1\n" - ); - std::fs::write(&bin, script).unwrap(); - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); - bin - }; - #[cfg(windows)] - let bin = { - let bin = dir.join("fake-core.cmd"); - let script = format!( - "@if \"%~1\"==\"{WARM}\" exit /b 0\r\n@echo Error: loading /x/config.yaml 1>&2\r\n@echo Caused by: 1>&2\r\n@echo another twcore is running 1>&2\r\n@exit /b 1\r\n" - ); - std::fs::write(&bin, script).unwrap(); - bin - }; - let s = Supervisor::new( - warmed(bin), - None, - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - ); - assert_eq!(s.run_once(true).await.unwrap(), Next::Stop); - let CoreState::Exited { reason } = s.state() else { - panic!("该报 Exited,实际是 {:?}", s.state()); - }; - assert!(reason.starts_with("loading /x/config.yaml\n"), "{reason}"); - #[cfg(unix)] - assert!( - reason.ends_with("已有 twcore 实例正在运行(pid 7)。"), - "{reason}" - ); - } - - #[test] - fn the_last_error_is_the_whole_chain_without_numbers() { - let lines: Vec = [ - "some earlier noise", - "Error: loading /x/config.yaml", - "", - "Caused by:", - " 0: schema error (line 3): bad", - " 1: deeper", - ] - .into_iter() - .map(String::from) - .collect(); - assert_eq!( - last_error(&lines).as_deref(), - Some("loading /x/config.yaml\nschema error (line 3): bad\ndeeper") - ); - // 没有 `Error:`(被信号杀掉、崩溃)就取最后几行 - let crash: Vec = ["a", "", "thread 'main' panicked at x", "boom"] - .into_iter() - .map(String::from) - .collect(); - assert_eq!( - last_error(&crash).as_deref(), - Some("a\nthread 'main' panicked at x\nboom") - ); - assert_eq!(last_error(&[]), None); - } - - /// 起过几次 - fn starts(file: &std::path::Path) -> usize { - std::fs::read_to_string(file).map_or(0, |s| s.lines().count()) - } - - /// 等到起过 `times` 次。只能看文件,所以隔一会儿看一眼 - async fn until_started(file: &std::path::Path, times: usize) { - let started = async { - while starts(file) < times { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }; - tokio::time::timeout(PATIENCE, started) - .await - .expect("core 迟迟没起来"); - } - - /// **还在启动的 core 也停得下来。**以前只认「运行中」:切到远程的那一刻本机的 core - /// 要是还没答应控制面,它照样起来、照样占着网关端口。停下之后照样接得回来 - #[tokio::test] - async fn a_core_that_is_still_starting_is_stopped_and_can_start_again() { - let answer = Arc::new(AtomicBool::new(false)); - let ready = { - let answer = answer.clone(); - probe(move || { - let yes = answer.load(Ordering::SeqCst); - async move { yes } - }) - }; - let (_dir, bin, started) = counting_core("starting", Acts::Stays); - let s = Arc::new(Supervisor::new( - bin, - None, - ready, - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - )); - let looped = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await.unwrap() }) - }; - // 进程起来了,控制面还没答应 - until_started(&started, 1).await; - assert_eq!(s.state(), CoreState::Starting); - - // 停还在启动的 core 不走控制面(按句柄直接杀),两个平台上都不该等到超时 - let t0 = Instant::now(); - s.stop_and_wait(PATIENCE).await; - assert_eq!(s.state(), CoreState::Stopped, "还在启动的 core 没被停下"); - assert!(t0.elapsed() < PATIENCE, "等到超时才停下"); - let next = tokio::time::timeout(PATIENCE, looped) - .await - .expect("守护循环没有停下") - .unwrap(); - assert_eq!(next, Next::Stop); - #[cfg(unix)] - { - let pid: i32 = std::fs::read_to_string(&started) - .unwrap() - .trim() - .parse() - .unwrap(); - // SAFETY: 信号 0 只问这个 pid 还在不在,什么都不送 - let alive = unsafe { libc::kill(pid, 0) } == 0; - assert!(!alive, "进程还在跑"); - } - - // 接回来:记号撤掉,照常起到「运行中」 - s.resume(); - answer.store(true, Ordering::SeqCst); - let again = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await.unwrap() }) - }; - until_running(&s).await; - until_started(&started, 2).await; - s.stop_and_wait(Duration::from_secs(5)).await; - assert_eq!(again.await.unwrap(), Next::Stop); - } - - /// **在退避里等着重起的 core 也停得下来**:不会睡醒了再起一个 - #[tokio::test] - async fn a_core_waiting_out_its_backoff_is_not_started_again() { - let (_dir, bin, started) = counting_core("backoff", Acts::Crashes); - let s = Arc::new(Supervisor::new( - bin, - None, - probe(|| async { false }), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - )); - // 和 `gateway::supervise` 一样,一轮接一轮地跑 - let looped = { - let s = s.clone(); - tokio::spawn(async move { - loop { - match s.run_once(false).await.unwrap() { - Next::Again => continue, - other => return other, - } - } - }) - }; - // 第一次崩溃立刻重起,第二次之后要等一秒 - let mut rx = s.watch(); - let backing_off = - rx.wait_for(|st| matches!(st, CoreState::Restarting { in_ms, .. } if *in_ms > 0)); - let _ = tokio::time::timeout(PATIENCE, backing_off) - .await - .expect("迟迟没进退避") - .unwrap(); - - s.stop_and_wait(Duration::from_secs(5)).await; - assert_eq!(s.state(), CoreState::Stopped, "退避里的 core 没被停下"); - let next = tokio::time::timeout(PATIENCE, looped) - .await - .expect("守护循环没有停下") - .unwrap(); - assert_eq!(next, Next::Stop); - assert_eq!(starts(&started), 2, "睡醒之后又起了一个"); - } - - /// 要停的请求落在两轮之间(比如按要求重启的那一下):**下一轮不起** - #[tokio::test] - async fn a_stop_between_two_rounds_keeps_the_next_one_from_starting() { - let (_dir, bin, started) = counting_core("between", Acts::Stays); - let s = Supervisor::new( - bin, - None, - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - ); - // 此刻没有在跑的:只是把记号立起来 - s.stop_and_wait(Duration::from_secs(5)).await; - let next = tokio::time::timeout(PATIENCE, s.run_once(false)) - .await - .expect("要停的时候又起了一个") - .unwrap(); - assert_eq!(next, Next::Stop); - assert_eq!(s.state(), CoreState::Stopped); - assert_eq!(starts(&started), 0); - } - - /// 卡死的 core 听了请求自己退了:**等到它走就收手**,不再干等三秒、再按那个 pid 强杀 —— - /// 那时守护循环已经在起下一个,这个号可能已经是系统里别的进程的了。 - /// - /// 只在 unix 上:这里的假 core 没有控制面,温和那一档只能走信号 - #[cfg(unix)] - #[tokio::test] - async fn a_wedged_core_that_leaves_when_asked_is_not_killed_afterwards() { - let (_dir, bin) = long_runner("wedged-leaves"); - let s = Arc::new(Supervisor::new( - bin, - None, - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - )); - let looped = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await.unwrap() }) - }; - until_running(&s).await; - - let t0 = Instant::now(); - s.report_wedged().await.unwrap(); - assert!( - t0.elapsed() < Duration::from_secs(2), - "它走了还等了 {:?} 才回来", - t0.elapsed() - ); - // 照一次失败算,守护循环接着起下一个 - assert_eq!(looped.await.unwrap(), Next::Again); - assert!( - matches!(s.state(), CoreState::Restarting { attempt: 1, .. }), - "{:?}", - s.state() - ); - } - - /// 请它退出也不走的,**照样强杀、换掉**:收手只收在它自己走了的时候 - #[tokio::test] - async fn a_wedged_core_that_ignores_the_ask_is_still_killed() { - let (_dir, bin, _) = counting_core("wedged-stays", Acts::IgnoresTheAsk); - let s = Arc::new(Supervisor::new( - bin, - None, - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - )); - let looped = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await.unwrap() }) - }; - until_running(&s).await; - s.report_wedged().await.unwrap(); - let next = tokio::time::timeout(PATIENCE, looped) - .await - .expect("强杀之后它还在") - .unwrap(); - assert_eq!(next, Next::Again); - } - - /// 一个一定不存在的 pid:比 Linux 的 `pid_max` 上限(2^22)和 macOS 的(99999)都大, - /// 又还是正数 —— 转成 `i32` 是负数的话,`kill` 会送给一整个进程组 - const NO_SUCH_PID: u32 = i32::MAX as u32; - - /// **没请动的重启不留记号,也不说成了。**Windows 上控制面不应时 core 照旧跑着,重启 - /// 没有发生;记号留着的话,它下一次真的崩溃会被当成这次重启:不计入退避,连着崩 - /// 也进不了安全模式 - #[tokio::test] - async fn a_restart_that_could_not_be_asked_for_leaves_no_mark() { - let (_dir, bin, _) = counting_core("restart-unasked", Acts::Crashes); - let s = Supervisor::new( - bin, - None, - probe(|| async { false }), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - ); - // 请不到它:控制面不在,这个 pid 上也没有进程收信号(两个平台上都是没请动) - s.set(CoreState::Running { pid: NO_SUCH_PID }); - assert!(s.request_restart().await.is_err(), "没重启成却说成了"); - - // 之后 core 真的崩了:照一次失败算,不是「按要求重启」 - assert_eq!(s.run_once(false).await.unwrap(), Next::Again); - assert!( - matches!(s.state(), CoreState::Restarting { attempt: 1, .. }), - "{:?}", - s.state() - ); - } - - /// 重启的请求撞上停止(点了「重新启动」、紧接着切到远程):守护循环先认停止。 - /// 那个重启的记号**不能带进下一轮**,不然接回来之后第一次真崩溃会被当成按要求重启 - #[tokio::test] - async fn a_restart_cut_short_by_a_stop_does_not_carry_into_the_next_round() { - let (_dir, bin) = long_runner("restart-then-stop"); - let s = Arc::new(Supervisor::new( - bin, - None, - always_ready(), - test_address(), - PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), - )); - let first = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await.unwrap() }) - }; - until_running(&s).await; - // 两个请求都在守护循环看见 core 退出之前立下(测试跑在单线程的运行时上, - // `join!` 先把两边各推到等待处)。Windows 上重启本来就请不动,不看它的结果 - let (_, restarted) = - tokio::join!(s.stop_and_wait(Duration::from_secs(5)), s.request_restart()); - let _ = restarted; - assert_eq!(first.await.unwrap(), Next::Stop); - - s.resume(); - let second = { - let s = s.clone(); - tokio::spawn(async move { s.run_once(false).await.unwrap() }) - }; - let pid = until_running(&s).await; - s.kill_now(pid); - assert_eq!(second.await.unwrap(), Next::Again); - assert!( - matches!(s.state(), CoreState::Restarting { attempt: 1, .. }), - "接回来之后的崩溃被当成了按要求重启:{:?}", - s.state() - ); - } - - /// 一个握了手就一声不吭的控制面。走回环端口:两个平台都认这一种传输。第一项是放 - /// 配置和端口文件的目录(见 [`fake_core_dir`]),拿着它到测试结束 - async fn silent_control_plane( - name: &str, - ) -> (tempfile::TempDir, tw_api::control::Address, PathBuf) { - let tmp = fake_core_dir(name); - let dir = tmp.path(); - let key = "ab".repeat(32); - let config = dir.join("config.yaml"); - std::fs::write( - &config, - format!("listen:\n control:\n key: \"{key}\"\n"), - ) - .unwrap(); - let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port_file = dir.join("control.port"); - std::fs::write(&port_file, l.local_addr().unwrap().port().to_string()).unwrap(); - tokio::spawn(async move { - let key = tw_link::ControlKey::parse(&key).unwrap(); - let acceptor = tw_link::Acceptor::new(move || Some(key.clone()), "9.9.9"); - let mut held = Vec::new(); - while let Ok((s, _)) = l.accept().await { - // 握手,然后拿着这条连接,请求来了也不答 - if let Ok(accepted) = acceptor.accept(s).await { - held.push(accepted); - } - } - }); - ( - tmp, - tw_api::control::Address::Loopback { port_file }, - config, - ) - } - - /// 控制面握了手却一直不答:**请它退出这一步不能跟着挂住**。心跳换掉卡死的 core、 - /// 装更新、退出应用都要先走这一步 - #[tokio::test] - async fn asking_a_core_whose_control_plane_never_answers_gives_up() { - let (_dir, at, config) = silent_control_plane("silent").await; - let s = Supervisor::new(PathBuf::from("/x"), None, always_ready(), at, config); - let asked = tokio::time::timeout(Duration::from_secs(10), s.ask_to_exit(NO_SUCH_PID)) - .await - .expect("请它退出挂住了"); - assert!(!asked, "控制面没答、信号也没处送,却说请到了"); - } -} +mod tests; diff --git a/src-tauri/src/supervisor/tests.rs b/src-tauri/src/supervisor/tests.rs new file mode 100644 index 00000000..e78d2beb --- /dev/null +++ b/src-tauri/src/supervisor/tests.rs @@ -0,0 +1,883 @@ +use super::*; + +/// 控制面一问就答应 +fn always_ready() -> Probe { + probe(|| async { true }) +} + +fn sup() -> Supervisor { + Supervisor::new( + PathBuf::from("/nonexistent/twcore"), + Some(PathBuf::from("/tmp/c.yaml")), + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ) +} + +/// 程序运行不了:**停在「无法启动」、带着原因**,不是停在「正在启动」。以前停在 +/// 后者,界面上一直说「请稍候」,而这件事等多久都不会好。 +#[tokio::test] +async fn a_binary_that_cannot_run_leaves_the_reason_in_the_state() { + let s = sup(); + assert!(s.run_once(false).await.is_err()); + match s.state() { + CoreState::Failed { reason } => { + assert!(reason.contains("/nonexistent/twcore"), "{reason}"); + assert!(!reason.contains("os error"), "{reason}"); + } + other => panic!("该是无法启动,实际 {other:?}"), + } +} + +#[test] +fn why_a_program_cannot_run_is_said_without_os_error_codes() { + use crate::i18n::{Lang, with_lang}; + let said = |e: std::io::Error| with_lang(Lang::Zh, || why_not(&e)); + assert_eq!( + said(std::io::ErrorKind::PermissionDenied.into()), + "没有执行权限" + ); + assert_eq!(said(std::io::ErrorKind::NotFound.into()), "文件不存在"); + assert_eq!( + said(std::io::Error::from_raw_os_error(libc::ENOEXEC)), + "不是这台电脑能运行的程序" + ); + let other = said(std::io::Error::from_raw_os_error(libc::EIO)); + assert!(!other.contains("os error"), "{other}"); +} + +#[test] +fn the_command_always_carries_our_pid() { + // 「让用户认为他俩就是一个程序」的落点:UI 没了 core 跟着退。 + let args = sup().command_args(false); + let i = args + .iter() + .position(|a| a == "--parent") + .expect("必须带 --parent"); + assert_eq!(args[i + 1], std::process::id().to_string()); +} + +#[test] +fn safe_mode_is_expressed_on_the_command_line() { + assert!(!sup().command_args(false).contains(&"--safe".to_string())); + assert!(sup().command_args(true).contains(&"--safe".to_string())); +} + +#[test] +fn an_explicit_config_path_is_passed_through() { + let args = sup().command_args(false); + let i = args.iter().position(|a| a == "--config").unwrap(); + assert_eq!(args[i + 1], "/tmp/c.yaml"); +} + +#[test] +fn without_a_config_we_let_core_pick_the_default() { + // 不要在这里重复一遍默认路径 —— 两处各写一遍就是两处会漂移。 + let s = Supervisor::new( + PathBuf::from("/x"), + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ); + assert!(!s.command_args(false).contains(&"--config".to_string())); +} + +#[tokio::test] +async fn a_binary_that_does_not_exist_fails_loudly_rather_than_looping() { + // 路径配错时要立刻报错,而不是安静地重试到进安全模式 —— 那会把 + // 一个「装错了」的问题伪装成「core 一直在崩」。 + let s = sup(); + assert!(s.run_once(false).await.is_err()); +} + +#[tokio::test] +async fn restarting_something_that_is_not_running_says_so() { + // 不该静默成功 —— 那会让「配置改了但没生效」多一种成因。 + let s = sup(); + assert!(s.request_restart().await.is_err()); +} + +#[tokio::test] +async fn state_starts_stopped() { + assert_eq!(sup().state(), CoreState::Stopped); +} + +/// 一个指向不存在之处的控制面。 +/// +/// 这些测试从不真的起 core,所以连不上正是对的 —— 请它退出那一步在 +/// unix 上走信号、根本不碰它,在 Windows 上会失败一次然后落到强杀, +/// 而强杀才是这些测试要看的那一步。 +fn test_address() -> tw_api::control::Address { + tw_api::control::Address::in_dir(std::path::Path::new("/tw-no-such-dir-xyz")) +} + +/// 等「迟早该发生的事」最多等多久:core 起来、停下、守护循环收尾。 +/// +/// **这是活性的上限,不是快慢的要求**:只为了出错时报错而不是挂住。平时几毫秒 +/// 的事,机器满载时慢上几秒不算错;「根本不会发生」的错,等多久都抓得到。 +/// 和被测代码自己的超时比快慢的断言(「没等到超时那一档」)比的是传进去的那个 +/// 超时,不是它 +const PATIENCE: Duration = Duration::from_secs(30); + +/// 假 core 认的第一个参数:带着它就当场退出,什么都不做(见 [`warmed`])。守护 +/// 起它时第一个参数是 `serve` +const WARM: &str = "warm"; + +/// 刚写出来的假 core 先空跑一次,再交给测试。 +/// +/// **新写出来的可执行文件,第一次运行要等系统先看过它。**macOS 上这类文件带着 +/// `com.apple.provenance`,第一次 exec 时子进程在跑到第一行之前停住:闲的时候 +/// 零点几秒,几个会话同时在编的时候实测停过 49 秒;同一个文件第二次运行不到 +/// 一毫秒。以前这一段算在测试「core 起来没有」的那五秒里,机器一忙,看 +/// 「起过几次」的两条(`a_core_that_is_still_starting_…`、 +/// `a_core_waiting_out_its_backoff_…`)就超时。慢的是系统、不是守护:放在 +/// 计时之前,不设上限。 +/// +/// 刚写完就运行在 Linux 上可能碰上「文本文件忙」,和守护自己一样等一下再试 +/// (见 `run_once`) +fn warmed(bin: PathBuf) -> PathBuf { + let t0 = Instant::now(); + loop { + let ran = std::process::Command::new(&bin) + .arg(WARM) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + match ran { + Ok(status) => { + assert!(status.success(), "假 core 空跑失败:{status}"); + return bin; + } + Err(e) + if e.kind() == std::io::ErrorKind::ExecutableFileBusy + && t0.elapsed() < PATIENCE => + { + std::thread::sleep(Duration::from_millis(20)); + } + Err(e) => panic!("假 core 起不来:{e}"), + } + } +} + +/// 放假 core 的目录,测试结束(过了、没过)就删掉。 +/// +/// **拿着它的变量要比守护活得久**:守护起的假 core 就在这里面,计数的那种每起一次 +/// 还往里写一行。所以每条测试在守护之前声明它(在守护之后析构);测试走到结尾时 +/// 守护循环都已经跑完、假 core 都已经退出。测试没过、提前 panic 时它先被删掉,但 +/// 单线程的运行时不会再往下跑守护循环,不会再起一个、把目录写回来 +fn fake_core_dir(name: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("tw-sup-{name}-")) + .tempdir() + .unwrap() +} + +/// 一个不管参数、一直跑到被杀掉的「core」(除了 [`WARM`])。返回它所在的目录 +/// (见 [`fake_core_dir`])和它自己。 +/// +/// **两个平台各写一份。**shebang 和执行位是 unix 的东西;Windows 上 +/// 写一个 `.cmd`,`std::process::Command` 认得它。 +fn long_runner(name: &str) -> (tempfile::TempDir, PathBuf) { + let tmp = fake_core_dir(name); + let dir = tmp.path(); + #[cfg(unix)] + let bin = { + let bin = dir.join("fake-core"); + let script = format!("#!/bin/sh\n[ \"$1\" = {WARM} ] && exit 0\nexec sleep 30\n"); + std::fs::write(&bin, script).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin + }; + #[cfg(windows)] + let bin = { + let bin = dir.join("fake-core.cmd"); + // `timeout` 要一个控制台,测试进程里没有;`ping` 不要。 + let script = format!("@if \"%~1\"==\"{WARM}\" exit /b 0\r\n@ping -n 30 127.0.0.1 >nul\r\n"); + std::fs::write(&bin, script).unwrap(); + bin + }; + (tmp, warmed(bin)) +} + +async fn until_running(s: &Supervisor) -> u32 { + let mut rx = s.watch(); + loop { + if let CoreState::Running { pid } = *rx.borrow_and_update() { + return pid; + } + tokio::time::timeout(PATIENCE, rx.changed()) + .await + .expect("core 迟迟没起来") + .unwrap(); + } +} + +/// 这一条是 `stop_and_wait` 存在的理由:为了重启应用而停的 core,**守护 +/// 循环不能把它当成一次崩溃**。当成崩溃的话,要么立刻再起一个去抢锁, +/// 要么进安全模式、把主窗口弹到正在重启的应用上。 +#[tokio::test] +async fn a_core_stopped_for_exit_is_not_restarted() { + let (_dir, bin) = long_runner("stop"); + let s = Arc::new(Supervisor::new( + bin, + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let looped = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + until_running(&s).await; + + let t0 = Instant::now(); + s.stop_and_wait(Duration::from_secs(5)).await; + // **温和那一档该管用,不用等到强杀** —— 但这句话只在 unix 上成立: + // 这里的假 core 是个脚本,没有控制面,所以温和那一档只能落到信号上, + // 而 Windows 没有信号。真的 core 在两个平台上都答应 `POST /shutdown`, + // 那条路由 tests/control_plane.rs 去证明。 + #[cfg(unix)] + assert!( + t0.elapsed() < Duration::from_secs(5), + "SIGTERM 就该让它退出,不用等到强杀" + ); + #[cfg(not(unix))] + let _ = t0; + // 两个平台都要成立的是这一条:它确实停了,而且没被再拉起来。 + assert_eq!(s.state(), CoreState::Stopped); + assert_eq!(looped.await.unwrap(), Next::Stop, "停下之后不再起"); +} + +/// **安全模式里的 core 答应了控制面,也还是安全模式。**报成「运行中」的话,界面、 +/// 菜单栏都说网关好好的,「网关未在转发」那条提醒也被撤掉 —— 而这时网关根本不 +/// 转发。停它、在安全模式里点「重新启动」,都要够得着这个 core +#[tokio::test] +async fn a_safe_mode_core_stays_in_safe_mode_until_restarted() { + let (_dir, bin) = long_runner("safe"); + let s = Arc::new(Supervisor::new( + bin, + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let looped = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(true).await.unwrap() }) + }; + let mut rx = s.watch(); + let mut began = false; + loop { + match *rx.borrow_and_update() { + CoreState::SafeMode { pid: Some(_) } => break, + CoreState::SafeMode { pid: None } => began = true, + // 那个任务还没跑到第一行 + CoreState::Stopped if !began => {} + ref other => panic!("安全模式里不该出现 {other:?}"), + } + tokio::time::timeout(PATIENCE, rx.changed()) + .await + .expect("迟迟没起来") + .unwrap(); + } + + #[cfg(unix)] + { + // 点「重新启动」:这个 core 退出,守护按正常模式再起(温和那一档在这里只能 + // 走信号,Windows 上没有,所以只在 unix 上看) + s.request_restart().await.unwrap(); + assert_eq!(looped.await.unwrap(), Next::Again); + } + #[cfg(not(unix))] + { + s.stop_and_wait(Duration::from_secs(5)).await; + assert_eq!(looped.await.unwrap(), Next::Stop); + } +} + +/// 没在跑就当场返回,**一点都不等**。 +/// +/// 用暂停的时钟量:它只在运行时无事可做、等着计时器的时候才往前跳,等了那个超时 +/// 就量出整整五秒,一点没等就是零。以前看墙上时钟、限一百毫秒,机器满载时线程 +/// 被晾上一会儿就会误报 +#[tokio::test(start_paused = true)] +async fn stopping_what_is_not_running_returns_at_once() { + let s = sup(); + let t0 = tokio::time::Instant::now(); + s.stop_and_wait(Duration::from_secs(5)).await; + assert_eq!(t0.elapsed(), Duration::ZERO); +} + +/// **控制面答应之前是「启动中」,不是「运行中」。**界面见到「运行中」就去 +/// 取数,而那时 socket 还没建好,拿回来的是一句「连不上」 +#[tokio::test] +async fn a_core_is_starting_until_its_control_plane_answers() { + let asked = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let third_time = { + let asked = asked.clone(); + probe(move || { + let n = asked.fetch_add(1, Ordering::SeqCst); + async move { n >= 3 } + }) + }; + let (_dir, bin) = long_runner("ready"); + let s = Arc::new(Supervisor::new( + bin, + None, + third_time, + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let mut rx = s.watch(); + let looped = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await }) + }; + let mut seen = Vec::new(); + loop { + tokio::time::timeout(PATIENCE, rx.changed()) + .await + .expect("迟迟没有就绪") + .unwrap(); + let now = rx.borrow_and_update().clone(); + if let CoreState::Running { .. } = now { + break; + } + seen.push(now); + } + assert_eq!(seen, vec![CoreState::Starting]); + assert!(asked.load(Ordering::SeqCst) >= 4, "问过几次才答应的"); + s.stop_and_wait(Duration::from_secs(5)).await; + looped.await.unwrap().unwrap(); +} + +/// 进程在、控制面一直不答应:**不能永远停在「启动中」**。按一次失败算, +/// 换掉它再起 —— 和启动就崩一个待遇,连着几次就进安全模式 +#[tokio::test(start_paused = true)] +async fn a_core_that_never_answers_is_replaced() { + let (_dir, bin) = long_runner("never"); + let s = Supervisor::new( + bin, + None, + probe(|| async { false }), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ); + let next = s.run_once(false).await.unwrap(); + assert_eq!(next, Next::Again); + assert!( + matches!(s.state(), CoreState::Restarting { attempt: 1, .. }), + "{:?}", + s.state() + ); +} + +/// 停过一次、又接回来之后,core 再死就是崩溃,**不是「按要求停止」**。 +/// +/// Windows 上更新没装成(UAC 点了「否」)走的就是这条:为了装更新停了 +/// core,没装成又接回来。记号要是留着,从那以后网关崩了不会再起。 +#[tokio::test] +async fn after_resuming_a_crash_is_a_crash_again() { + let (_dir, bin) = long_runner("resume"); + let s = Arc::new(Supervisor::new( + bin, + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let first = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + until_running(&s).await; + s.stop_and_wait(Duration::from_secs(5)).await; + assert_eq!(first.await.unwrap(), Next::Stop); + + s.resume(); + let second = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + let pid = until_running(&s).await; + s.kill_now(pid); + assert_ne!( + second.await.unwrap(), + Next::Stop, + "接回来之后的崩溃被当成了按要求停止" + ); +} + +/// 对照组:改配置那种重启照旧立刻再起 —— 两个标志没有串。 +/// +/// **只在 unix 上跑**:这个假 core 没有控制面,请它退只能靠信号。Windows 上没有信号 +/// 这条退路,重启本来就请不动(见 `a_restart_that_could_not_be_asked_for_leaves_no_mark`) +/// —— 以前那里「请」了个空也照样说成了,这一条是等假 core 自己跑完三十秒才过的 +#[cfg(unix)] +#[tokio::test] +async fn a_requested_restart_still_comes_back() { + let (_dir, bin) = long_runner("restart"); + let s = Arc::new(Supervisor::new( + bin, + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let looped = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + until_running(&s).await; + s.request_restart().await.unwrap(); + assert_eq!(looped.await.unwrap(), Next::Again); +} + +/// 假 core 起来之后做什么 +#[derive(Clone, Copy)] +enum Acts { + /// 一直跑到被杀掉 + Stays, + /// 一直跑,请它退出也不理(unix 上不理 SIGTERM;Windows 上本来就只能强杀) + IgnoresTheAsk, + /// 当场退出:启动就崩 + Crashes, +} + +/// 一个每起一次就往 `starts` 那个文件里记一行的「core」(unix 上记的是它的 pid)。 +/// 返回它所在的目录(见 [`fake_core_dir`])、程序和那个文件。空跑([`warmed`])不记 +fn counting_core(name: &str, acts: Acts) -> (tempfile::TempDir, PathBuf, PathBuf) { + let tmp = fake_core_dir(name); + let dir = tmp.path(); + let starts = dir.join("starts"); + #[cfg(unix)] + let bin = { + let bin = dir.join("fake-core"); + let then = match acts { + Acts::Stays => "exec sleep 30", + // 忽略掉的信号在 exec 之后还是忽略的 + Acts::IgnoresTheAsk => "trap '' TERM\nexec sleep 30", + Acts::Crashes => "exit 1", + }; + let script = format!( + "#!/bin/sh\n[ \"$1\" = {WARM} ] && exit 0\necho $$ >> '{}'\n{then}\n", + starts.display() + ); + std::fs::write(&bin, script).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin + }; + #[cfg(windows)] + let bin = { + let bin = dir.join("fake-core.cmd"); + let then = match acts { + Acts::Stays | Acts::IgnoresTheAsk => "@ping -n 30 127.0.0.1 >nul", + Acts::Crashes => "@exit /b 1", + }; + let script = format!( + "@if \"%~1\"==\"{WARM}\" exit /b 0\r\n@echo x>> \"{}\"\r\n{then}\r\n", + starts.display() + ); + std::fs::write(&bin, script).unwrap(); + bin + }; + (tmp, warmed(bin), starts) +} + +/// **安全模式里的 core 也退出了,界面拿到的是它最后说的那句话**,不是一句「已停止」 +#[tokio::test] +async fn a_safe_mode_core_that_exits_reports_what_it_said() { + let tmp = fake_core_dir("exits"); + let dir = tmp.path(); + #[cfg(unix)] + let bin = { + let bin = dir.join("fake-core"); + let script = format!( + "#!/bin/sh\n[ \"$1\" = {WARM} ] && exit 0\necho 'Error: loading /x/config.yaml' >&2\necho '' >&2\necho 'Caused by:' >&2\necho ' 已有 twcore 实例正在运行(pid 7)。' >&2\nexit 1\n" + ); + std::fs::write(&bin, script).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin + }; + #[cfg(windows)] + let bin = { + let bin = dir.join("fake-core.cmd"); + let script = format!( + "@if \"%~1\"==\"{WARM}\" exit /b 0\r\n@echo Error: loading /x/config.yaml 1>&2\r\n@echo Caused by: 1>&2\r\n@echo another twcore is running 1>&2\r\n@exit /b 1\r\n" + ); + std::fs::write(&bin, script).unwrap(); + bin + }; + let s = Supervisor::new( + warmed(bin), + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ); + assert_eq!(s.run_once(true).await.unwrap(), Next::Stop); + let CoreState::Exited { reason } = s.state() else { + panic!("该报 Exited,实际是 {:?}", s.state()); + }; + assert!(reason.starts_with("loading /x/config.yaml\n"), "{reason}"); + #[cfg(unix)] + assert!( + reason.ends_with("已有 twcore 实例正在运行(pid 7)。"), + "{reason}" + ); +} + +#[test] +fn the_last_error_is_the_whole_chain_without_numbers() { + let lines: Vec = [ + "some earlier noise", + "Error: loading /x/config.yaml", + "", + "Caused by:", + " 0: schema error (line 3): bad", + " 1: deeper", + ] + .into_iter() + .map(String::from) + .collect(); + assert_eq!( + last_error(&lines).as_deref(), + Some("loading /x/config.yaml\nschema error (line 3): bad\ndeeper") + ); + // 没有 `Error:`(被信号杀掉、崩溃)就取最后几行 + let crash: Vec = ["a", "", "thread 'main' panicked at x", "boom"] + .into_iter() + .map(String::from) + .collect(); + assert_eq!( + last_error(&crash).as_deref(), + Some("a\nthread 'main' panicked at x\nboom") + ); + assert_eq!(last_error(&[]), None); +} + +/// 起过几次 +fn starts(file: &std::path::Path) -> usize { + std::fs::read_to_string(file).map_or(0, |s| s.lines().count()) +} + +/// 等到起过 `times` 次。只能看文件,所以隔一会儿看一眼 +async fn until_started(file: &std::path::Path, times: usize) { + let started = async { + while starts(file) < times { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }; + tokio::time::timeout(PATIENCE, started) + .await + .expect("core 迟迟没起来"); +} + +/// **还在启动的 core 也停得下来。**以前只认「运行中」:切到远程的那一刻本机的 core +/// 要是还没答应控制面,它照样起来、照样占着网关端口。停下之后照样接得回来 +#[tokio::test] +async fn a_core_that_is_still_starting_is_stopped_and_can_start_again() { + let answer = Arc::new(AtomicBool::new(false)); + let ready = { + let answer = answer.clone(); + probe(move || { + let yes = answer.load(Ordering::SeqCst); + async move { yes } + }) + }; + let (_dir, bin, started) = counting_core("starting", Acts::Stays); + let s = Arc::new(Supervisor::new( + bin, + None, + ready, + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let looped = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + // 进程起来了,控制面还没答应 + until_started(&started, 1).await; + assert_eq!(s.state(), CoreState::Starting); + + // 停还在启动的 core 不走控制面(按句柄直接杀),两个平台上都不该等到超时 + let t0 = Instant::now(); + s.stop_and_wait(PATIENCE).await; + assert_eq!(s.state(), CoreState::Stopped, "还在启动的 core 没被停下"); + assert!(t0.elapsed() < PATIENCE, "等到超时才停下"); + let next = tokio::time::timeout(PATIENCE, looped) + .await + .expect("守护循环没有停下") + .unwrap(); + assert_eq!(next, Next::Stop); + #[cfg(unix)] + { + let pid: i32 = std::fs::read_to_string(&started) + .unwrap() + .trim() + .parse() + .unwrap(); + // SAFETY: 信号 0 只问这个 pid 还在不在,什么都不送 + let alive = unsafe { libc::kill(pid, 0) } == 0; + assert!(!alive, "进程还在跑"); + } + + // 接回来:记号撤掉,照常起到「运行中」 + s.resume(); + answer.store(true, Ordering::SeqCst); + let again = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + until_running(&s).await; + until_started(&started, 2).await; + s.stop_and_wait(Duration::from_secs(5)).await; + assert_eq!(again.await.unwrap(), Next::Stop); +} + +/// **在退避里等着重起的 core 也停得下来**:不会睡醒了再起一个 +#[tokio::test] +async fn a_core_waiting_out_its_backoff_is_not_started_again() { + let (_dir, bin, started) = counting_core("backoff", Acts::Crashes); + let s = Arc::new(Supervisor::new( + bin, + None, + probe(|| async { false }), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + // 和 `gateway::supervise` 一样,一轮接一轮地跑 + let looped = { + let s = s.clone(); + tokio::spawn(async move { + loop { + match s.run_once(false).await.unwrap() { + Next::Again => continue, + other => return other, + } + } + }) + }; + // 第一次崩溃立刻重起,第二次之后要等一秒 + let mut rx = s.watch(); + let backing_off = + rx.wait_for(|st| matches!(st, CoreState::Restarting { in_ms, .. } if *in_ms > 0)); + let _ = tokio::time::timeout(PATIENCE, backing_off) + .await + .expect("迟迟没进退避") + .unwrap(); + + s.stop_and_wait(Duration::from_secs(5)).await; + assert_eq!(s.state(), CoreState::Stopped, "退避里的 core 没被停下"); + let next = tokio::time::timeout(PATIENCE, looped) + .await + .expect("守护循环没有停下") + .unwrap(); + assert_eq!(next, Next::Stop); + assert_eq!(starts(&started), 2, "睡醒之后又起了一个"); +} + +/// 要停的请求落在两轮之间(比如按要求重启的那一下):**下一轮不起** +#[tokio::test] +async fn a_stop_between_two_rounds_keeps_the_next_one_from_starting() { + let (_dir, bin, started) = counting_core("between", Acts::Stays); + let s = Supervisor::new( + bin, + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ); + // 此刻没有在跑的:只是把记号立起来 + s.stop_and_wait(Duration::from_secs(5)).await; + let next = tokio::time::timeout(PATIENCE, s.run_once(false)) + .await + .expect("要停的时候又起了一个") + .unwrap(); + assert_eq!(next, Next::Stop); + assert_eq!(s.state(), CoreState::Stopped); + assert_eq!(starts(&started), 0); +} + +/// 卡死的 core 听了请求自己退了:**等到它走就收手**,不再干等三秒、再按那个 pid 强杀 —— +/// 那时守护循环已经在起下一个,这个号可能已经是系统里别的进程的了。 +/// +/// 只在 unix 上:这里的假 core 没有控制面,温和那一档只能走信号 +#[cfg(unix)] +#[tokio::test] +async fn a_wedged_core_that_leaves_when_asked_is_not_killed_afterwards() { + let (_dir, bin) = long_runner("wedged-leaves"); + let s = Arc::new(Supervisor::new( + bin, + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let looped = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + until_running(&s).await; + + let t0 = Instant::now(); + s.report_wedged().await.unwrap(); + assert!( + t0.elapsed() < Duration::from_secs(2), + "它走了还等了 {:?} 才回来", + t0.elapsed() + ); + // 照一次失败算,守护循环接着起下一个 + assert_eq!(looped.await.unwrap(), Next::Again); + assert!( + matches!(s.state(), CoreState::Restarting { attempt: 1, .. }), + "{:?}", + s.state() + ); +} + +/// 请它退出也不走的,**照样强杀、换掉**:收手只收在它自己走了的时候 +#[tokio::test] +async fn a_wedged_core_that_ignores_the_ask_is_still_killed() { + let (_dir, bin, _) = counting_core("wedged-stays", Acts::IgnoresTheAsk); + let s = Arc::new(Supervisor::new( + bin, + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let looped = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + until_running(&s).await; + s.report_wedged().await.unwrap(); + let next = tokio::time::timeout(PATIENCE, looped) + .await + .expect("强杀之后它还在") + .unwrap(); + assert_eq!(next, Next::Again); +} + +/// 一个一定不存在的 pid:比 Linux 的 `pid_max` 上限(2^22)和 macOS 的(99999)都大, +/// 又还是正数 —— 转成 `i32` 是负数的话,`kill` 会送给一整个进程组 +const NO_SUCH_PID: u32 = i32::MAX as u32; + +/// **没请动的重启不留记号,也不说成了。**Windows 上控制面不应时 core 照旧跑着,重启 +/// 没有发生;记号留着的话,它下一次真的崩溃会被当成这次重启:不计入退避,连着崩 +/// 也进不了安全模式 +#[tokio::test] +async fn a_restart_that_could_not_be_asked_for_leaves_no_mark() { + let (_dir, bin, _) = counting_core("restart-unasked", Acts::Crashes); + let s = Supervisor::new( + bin, + None, + probe(|| async { false }), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ); + // 请不到它:控制面不在,这个 pid 上也没有进程收信号(两个平台上都是没请动) + s.set(CoreState::Running { pid: NO_SUCH_PID }); + assert!(s.request_restart().await.is_err(), "没重启成却说成了"); + + // 之后 core 真的崩了:照一次失败算,不是「按要求重启」 + assert_eq!(s.run_once(false).await.unwrap(), Next::Again); + assert!( + matches!(s.state(), CoreState::Restarting { attempt: 1, .. }), + "{:?}", + s.state() + ); +} + +/// 重启的请求撞上停止(点了「重新启动」、紧接着切到远程):守护循环先认停止。 +/// 那个重启的记号**不能带进下一轮**,不然接回来之后第一次真崩溃会被当成按要求重启 +#[tokio::test] +async fn a_restart_cut_short_by_a_stop_does_not_carry_into_the_next_round() { + let (_dir, bin) = long_runner("restart-then-stop"); + let s = Arc::new(Supervisor::new( + bin, + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let first = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + until_running(&s).await; + // 两个请求都在守护循环看见 core 退出之前立下(测试跑在单线程的运行时上, + // `join!` 先把两边各推到等待处)。Windows 上重启本来就请不动,不看它的结果 + let (_, restarted) = tokio::join!(s.stop_and_wait(Duration::from_secs(5)), s.request_restart()); + let _ = restarted; + assert_eq!(first.await.unwrap(), Next::Stop); + + s.resume(); + let second = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + let pid = until_running(&s).await; + s.kill_now(pid); + assert_eq!(second.await.unwrap(), Next::Again); + assert!( + matches!(s.state(), CoreState::Restarting { attempt: 1, .. }), + "接回来之后的崩溃被当成了按要求重启:{:?}", + s.state() + ); +} + +/// 一个握了手就一声不吭的控制面。走回环端口:两个平台都认这一种传输。第一项是放 +/// 配置和端口文件的目录(见 [`fake_core_dir`]),拿着它到测试结束 +async fn silent_control_plane( + name: &str, +) -> (tempfile::TempDir, tw_api::control::Address, PathBuf) { + let tmp = fake_core_dir(name); + let dir = tmp.path(); + let key = "ab".repeat(32); + let config = dir.join("config.yaml"); + std::fs::write( + &config, + format!("listen:\n control:\n key: \"{key}\"\n"), + ) + .unwrap(); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port_file = dir.join("control.port"); + std::fs::write(&port_file, l.local_addr().unwrap().port().to_string()).unwrap(); + tokio::spawn(async move { + let key = tw_link::ControlKey::parse(&key).unwrap(); + let acceptor = tw_link::Acceptor::new(move || Some(key.clone()), "9.9.9"); + let mut held = Vec::new(); + while let Ok((s, _)) = l.accept().await { + // 握手,然后拿着这条连接,请求来了也不答 + if let Ok(accepted) = acceptor.accept(s).await { + held.push(accepted); + } + } + }); + ( + tmp, + tw_api::control::Address::Loopback { port_file }, + config, + ) +} + +/// 控制面握了手却一直不答:**请它退出这一步不能跟着挂住**。心跳换掉卡死的 core、 +/// 装更新、退出应用都要先走这一步 +#[tokio::test] +async fn asking_a_core_whose_control_plane_never_answers_gives_up() { + let (_dir, at, config) = silent_control_plane("silent").await; + let s = Supervisor::new(PathBuf::from("/x"), None, always_ready(), at, config); + let asked = tokio::time::timeout(Duration::from_secs(10), s.ask_to_exit(NO_SUCH_PID)) + .await + .expect("请它退出挂住了"); + assert!(!asked, "控制面没答、信号也没处送,却说请到了"); +} diff --git a/src-tauri/src/supervisor/user_env.rs b/src-tauri/src/supervisor/user_env.rs index 400048e2..c46819ac 100644 --- a/src-tauri/src/supervisor/user_env.rs +++ b/src-tauri/src/supervisor/user_env.rs @@ -14,7 +14,9 @@ //! 设置里改了变量,它和它拉起的 core 都还拿着旧值。所以每次现读注册表。 //! //! **每次起 core 都重新取**,不是应用启动时取一次:core 崩了被重新拉起、或者 -//! 被重启时,拿到的是那一刻的值,而不是应用打开那天的。 +//! 被重启时,拿到的是那一刻的值,而不是应用打开那天的。登录 shell 慢的机器上这一取 +//! 要好几秒,而它挡在 core 前面,所以能早的时候就早点开始([`read_ahead`]):应用一 +//! 启动、点了重启、崩溃后退避的那几秒 —— 取到的仍是这一次启动时的值。 //! //! 这个文件只用 `std`、`tokio`、`libc` 和 `windows-sys`,不引 `crate::` —— Windows 那一支 //! 要能摘进临时 crate 交叉编译。 @@ -25,7 +27,11 @@ /// 取到的这一份记下来([`last`]):接管 Claude Code 时要看用户环境里打开没打开 /// `CLAUDE_CODE_USE_BEDROCK` 这类开关,不为这个再跑一次登录 shell。 pub async fn load() -> Vec<(String, String)> { - let (env, proxy): (Vars, Vars) = read().await.into_iter().partition(|(k, _)| keep(k)); + let all = match ahead().await { + Some(all) => all, + None => read().await, + }; + let (env, proxy): (Vars, Vars) = all.into_iter().partition(|(k, _)| keep(k)); *LAST.lock().unwrap_or_else(|e| e.into_inner()) = Some(env.clone()); // 滤掉的那些里,代理变量另外记着:不带给 core,可接管 Pi 时要看它访问网关会不会经过 // 代理(`tw_adopt::pi::proxy_notes`) @@ -37,6 +43,44 @@ pub async fn load() -> Vec<(String, String)> { /// 一份环境变量:名字和值 type Vars = Vec<(String, String)>; +/// 现在就开始读,结果留给下一次 [`load`]。返回的那个 future 由调用方交给运行时(这个 +/// 文件不认识应用的运行时)。 +/// +/// 读要跑一次登录 shell,配置重的机器上好几秒,而 core 要等它读完才起得来。提前开始, +/// 就和别的事叠在一起:应用启动时和 Tauri 起来的那一段,点了重启时和旧的 core 收尾的 +/// 那一段,崩溃之后和退避的那几秒。**拿到的仍是这一次启动时的值**:开始读之后过了 +/// [`AHEAD_FRESH`] 还没被用上的就不用了,到时候现读 +pub fn read_ahead() -> impl std::future::Future + Send + 'static { + let (tx, rx) = tokio::sync::oneshot::channel(); + *AHEAD.lock().unwrap_or_else(|e| e.into_inner()) = Some((std::time::Instant::now(), rx)); + async move { + let _ = tx.send(read().await); + } +} + +/// 提前开始的那一次([`read_ahead`]):什么时候开始读的,和等结果的那一头。取走就没了, +/// 一次读到的只给一次启动用 +static AHEAD: std::sync::Mutex> = std::sync::Mutex::new(None); + +type Ahead = (std::time::Instant, tokio::sync::oneshot::Receiver); + +/// 提前读的那一份从开始读算起多久之内还用得上。启动时要是先停在连接选择上,选好本机 +/// 已经是几分钟以后,那时的值要现读 +const AHEAD_FRESH: std::time::Duration = std::time::Duration::from_secs(30); + +/// 提前读的那一份([`AHEAD`])。没有提前读、放久了、没读完就被丢下(应用在退出)都是 `None` +async fn ahead() -> Option { + take_ahead(&AHEAD).await +} + +async fn take_ahead(slot: &std::sync::Mutex>) -> Option { + let (at, rx) = slot.lock().unwrap_or_else(|e| e.into_inner()).take()?; + if at.elapsed() > AHEAD_FRESH { + return None; + } + rx.await.ok() +} + /// 最近一次取到的那一份([`load`])。 static LAST: std::sync::Mutex> = std::sync::Mutex::new(None); @@ -394,6 +438,34 @@ mod tests { } } + /// 提前读的那一份**只用一次**,**放久了不用**:用的是这一次启动时的值,不是应用打开那天的 + #[tokio::test] + async fn a_read_ahead_is_used_once_and_only_while_fresh() { + let slot = std::sync::Mutex::new(None); + let ready = |at: std::time::Instant| { + let (tx, rx) = tokio::sync::oneshot::channel(); + tx.send(vec![("OPENAI_API_KEY".to_string(), "sk-1".to_string())]) + .unwrap(); + Some((at, rx)) + }; + *slot.lock().unwrap() = ready(std::time::Instant::now()); + assert_eq!( + take_ahead(&slot).await, + Some(vec![("OPENAI_API_KEY".into(), "sk-1".into())]) + ); + assert_eq!(take_ahead(&slot).await, None, "用过的又用了一次"); + + let long_ago = std::time::Instant::now() - AHEAD_FRESH - std::time::Duration::from_secs(1); + *slot.lock().unwrap() = ready(long_ago); + assert_eq!(take_ahead(&slot).await, None, "放久了的还在用"); + + // 读的那一头没读完就没了(应用在退出):当没有,现读 + let (tx, rx) = tokio::sync::oneshot::channel::(); + drop(tx); + *slot.lock().unwrap() = Some((std::time::Instant::now(), rx)); + assert_eq!(take_ahead(&slot).await, None); + } + /// 真跑一次登录 shell。**记号之间真的拿到了东西**,而不是超时或者被 /// shell 的启动输出搅乱之后返回空的 —— 那样 `load` 也不会报错,只是悄悄没用。 #[cfg(unix)] diff --git a/src-tauri/src/theme.rs b/src-tauri/src/theme.rs index 2fd51990..092778c5 100644 --- a/src-tauri/src/theme.rs +++ b/src-tauri/src/theme.rs @@ -86,11 +86,9 @@ pub fn system() -> Theme { /// 要跟的是应用那一档,因为这个应用就是一个应用。 #[cfg(windows)] fn apps_use_light_theme() -> Option { + use crate::wide; use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, RRF_RT_REG_DWORD, RegGetValueW}; - fn wide(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() - } let sub = wide(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); let name = wide("AppsUseLightTheme"); let mut val: u32 = 0; diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index d2ea1f30..4315abb4 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -42,13 +42,8 @@ pub fn update_state(app: tauri::AppHandle) -> UpdateView { } #[tauri::command] -pub fn set_update_check(app: tauri::AppHandle, on: bool) -> Out { - prefs::update(&data_dir(), |p| p.check_updates = on).map_err(|e| { - tr!( - format!("无法保存设置:{e:#}"), - format!("The setting could not be saved: {e:#}") - ) - })?; +pub async fn set_update_check(app: tauri::AppHandle, on: bool) -> Out { + prefs::change(move |p| p.check_updates = on).await?; Ok(update_view(&app)) } diff --git a/src-tauri/src/upstreams.rs b/src-tauri/src/upstreams.rs index e8d77629..338caf87 100644 --- a/src-tauri/src/upstreams.rs +++ b/src-tauri/src/upstreams.rs @@ -18,7 +18,7 @@ use crate::error::Out; /// **几样同时问,不排队。**额度那一问可能要等几秒:GLM Coding Plan 的额度不在响应头 /// 里,core 答 `/quota` 时现去问账号的额度接口(最多等 5 秒)。排着队问的话,费用和 /// 走势要白白跟着等。 -#[derive(serde::Serialize)] +#[derive(serde::Serialize, Default)] pub struct UpstreamStats { costs: Option>, latency: Option>, @@ -39,7 +39,10 @@ pub async fn upstream_stats( since_ms: i64, bucket_ms: i64, ) -> Out { - let c = &state.control; + // 钥匙读一次(见 `control::Pinned`)。读不到就是 core 不在:每一样都拿不到,不是报错 + let Ok(c) = state.control.pin() else { + return Ok(UpstreamStats::default()); + }; let costs = tw_api::GroupQuery { from_ms: Some(since_ms), to_ms: None, diff --git a/src-tauri/tests/control_plane.rs b/src-tauri/tests/control_plane.rs index f4d35907..7ebdb404 100644 --- a/src-tauri/tests/control_plane.rs +++ b/src-tauri/tests/control_plane.rs @@ -213,6 +213,8 @@ async fn after_the_key_is_rotated_the_next_connection_gets_in() { let core = Core::start(); core.wait_ready().await; let before = tw_link::read_key(&core.config()).unwrap(); + // 换钥匙之前就取定了的一份(一个命令里接连几问用的那种):手上是旧钥匙 + let pinned = core.ok().pin().unwrap(); let rotated = Command::new(core_binary()) .args(["control-key", "--rotate", "--config"]) @@ -245,6 +247,11 @@ async fn after_the_key_is_rotated_the_next_connection_gets_in() { assert!(Instant::now() < deadline, "旧钥匙还进得去"); tokio::time::sleep(Duration::from_millis(200)).await; } + // 取定时拿着旧钥匙的那一份照样进得去:握手说钥匙不对,它再读一次配置 + pinned + .call::(&[], &()) + .await + .expect("取定了旧钥匙的那一份没换上新的"); } /// 控制面拒绝时,**带着 core 的码回来**,不是一段要界面再解析一遍的文本。 diff --git a/src/App.i18n.tsx b/src/App.i18n.tsx index f48060ad..668e0374 100644 --- a/src/App.i18n.tsx +++ b/src/App.i18n.tsx @@ -19,7 +19,6 @@ export const appText = messages( surfaces: { dashboard: "概览", requests: "流量", - sessions: "会话", security: "安全", upstreams: "上游", routing: "路由", @@ -68,7 +67,6 @@ export const appText = messages( surfaces: { dashboard: "Overview", requests: "Traffic", - sessions: "Sessions", security: "Security", upstreams: "Upstreams", routing: "Routing", diff --git a/src/App.tsx b/src/App.tsx index 5ed0dd9f..91625704 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,207 +1,21 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { subscribe } from "@/lib/tauriEvent"; -import { SearchIcon } from "lucide-react"; -import { call } from "@/control"; -import { useRequests } from "./useRequests"; -import { useStableState } from "./useStable"; -import { EMPTY_FILTER } from "./requestTable"; -import { SettingsPage } from "./settings/SettingsPage"; -import { ConfigFileDialog, VersionHistoryDialog } from "./ConfigDialogs"; -import { ImportDialog } from "./import/ImportDialog"; -import { importDialogText } from "./import/ImportDialog.i18n"; -import { notify } from "@/ui/notify"; -import type { ConfigFocus } from "./configLocate"; -import UpstreamsPage from "./upstreams/UpstreamsPage"; -import ClientsPage from "./clients/ClientsPage"; -import KeysPage from "./keys/KeysPage"; -import { useBusyKeys } from "./keys/live"; -import RoutingPage from "./routing/RoutingPage"; -import SecurityPage, { type LogFocus } from "./security/SecurityPage"; -import McpPage from "./mcp/McpPage"; -import PluginsPage from "./plugins/PluginsPage"; -import TrafficPage from "./traffic/TrafficPage"; -import { useTrafficView } from "./traffic/view"; -import { useSessions } from "./traffic/useSessions"; -import { Notices } from "./Notices"; -import { Tip, TooltipRoot } from "@/ui/tip"; -import { - IconClient, - IconDashboard, - IconFlow, - IconGuard, - IconKey, - IconMcp, - IconPlugin, - IconRoute, - IconServer, - IconSettings, -} from "./ui/icons"; -import { isMac, isMod } from "@/platform"; -import OverviewPage from "./overview/OverviewPage"; -import type { CoreStatus, ImportProposal, Overview } from "./types"; -import { stageLabel } from "./labels"; -import { textOf, useText } from "@/i18n"; -import { commonText } from "@/i18n/common.i18n"; -import { appText } from "./App.i18n"; -import { Button } from "@/ui/button"; -import type { LucideIcon } from "lucide-react"; -import { Toaster } from "@/ui/sonner"; -import { coreText, errorText } from "@/i18n/core.i18n"; -import { trouble } from "./launch/trouble"; -import { LaunchScreen } from "./launch/LaunchScreen"; -import { warm } from "./launch/warm"; -import { ConnectionProvider, useConnections } from "./connection/ConnectionProvider"; -import { Switcher } from "./connection/Switcher"; -import { ConfigBroken, Unlinked } from "./connection/Unlinked"; -import { FixList, repairText, useRepair } from "./repair"; +import { useRef, useState } from "react"; +import { useConnections, ConnectionProvider } from "./connection/ConnectionProvider"; import { currentProfile } from "./connection/api"; -import { connText } from "./connection/connection.i18n"; -import { NavContext, SURFACES, type Nav, type NavDelivery, type NavParams, type Surface } from "./nav"; -import { Banner } from "@/ui/banner"; -import { Reveal } from "@/ui/motion"; -import { Page, PageHeader } from "@/ui/page"; -import { ErrorState, TableSkeleton } from "@/ui/states"; -import { invalidateAll, resetResources } from "@/lib/resource"; -import { cn } from "@/lib/utils"; -import { Palette } from "./palette/Palette"; -import { paletteText } from "./palette/palette.i18n"; -import { COMBOS, DIGIT_PAGES, Keys, comboText, isTyping, modalOpen, pageCombo } from "./palette/keys"; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupContent, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarProvider, - SidebarSeparator, - SidebarTrigger, -} from "@/ui/sidebar"; - -/** - * 连控制面最多退避重试几次。 - * - * **到了上限就停手**,不再往下试 —— 再试下去就成了轮询。守护状态一变 - * (启动中 → 运行中、进了重启、进了安全模式)那个 effect 会重跑,那才 - * 是它该被叫醒的时机。 - */ -const MAX_TRIES = 8; - -/** 概览的几个重读理由挨着到时,等这么久再读:一串只读一次 */ -const COALESCE_MS = 100; - -/** - * 启动画面最多等多久。**过了就照常打开窗口**,内容区显示「未连接」那一页、后台接着 - * 重连 —— 而不是一直转圈。界面外壳不该依赖 core 连没连上:连接管理在设置里,那一页 - * 这时必须能用 - */ -const LAUNCH_CAP_MS = 8_000; - -/** 编辑 config.yaml 的几页。工具栏上的「配置文件」「版本历史」只在这几页出现 */ -const CONFIG_PAGES = new Set(["upstreams", "keys", "routing", "security", "plugins"]); - -/** 配置文件里的一段由哪一页管理 */ -function surfaceOf(section: string | null): Surface { - switch (section) { - case "providers": - case "proxies": - case "pricing": - return "upstreams"; - case "clients": - return "keys"; - case "routes": - case "groups": - case "default_route": - return "routing"; - case "security": - return "security"; - case "plugins": - return "plugins"; - default: - // 监听、日志保留在设置页;辅助请求在路由页,但它没有自己的段名 - return "settings"; - } -} - -/** - * 主窗口的几个面。 - * - * **源列表,不是标签栏。**原生客户端用左侧源列表:它能分组、能挂角标,加一项 - * 不会把别的挤窄。分组的判据是打开频率:上面那组每天看,越往下越是配一次就不动的。 - * 顺序和 `SURFACES` 一致,⌘1…⌘9 按它数(设置是 ⌘,,见 `palette/keys.tsx` 的 `DIGIT_PAGES`)。 - */ -const SOURCES: { group: string; items: { id: Surface; icon: LucideIcon }[] }[] = [ - { - // 概览回答「现在什么情况」,是打开这个应用的默认意图;流量回答「刚才那一条 - // 发生了什么」。会话不是第三项,是流量的第二种粒度(归组) - group: "monitor", - items: [ - { id: "dashboard", icon: IconDashboard }, - { id: "requests", icon: IconFlow }, - ], - }, - { - // 谁在用这个网关、拿什么连进来:客户端和密钥是同一件事的两面,挨着放 - group: "access", - items: [ - { id: "clients", icon: IconClient }, - { id: "keys", icon: IconKey }, - ], - }, - { - // 网关的配置。安全、MCP 和插件也在这一组:它们是要去动的开关和规则,不是看板 - group: "config", - items: [ - { id: "upstreams", icon: IconServer }, - { id: "routing", icon: IconRoute }, - { id: "security", icon: IconGuard }, - { id: "mcp", icon: IconMcp }, - { id: "plugins", icon: IconPlugin }, - ], - }, - { - // 应用自己的设置,不是网关的配置 - group: "app", - items: [{ id: "settings", icon: IconSettings }], - }, -]; - -/** - * core 的状态说成人话。 - * - * `short` 是给收起的源列表用的 —— 那里只有 80px。**不是截断,是另写一句**:截断 - * 出来的「安全模式 · 网关未…」比四个字更难读。 - */ -function describeCore(raw: string): { - text: string; - short: string; - tone: "ok" | "warn" | "bad"; -} { - const t = textOf(appText); - if (raw.startsWith("running:")) return { text: t.running, short: t.running, tone: "ok" }; - if (raw === "starting") return { text: t.starting, short: t.starting, tone: "warn" }; - if (raw.startsWith("restarting:")) { - const [, attempt] = raw.split(":"); - return { text: t.restarting(`${attempt}`), short: t.restartingShort, tone: "warn" }; - } - // 安全模式必须显眼:这时候网关不转发了,所有 AI 客户端都停着 - if (raw.startsWith("safe_mode")) return { text: t.safeMode, short: t.safeModeShort, tone: "bad" }; - // 程序运行不了。原因在启动画面上说(见 launch/trouble.ts) - if (raw.startsWith("failed:") || raw.startsWith("exited:")) - return { text: t.cannotStart, short: t.cannotStart, tone: "bad" }; - return { text: t.stopped, short: t.stopped, tone: "bad" }; -} - -/** - * 标成拖拽区,**只在 macOS 上**。那里窗口用的是 Overlay 标题栏(红绿灯浮在内容上), - * 没有一条真的标题栏可以抓。Windows 和 Linux 用系统标题栏,再把内容标成拖拽区的话, - * 点一下侧栏空白就会把窗口拖走。 - */ -const drag = isMac ? { "data-tauri-drag-region": true } : {}; +import { resetResources } from "@/lib/resource"; +import { RequestsProvider } from "./useRequests"; +import { LaunchScreen } from "./launch/LaunchScreen"; +import { useCoreLink } from "./shell/useCoreLink"; +import { lazyPart, usePart } from "./shell/lazyPart"; + +/* + **主窗口最先加载的只有这一小块**:连接、和 core 之间的那一层(`useCoreLink`)、启动画面。 + 主界面(源列表、工具栏、各页)是另一块,在启动画面下面加载 —— 冷启动时启动画面不用等 + 它解析完才出现。模块一求值就开始载入它,**落地页(概览)也同时开始载入**,不等主界面 + 载入完再去要:两块排着队载入的话,热启动的窗口要多藏一截才出现。 +*/ +const Workspace = lazyPart(() => import("./shell/Workspace").then((m) => m.default)); +void Workspace.preload().catch(() => {}); +void import("./overview/OverviewPage").catch(() => {}); /** * 主窗口。 @@ -233,978 +47,35 @@ function PerConnection() { return ; } +/** + * 一个连接上的主窗口:和 core 之间的那一层、主界面、启动画面。 + * + * **这一层在实时请求上不重画。**请求列表在 `useRequests` 的 store 里,谁用谁订阅(见 + * `RequestsView`);这里只有连接状态、概览这些「现在什么情况」,变得很少。 + */ function Shell({ first }: { first: boolean }) { - const t = useText(appText); - const ct = useText(connText); - const pt = useText(paletteText); - const common = useText(commonText); const conn = useConnections(); const profile = conn.view ? currentProfile(conn.view) : undefined; - /** 连着的是远程 core */ - const remote = profile !== undefined && !profile.local; - const link = conn.view?.link ?? null; - /** - * 守护状态。**先当它在起**:第一次读到之前画「已停止」的话,启动画面会先 - * 闪一下「core 未运行」 - */ - const [core, setCore] = useState("starting"); - /** - * 这次开窗连上过控制面吗。 - * - * **取数的都等它。**连上之前去取,拿回来的只有一句「连不上」。它也决定断线时的 - * 样子:连上过就保留页面、顶上挂一条横幅 —— 清空比留着旧值加一句说明更糟。 - */ - const [linked, setLinked] = useState(false); - /** `linked` 的同一个值,给只在变化时才该重跑的 effect 读 */ - const linkedRef = useRef(false); - /** 连了第几次了。只用来在界面上说清楚,不参与重试逻辑 */ - const [tries, setTries] = useState(0); - /** 最近一次读状态失败的原因。启动画面上连着失败时说出来 */ - const [linkError, setLinkError] = useState(null); - const { - rows: allRows, - seeded, - seedError, - reseed, - settled, - health, - models, - listening, - locallyAnswered, - rejected, - reloads, - alerts, - rotated, - clearRotated, - clearAlerts, - upstreamState, - } = useRequests(linked); - const sessions = useSessions(linked); - /** 此刻有请求在跑的密钥:密钥页、客户端页上那一格的「请求中」 */ - const busyKeys = useBusyKeys(allRows); - const traffic = useTrafficView(); - const { setFilter, setGrouped } = traffic; - - const [status, setStatus] = useStableState(null); - /** - * 启动画面还在。**只在冷启动、这次开窗第一次交接之前**,见 `LaunchScreen`。 - * 热启动没有这一面,窗口藏到交接那一刻才出现(见 `warm`) - */ - const [launching, setLaunching] = useState(!warm && first); - /** 启动画面等够了:不再等,交出主界面 */ - const [gaveUp, setGaveUp] = useState(false); - useEffect(() => { - const h = setTimeout(() => setGaveUp(true), LAUNCH_CAP_MS); - return () => clearTimeout(h); - }, []); - const lasting = - link?.kind === "down" && (link.error.kind === "wrong_key" || link.error.kind === "version_mismatch"); - /** 概览那一页的第一份数据到了。启动画面等它,交接时数字已经是对的 */ - const [landed, setLanded] = useState(false); - /** 连上之后首屏迟迟取不齐:不再等,交给那一页自己的骨架 */ - const [waited, setWaited] = useState(false); - /** 菜单栏里点了「全部提醒…」几次。每点一次,工具栏上的提醒就打开一次 */ - const [noticesAsked, setNoticesAsked] = useState(0); - const [tab, setTab] = useState("dashboard"); - /** 送到某一页的深链参数,见 `nav.tsx` */ - const [delivery, setDelivery] = useState(null); - const deliveries = useRef(0); - /** - * 从概览的安全计数点进日志时带的区间。**离开安全页就清掉** —— 过一阵再回来, - * 不该又被拨回当时那一段时间。 - */ - const [securityFocus, setSecurityFocus] = useState(null); - useEffect(() => { - if (tab !== "security") setSecurityFocus(null); - }, [tab]); - - /** - * 打开一页。**所有换页都走这里**(源列表、⌘ 快捷键、深链、通知):流量页的筛选 - * 由外壳持有,这里直接改;其余参数交给目标页自己读(`useNavParams`)。 - */ - const open = useCallback( - (s: S, params?: NavParams[S]) => { - if (s === "requests" && params) { - const p = params as NavParams["requests"]; - if (p.filter) setFilter({ ...EMPTY_FILTER, ...p.filter }); - if (p.grouped !== undefined) setGrouped(p.grouped); - } - if (s === "security") setSecurityFocus((params as NavParams["security"])?.focus ?? null); - setTab(s); - deliveries.current += 1; - setDelivery({ surface: s, params, seq: deliveries.current }); - }, - [setFilter, setGrouped], - ); - const nav = useMemo