diff --git a/UPDATELOG.md b/UPDATELOG.md index 8b813ec4..a448523f 100644 --- a/UPDATELOG.md +++ b/UPDATELOG.md @@ -60,6 +60,7 @@ - 修复 macOS 连接界面显示异常 - 修复规则配置项在不同配置文件间全局共享导致切换被重置的问题 - 修复 Linux Wayland 下部分 GPU 可能出现的 UI 渲染问题 +- 修复自动更新使版本回退的问题 ## v2.4.2 diff --git a/package.json b/package.json index aed83d71..411c1921 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "lint:fix": "eslint -c eslint.config.ts --cache --cache-location .eslintcache --fix src", "format": "prettier --write .", "format:check": "prettier --check .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -73,9 +74,9 @@ "react-router-dom": "7.9.4", "react-virtuoso": "^4.14.1", "swr": "^2.3.6", + "tauri-plugin-mihomo-api": "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo", "types-pac": "^1.0.3", - "zustand": "^5.0.8", - "tauri-plugin-mihomo-api": "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo" + "zustand": "^5.0.8" }, "devDependencies": { "@actions/github": "^6.0.1", @@ -84,6 +85,7 @@ "@tauri-apps/cli": "2.8.4", "@types/js-yaml": "^4.0.9", "@types/lodash-es": "^4.17.12", + "@types/node": "^24.8.1", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@vitejs/plugin-legacy": "^7.2.1", @@ -116,7 +118,8 @@ "typescript-eslint": "^8.46.1", "vite": "^7.1.10", "vite-plugin-monaco-editor": "^1.1.0", - "vite-plugin-svgr": "^4.5.0" + "vite-plugin-svgr": "^4.5.0", + "vitest": "^3.2.4" }, "lint-staged": { "*.{ts,tsx,js,jsx}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74fbb3eb..7bf57f9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 + '@types/node': + specifier: ^24.8.1 + version: 24.8.1 '@types/react': specifier: 19.2.2 version: 19.2.2 @@ -164,10 +167,10 @@ importers: version: 19.2.2(@types/react@19.2.2) '@vitejs/plugin-legacy': specifier: ^7.2.1 - version: 7.2.1(terser@5.44.0)(vite@7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)) + version: 7.2.1(terser@5.44.0)(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)) '@vitejs/plugin-react': specifier: 5.0.4 - version: 5.0.4(vite@7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.0.4(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)) adm-zip: specifier: ^0.5.16 version: 0.5.16 @@ -248,13 +251,16 @@ importers: version: 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.10 - version: 7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + version: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) vite-plugin-monaco-editor: specifier: ^1.1.0 version: 1.1.0(monaco-editor@0.54.0) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)) + version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) packages: @@ -1722,9 +1728,15 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1758,6 +1770,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.8.1': + resolution: {integrity: sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -1953,6 +1968,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2028,6 +2072,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2098,6 +2146,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2124,6 +2176,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2140,6 +2196,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2285,6 +2345,10 @@ packages: decode-named-character-reference@1.1.0: resolution: {integrity: sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2370,6 +2434,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2605,6 +2672,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2615,6 +2685,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} @@ -3032,6 +3106,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -3119,6 +3196,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -3440,6 +3520,13 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3735,6 +3822,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3768,6 +3858,12 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3826,6 +3922,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.16: resolution: {integrity: sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==} @@ -3875,10 +3974,28 @@ packages: resolution: {integrity: sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==} engines: {node: '>=0.12'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3955,6 +4072,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@7.14.0: + resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} + undici@5.29.0: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} @@ -4019,6 +4139,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-monaco-editor@1.1.0: resolution: {integrity: sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==} peerDependencies: @@ -4069,6 +4194,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -4114,6 +4267,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5774,10 +5932,16 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -5809,6 +5973,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@24.8.1': + dependencies: + undici-types: 7.14.0 + '@types/parse-json@4.0.2': {} '@types/prop-types@15.7.15': {} @@ -5983,7 +6151,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-legacy@7.2.1(terser@5.44.0)(vite@7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitejs/plugin-legacy@7.2.1(terser@5.44.0)(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.28.4) @@ -5998,11 +6166,11 @@ snapshots: regenerator-runtime: 0.14.1 systemjs: 6.15.1 terser: 5.44.0 - vite: 7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.0.4(vite@7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitejs/plugin-react@5.0.4(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) @@ -6010,10 +6178,52 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.38 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -6118,6 +6328,8 @@ snapshots: is-array-buffer: 3.0.5 optional: true + assertion-error@2.0.1: {} + async-function@1.0.0: optional: true @@ -6201,6 +6413,8 @@ snapshots: buffer-from@1.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6228,6 +6442,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6241,6 +6463,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.1: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -6377,6 +6601,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -6509,6 +6735,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6884,6 +7112,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} event-emitter@0.3.5: @@ -6893,6 +7125,8 @@ snapshots: eventemitter3@5.0.1: {} + expect-type@1.2.2: {} + ext@1.7.0: dependencies: type: 2.7.3 @@ -7347,6 +7581,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -7430,6 +7666,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -7904,6 +8142,10 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -8251,6 +8493,8 @@ snapshots: side-channel-weakmap: 1.0.2 optional: true + siginfo@2.0.0: {} + signal-exit@4.1.0: {} slice-ansi@7.1.2: @@ -8278,6 +8522,10 @@ snapshots: stable-hash-x@0.2.0: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -8355,6 +8603,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + style-to-js@1.1.16: dependencies: style-to-object: 1.0.8 @@ -8409,11 +8661,21 @@ snapshots: es5-ext: 0.10.64 next-tick: 1.1.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -8511,6 +8773,8 @@ snapshots: which-boxed-primitive: 1.1.1 optional: true + undici-types@7.14.0: {} + undici@5.29.0: dependencies: '@fastify/busboy': 2.1.1 @@ -8609,22 +8873,43 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + vite-node@3.2.4(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-monaco-editor@1.1.0(monaco-editor@0.54.0): dependencies: monaco-editor: 0.54.0 - vite-plugin-svgr@4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)): + vite-plugin-svgr@4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)): dependencies: '@rollup/pluginutils': 5.2.0(rollup@4.46.2) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - rollup - supports-color - typescript - vite@7.1.10(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1): + vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1): dependencies: esbuild: 0.25.4 fdir: 6.5.0(picomatch@4.0.3) @@ -8633,12 +8918,55 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 24.8.1 fsevents: 2.3.3 jiti: 2.6.1 sass: 1.93.2 terser: 5.44.0 yaml: 2.8.1 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.1 + expect-type: 1.2.2 + magic-string: 0.30.17 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.8.1)(jiti@2.6.1)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 24.8.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} @@ -8705,6 +9033,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: diff --git a/src/components/home/system-info-card.tsx b/src/components/home/system-info-card.tsx index b2a397c4..4b428f5a 100644 --- a/src/components/home/system-info-card.tsx +++ b/src/components/home/system-info-card.tsx @@ -14,7 +14,6 @@ import { IconButton, Tooltip, } from "@mui/material"; -import { check as checkUpdate } from "@tauri-apps/plugin-updater"; import { useLockFn } from "ahooks"; import { useCallback, useEffect, useMemo, useReducer } from "react"; import { useTranslation } from "react-i18next"; @@ -26,6 +25,7 @@ import { useVerge } from "@/hooks/use-verge"; import { useServiceInstaller } from "@/hooks/useServiceInstaller"; import { getSystemInfo } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; +import { checkUpdateSafe as checkUpdate } from "@/services/update"; import { version as appVersion } from "@root/package.json"; import { EnhancedCard } from "./enhanced-card"; diff --git a/src/components/layout/update-button.tsx b/src/components/layout/update-button.tsx index afa0f83b..d5c8b4ff 100644 --- a/src/components/layout/update-button.tsx +++ b/src/components/layout/update-button.tsx @@ -1,9 +1,9 @@ import { Button } from "@mui/material"; -import { check } from "@tauri-apps/plugin-updater"; import { useRef } from "react"; import useSWR from "swr"; import { useVerge } from "@/hooks/use-verge"; +import { checkUpdateSafe } from "@/services/update"; import { DialogRef } from "../base"; import { UpdateViewer } from "../setting/mods/update-viewer"; @@ -21,7 +21,7 @@ export const UpdateButton = (props: Props) => { const { data: updateInfo } = useSWR( auto_check_update || auto_check_update === null ? "checkUpdate" : null, - check, + checkUpdateSafe, { errorRetryCount: 2, revalidateIfStale: false, diff --git a/src/components/setting/mods/update-viewer.tsx b/src/components/setting/mods/update-viewer.tsx index b65a0e64..8efaa3fe 100644 --- a/src/components/setting/mods/update-viewer.tsx +++ b/src/components/setting/mods/update-viewer.tsx @@ -2,7 +2,6 @@ import { Box, Button, LinearProgress } from "@mui/material"; import { Event, UnlistenFn } from "@tauri-apps/api/event"; import { relaunch } from "@tauri-apps/plugin-process"; import { open as openUrl } from "@tauri-apps/plugin-shell"; -import { check as checkUpdate } from "@tauri-apps/plugin-updater"; import { useLockFn } from "ahooks"; import type { Ref } from "react"; import { useEffect, useImperativeHandle, useMemo, useState } from "react"; @@ -15,6 +14,7 @@ import { useListen } from "@/hooks/use-listen"; import { portableFlag } from "@/pages/_layout"; import { showNotice } from "@/services/noticeService"; import { useSetUpdateState, useUpdateState } from "@/services/states"; +import { checkUpdateSafe as checkUpdate } from "@/services/update"; export function UpdateViewer({ ref }: { ref?: Ref }) { const { t } = useTranslation(); diff --git a/src/components/setting/setting-verge-advanced.tsx b/src/components/setting/setting-verge-advanced.tsx index 7df55ced..c88348ab 100644 --- a/src/components/setting/setting-verge-advanced.tsx +++ b/src/components/setting/setting-verge-advanced.tsx @@ -1,6 +1,5 @@ import { ContentCopyRounded } from "@mui/icons-material"; import { Typography } from "@mui/material"; -import { check as checkUpdate } from "@tauri-apps/plugin-updater"; import { useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -15,6 +14,7 @@ import { openLogsDir, } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; +import { checkUpdateSafe as checkUpdate } from "@/services/update"; import { version } from "@root/package.json"; import { BackupViewer } from "./mods/backup-viewer"; diff --git a/src/services/update.test.ts b/src/services/update.test.ts new file mode 100644 index 00000000..30b6351f --- /dev/null +++ b/src/services/update.test.ts @@ -0,0 +1,140 @@ +import type { Update } from "@tauri-apps/plugin-updater"; +import { describe, expect, it } from "vitest"; + +import { + compareVersions, + ensureSemver, + extractSemver, + normalizeVersion, + resolveRemoteVersion, + splitVersion, +} from "@/services/update"; +import type { VersionParts } from "@/services/update"; + +const makeUpdate = (data: { + version?: string | null; + rawJson?: Record | null; +}): Update => + ({ + version: data.version ?? "", + rawJson: data.rawJson ?? {}, + }) as unknown as Update; + +describe("normalizeVersion", () => { + it("strips optional v prefix and trims whitespace", () => { + expect(normalizeVersion(" v1.2.3 ")).toBe("1.2.3"); + expect(normalizeVersion("V2.0.0-beta")).toBe("2.0.0-beta"); + }); + + it("returns null for empty or non-string input", () => { + expect(normalizeVersion(null)).toBeNull(); + expect(normalizeVersion(" ")).toBeNull(); + }); +}); + +describe("ensureSemver", () => { + it("returns normalized semver when input is valid", () => { + expect(ensureSemver("1.2.3")).toBe("1.2.3"); + expect(ensureSemver("v3.4.5-alpha.1+build.7")).toBe( + "3.4.5-alpha.1+build.7", + ); + }); + + it("returns null for invalid versions", () => { + expect(ensureSemver("1")).toBeNull(); + expect(ensureSemver("1.2.3.4")).toBeNull(); + expect(ensureSemver("release-candidate")).toBeNull(); + }); +}); + +describe("extractSemver", () => { + it("finds the first semver-like string and normalizes it", () => { + expect(extractSemver("Release v1.2.3 (latest)")).toBe("1.2.3"); + expect(extractSemver("tag:V2.0.0-beta+exp.sha")).toBe("2.0.0-beta+exp.sha"); + }); + + it("returns null when no semver-like string is present", () => { + expect(extractSemver("no version available")).toBeNull(); + }); +}); + +describe("splitVersion", () => { + it("splits version into numeric main and typed prerelease parts", () => { + const parts = splitVersion("1.2.3-alpha.4.beta") as VersionParts; + expect(parts.main).toEqual([1, 2, 3]); + expect(parts.pre).toEqual(["alpha", 4, "beta"]); + }); + + it("returns null when version is missing", () => { + expect(splitVersion(null)).toBeNull(); + }); +}); + +describe("compareVersions", () => { + it("orders versions by numeric components", () => { + expect(compareVersions("1.2.3", "1.2.4")).toBe(-1); + expect(compareVersions("2.0.0", "1.9.9")).toBe(1); + }); + + it("treats release versions as newer than prereleases", () => { + expect(compareVersions("1.0.0", "1.0.0-beta")).toBe(1); + expect(compareVersions("1.0.0-beta", "1.0.0")).toBe(-1); + }); + + it("resolves prerelease precedence correctly", () => { + expect(compareVersions("1.0.0-beta", "1.0.0-alpha")).toBe(1); + expect(compareVersions("1.0.0-alpha.1", "1.0.0-alpha.beta")).toBe(-1); + }); + + it("returns null when comparison cannot be made", () => { + expect(compareVersions(null, "1.0.0")).toBeNull(); + }); +}); + +describe("resolveRemoteVersion", () => { + it("prefers direct semver value on the update object", () => { + const update = makeUpdate({ version: "v1.2.3" }); + expect(resolveRemoteVersion(update)).toBe("1.2.3"); + }); + + it("falls back through rawJson fields when primary version is missing", () => { + const update = makeUpdate({ + version: "See release notes", + rawJson: { + version: "v2.3.4", + tag_name: "ignore-me", + name: "v0.0.1", + }, + }); + expect(resolveRemoteVersion(update)).toBe("2.3.4"); + }); + + it("rescues version from tag_name or name when needed", () => { + const update = makeUpdate({ + version: "no version here", + rawJson: { + tag_name: "release-v3.1.0", + name: "build-should-not-override", + }, + }); + expect(resolveRemoteVersion(update)).toBe("3.1.0"); + + const nameOnly = makeUpdate({ + version: "invalid", + rawJson: { + name: "release v4.0.0-beta.1", + }, + }); + expect(resolveRemoteVersion(nameOnly)).toBe("4.0.0-beta.1"); + }); + + it("returns null when no semver-like data is present", () => { + const update = makeUpdate({ + version: "not-a-version", + rawJson: { + name: "nope", + }, + }); + expect(resolveRemoteVersion(update)).toBeNull(); + }); +}); diff --git a/src/services/update.ts b/src/services/update.ts new file mode 100644 index 00000000..a9b0fe85 --- /dev/null +++ b/src/services/update.ts @@ -0,0 +1,155 @@ +import { + check, + type CheckOptions, + type Update, +} from "@tauri-apps/plugin-updater"; + +import { version as appVersion } from "@root/package.json"; + +export type VersionParts = { + main: number[]; + pre: (number | string)[]; +}; + +const SEMVER_FULL_REGEX = + /^\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const SEMVER_SEARCH_REGEX = + /v?\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/i; + +export const normalizeVersion = ( + input: string | null | undefined, +): string | null => { + if (typeof input !== "string") return null; + const trimmed = input.trim(); + if (!trimmed) return null; + return trimmed.replace(/^v/i, ""); +}; + +export const ensureSemver = ( + input: string | null | undefined, +): string | null => { + const normalized = normalizeVersion(input); + if (!normalized) return null; + return SEMVER_FULL_REGEX.test(normalized) ? normalized : null; +}; + +export const extractSemver = ( + input: string | null | undefined, +): string | null => { + if (typeof input !== "string") return null; + const match = input.match(SEMVER_SEARCH_REGEX); + if (!match) return null; + return normalizeVersion(match[0]); +}; + +export const splitVersion = (version: string | null): VersionParts | null => { + if (!version) return null; + const [mainPart, preRelease] = version.split("-"); + const main = mainPart + .split(".") + .map((part) => Number.parseInt(part, 10)) + .map((num) => (Number.isNaN(num) ? 0 : num)); + + const pre = + preRelease?.split(".").map((token) => { + const numeric = Number.parseInt(token, 10); + return Number.isNaN(numeric) ? token : numeric; + }) ?? []; + + return { main, pre }; +}; + +const compareVersionParts = (a: VersionParts, b: VersionParts): number => { + const length = Math.max(a.main.length, b.main.length); + for (let i = 0; i < length; i += 1) { + const diff = (a.main[i] ?? 0) - (b.main[i] ?? 0); + if (diff !== 0) return diff > 0 ? 1 : -1; + } + + if (a.pre.length === 0 && b.pre.length === 0) return 0; + if (a.pre.length === 0) return 1; + if (b.pre.length === 0) return -1; + + const preLen = Math.max(a.pre.length, b.pre.length); + for (let i = 0; i < preLen; i += 1) { + const aToken = a.pre[i]; + const bToken = b.pre[i]; + if (aToken === undefined) return -1; + if (bToken === undefined) return 1; + + if (typeof aToken === "number" && typeof bToken === "number") { + if (aToken > bToken) return 1; + if (aToken < bToken) return -1; + continue; + } + + if (typeof aToken === "number") return -1; + if (typeof bToken === "number") return 1; + + if (aToken > bToken) return 1; + if (aToken < bToken) return -1; + } + + return 0; +}; + +export const compareVersions = ( + a: string | null, + b: string | null, +): number | null => { + const partsA = splitVersion(a); + const partsB = splitVersion(b); + if (!partsA || !partsB) return null; + return compareVersionParts(partsA, partsB); +}; + +export const resolveRemoteVersion = (update: Update): string | null => { + const primary = ensureSemver(update.version); + if (primary) return primary; + + const fallbackPrimary = extractSemver(update.version); + if (fallbackPrimary) return fallbackPrimary; + + const raw = update.rawJson ?? {}; + const rawVersion = ensureSemver( + typeof raw.version === "string" ? raw.version : null, + ); + if (rawVersion) return rawVersion; + + const tagVersion = extractSemver( + typeof raw.tag_name === "string" ? raw.tag_name : null, + ); + if (tagVersion) return tagVersion; + + const nameVersion = extractSemver( + typeof raw.name === "string" ? raw.name : null, + ); + if (nameVersion) return nameVersion; + + return null; +}; + +const localVersionNormalized = normalizeVersion(appVersion); + +export const checkUpdateSafe = async ( + options?: CheckOptions, +): Promise => { + const result = await check({ ...(options ?? {}), allowDowngrades: false }); + if (!result) return null; + + const remoteVersion = resolveRemoteVersion(result); + const comparison = compareVersions(remoteVersion, localVersionNormalized); + + if (comparison !== null && comparison <= 0) { + try { + await result.close(); + } catch (err) { + console.warn("[updater] failed to close stale update resource", err); + } + return null; + } + + return result; +}; + +export type { CheckOptions }; diff --git a/vite.config.mts b/vite.config.mts index b33edbca..86f0d0e4 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -1,11 +1,12 @@ -import { defineConfig } from "vite"; -import path from "path"; -import svgr from "vite-plugin-svgr"; -import react from "@vitejs/plugin-react"; +import path from "node:path"; + import legacy from "@vitejs/plugin-legacy"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; import monacoEditorPlugin, { type IMonacoEditorOpts, } from "vite-plugin-monaco-editor"; +import svgr from "vite-plugin-svgr"; const monacoEditorPluginDefault = (monacoEditorPlugin as any).default as ( options: IMonacoEditorOpts, ) => any; @@ -159,6 +160,10 @@ export default defineConfig({ "@root": path.resolve("."), }, }, + test: { + environment: "node", + include: ["**/*.{test,spec}.{ts,tsx}"], + }, define: { OS_PLATFORM: `"${process.platform}"`,