From 3c3d259b9076a8e935f52287157599abcfb514d1 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 24 Aug 2026 09:28:28 +0000
Subject: [PATCH 01/59] chore(go-deps:backend)(deps): bump the go-dependencies
group with 3 updates
Bumps the go-dependencies group with 3 updates: [github.com/mmcdole/gofeed](https://github.com/mmcdole/gofeed), [github.com/wailsapp/wails/v3](https://github.com/wailsapp/wails) and [modernc.org/sqlite](https://gitlab.com/cznic/sqlite).
Updates `github.com/mmcdole/gofeed` from 1.4.1 to 1.4.2
- [Release notes](https://github.com/mmcdole/gofeed/releases)
- [Commits](https://github.com/mmcdole/gofeed/compare/v1.4.1...v1.4.2)
Updates `github.com/wailsapp/wails/v3` from 3.0.0-beta.8 to 3.0.0-beta.11
- [Release notes](https://github.com/wailsapp/wails/releases)
- [Commits](https://github.com/wailsapp/wails/compare/v3.0.0-beta.8...v3.0.0-beta.11)
Updates `modernc.org/sqlite` from 1.56.0 to 1.57.0
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.56.0...v1.57.0)
---
updated-dependencies:
- dependency-name: github.com/mmcdole/gofeed
dependency-version: 1.4.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: go-dependencies
- dependency-name: github.com/wailsapp/wails/v3
dependency-version: 3.0.0-beta.11
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: go-dependencies
- dependency-name: modernc.org/sqlite
dependency-version: 1.57.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-dependencies
...
Signed-off-by: dependabot[bot]
---
go.mod | 6 +++---
go.sum | 12 ++++++------
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/go.mod b/go.mod
index ca9cafe6..7228a2d1 100644
--- a/go.mod
+++ b/go.mod
@@ -15,13 +15,13 @@ require (
github.com/go-ego/gse v1.0.2
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/longbridgeapp/opencc v0.3.13
- github.com/mmcdole/gofeed v1.4.1
+ github.com/mmcdole/gofeed v1.4.2
github.com/swaggo/http-swagger v1.3.4
- github.com/wailsapp/wails/v3 v3.0.0-beta.8
+ github.com/wailsapp/wails/v3 v3.0.0-beta.11
golang.org/x/crypto v0.55.0
golang.org/x/net v0.58.0
golang.org/x/text v0.41.0
- modernc.org/sqlite v1.56.0
+ modernc.org/sqlite v1.57.0
)
require (
diff --git a/go.sum b/go.sum
index b66ddcf8..6ad3fee9 100644
--- a/go.sum
+++ b/go.sum
@@ -131,8 +131,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
-github.com/mmcdole/gofeed v1.4.1 h1:m7vd4YAukLvoqUQghaILKbqUquHyZq1jBYkeCke/Z3c=
-github.com/mmcdole/gofeed v1.4.1/go.mod h1:X5x1PyeibJi152VEya0AsV+PW4daYmCD4LJaJbeFkcs=
+github.com/mmcdole/gofeed v1.4.2 h1:XFFOtsNNZg+zudjtMXb8BI0J/YdnSIGfjEpPgPnZib0=
+github.com/mmcdole/gofeed v1.4.2/go.mod h1:X5x1PyeibJi152VEya0AsV+PW4daYmCD4LJaJbeFkcs=
github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5rQ=
github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -171,8 +171,8 @@ github.com/vcaesar/cedar v0.30.0 h1:9fSDpM7FTjjUdPiBUUa0MWYMRGSEcqgFXvppZcZ4d7Y=
github.com/vcaesar/cedar v0.30.0/go.mod h1:lyuGvALuZZDPNXwpzv/9LyxW+8Y6faN7zauFezNsnik=
github.com/vcaesar/tt v0.20.1 h1:D/jUeeVCNbq3ad8M7hhtB3J9x5RZ6I1n1eZ0BJp7M+4=
github.com/vcaesar/tt v0.20.1/go.mod h1:cH2+AwGAJm19Wa6xvEa+0r+sXDJBT0QgNQey6mwqLeU=
-github.com/wailsapp/wails/v3 v3.0.0-beta.8 h1:r5d+5ERZ6TYI/OPJnqq91Ugux+TkfplCURUi/+6vA/Q=
-github.com/wailsapp/wails/v3 v3.0.0-beta.8/go.mod h1:A/OaL1mXOnwWynTJv4rZU89Wbk5q3rtq3C3qkx4rRN0=
+github.com/wailsapp/wails/v3 v3.0.0-beta.11 h1:+DA9+DjHq/zV1Wkrg637WD0nSKly20w6jYbe/4LCwk4=
+github.com/wailsapp/wails/v3 v3.0.0-beta.11/go.mod h1:zKZYhB3WjrN5LhJWbnOAVMN0Xf8qTozbw2nf5micKl4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
@@ -301,8 +301,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
-modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
+modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
+modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
From 465210a90dee7f1e060089d92d86922eb5b1de2d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 24 Aug 2026 09:28:34 +0000
Subject: [PATCH 02/59] chore(npm-deps:website)(deps-dev): bump vite
Bumps the website-dependencies group in /website with 1 update: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).
Updates `vite` from 8.2.1 to 8.2.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.2/packages/vite)
---
updated-dependencies:
- dependency-name: vite
dependency-version: 8.2.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: website-dependencies
...
Signed-off-by: dependabot[bot]
---
website/package-lock.json | 158 +++++++++++++++++++++-----------------
website/package.json | 2 +-
2 files changed, 89 insertions(+), 71 deletions(-)
diff --git a/website/package-lock.json b/website/package-lock.json
index f8221aec..ab2866ab 100644
--- a/website/package-lock.json
+++ b/website/package-lock.json
@@ -18,7 +18,7 @@
"@vitejs/plugin-vue": "^6.0.8",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
- "vite": "^8.2.1"
+ "vite": "^8.2.2"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -254,9 +254,9 @@
"license": "MIT"
},
"node_modules/@oxc-project/types": {
- "version": "0.143.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
- "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
+ "version": "0.146.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
+ "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -275,10 +275,27 @@
"vue": ">=3.2.39"
}
},
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
+ "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
- "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz",
+ "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==",
"cpu": [
"arm64"
],
@@ -293,9 +310,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
- "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz",
+ "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==",
"cpu": [
"arm64"
],
@@ -310,9 +327,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
- "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz",
+ "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==",
"cpu": [
"x64"
],
@@ -327,9 +344,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
- "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz",
+ "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==",
"cpu": [
"x64"
],
@@ -344,9 +361,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
- "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz",
+ "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==",
"cpu": [
"arm"
],
@@ -361,9 +378,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
- "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz",
+ "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==",
"cpu": [
"arm64"
],
@@ -381,9 +398,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
- "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz",
+ "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==",
"cpu": [
"arm64"
],
@@ -401,9 +418,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
- "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz",
+ "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==",
"cpu": [
"ppc64"
],
@@ -421,9 +438,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
- "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz",
+ "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==",
"cpu": [
"s390x"
],
@@ -441,9 +458,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
- "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz",
+ "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==",
"cpu": [
"x64"
],
@@ -461,9 +478,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
- "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz",
+ "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==",
"cpu": [
"x64"
],
@@ -481,9 +498,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
- "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz",
+ "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==",
"cpu": [
"arm64"
],
@@ -498,9 +515,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
- "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz",
+ "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==",
"cpu": [
"arm64"
],
@@ -515,9 +532,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
- "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz",
+ "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==",
"cpu": [
"x64"
],
@@ -1922,13 +1939,13 @@
}
},
"node_modules/rolldown": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
- "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
+ "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.143.0",
+ "@oxc-project/types": "=0.146.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -1938,20 +1955,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.2.3",
- "@rolldown/binding-darwin-arm64": "1.2.3",
- "@rolldown/binding-darwin-x64": "1.2.3",
- "@rolldown/binding-freebsd-x64": "1.2.3",
- "@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
- "@rolldown/binding-linux-arm64-gnu": "1.2.3",
- "@rolldown/binding-linux-arm64-musl": "1.2.3",
- "@rolldown/binding-linux-ppc64-gnu": "1.2.3",
- "@rolldown/binding-linux-s390x-gnu": "1.2.3",
- "@rolldown/binding-linux-x64-gnu": "1.2.3",
- "@rolldown/binding-linux-x64-musl": "1.2.3",
- "@rolldown/binding-openharmony-arm64": "1.2.3",
- "@rolldown/binding-win32-arm64-msvc": "1.2.3",
- "@rolldown/binding-win32-x64-msvc": "1.2.3"
+ "@rolldown/binding-android-arm-eabi": "1.2.5",
+ "@rolldown/binding-android-arm64": "1.2.5",
+ "@rolldown/binding-darwin-arm64": "1.2.5",
+ "@rolldown/binding-darwin-x64": "1.2.5",
+ "@rolldown/binding-freebsd-x64": "1.2.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.5",
+ "@rolldown/binding-linux-arm64-musl": "1.2.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.5",
+ "@rolldown/binding-linux-x64-gnu": "1.2.5",
+ "@rolldown/binding-linux-x64-musl": "1.2.5",
+ "@rolldown/binding-openharmony-arm64": "1.2.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.5",
+ "@rolldown/binding-win32-x64-msvc": "1.2.5"
}
},
"node_modules/semver": {
@@ -2068,16 +2086,16 @@
}
},
"node_modules/vite": {
- "version": "8.2.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
- "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
- "postcss": "^8.5.25",
- "rolldown": "~1.2.1",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -2094,7 +2112,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.4.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
diff --git a/website/package.json b/website/package.json
index ba9f6283..b4642414 100644
--- a/website/package.json
+++ b/website/package.json
@@ -21,6 +21,6 @@
"@vitejs/plugin-vue": "^6.0.8",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
- "vite": "^8.2.1"
+ "vite": "^8.2.2"
}
}
From b08169d4cb2a1fbe8be9951d329bc2a6c563b90b Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 24 Aug 2026 09:28:55 +0000
Subject: [PATCH 03/59] chore(npm-deps:frontend)(deps-dev): bump the
frontend-dependencies group
Bumps the frontend-dependencies group in /frontend with 4 updates: [@wailsio/runtime](https://github.com/wailsapp/wails/tree/HEAD/v3/internal/runtime/desktop/@wailsio/runtime), [cypress](https://github.com/cypress-io/cypress), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).
Updates `@wailsio/runtime` from 3.0.0-beta.8 to 3.0.0-beta.11
- [Release notes](https://github.com/wailsapp/wails/releases)
- [Commits](https://github.com/wailsapp/wails/commits/v3.0.0-beta.11/v3/internal/runtime/desktop/@wailsio/runtime)
Updates `cypress` from 15.20.1 to 15.21.0
- [Release notes](https://github.com/cypress-io/cypress/releases)
- [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/cypress-io/cypress/compare/v15.20.1...v15.21.0)
Updates `vite` from 8.2.1 to 8.2.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.2/packages/vite)
Updates `vitest` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)
---
updated-dependencies:
- dependency-name: "@wailsio/runtime"
dependency-version: 3.0.0-beta.11
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: frontend-dependencies
- dependency-name: cypress
dependency-version: 15.21.0
dependency-type: direct:development
update-type: version-update:semver-minor
dependency-group: frontend-dependencies
- dependency-name: vite
dependency-version: 8.2.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: frontend-dependencies
- dependency-name: vitest
dependency-version: 4.1.11
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: frontend-dependencies
...
Signed-off-by: dependabot[bot]
---
frontend/package-lock.json | 215 +++++++++++++++++++------------------
frontend/package.json | 8 +-
2 files changed, 117 insertions(+), 106 deletions(-)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1015cbe9..1b88cd5a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -28,9 +28,9 @@
"@vitejs/plugin-vue": "^6.0.8",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/test-utils": "^2.4.11",
- "@wailsio/runtime": "^3.0.0-beta.8",
+ "@wailsio/runtime": "^3.0.0-beta.11",
"autoprefixer": "^10.5.4",
- "cypress": "^15.20.1",
+ "cypress": "^15.21.0",
"eslint": "^10.8.1",
"eslint-plugin-vue": "^10.10.0",
"husky": "^9.1.7",
@@ -40,8 +40,8 @@
"postcss": "^8.5.26",
"prettier": "^3.9.6",
"typescript": "^7.0.2",
- "vite": "^8.2.1",
- "vitest": "^4.1.10",
+ "vite": "^8.2.2",
+ "vitest": "^4.1.11",
"vue-eslint-parser": "^10.4.1"
}
},
@@ -131,7 +131,6 @@
"integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^8.0.0",
"@babel/generator": "^8.0.0",
@@ -703,7 +702,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -752,7 +750,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -850,7 +847,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
@@ -863,7 +859,6 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -2883,7 +2878,6 @@
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~8.3.0"
}
@@ -2916,7 +2910,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2933,7 +2926,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2950,7 +2942,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2967,7 +2958,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2984,7 +2974,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3001,7 +2990,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3018,7 +3006,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3035,7 +3022,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3052,7 +3038,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3069,7 +3054,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3086,7 +3070,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3103,7 +3086,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3120,7 +3102,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3137,7 +3118,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3154,7 +3134,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3171,7 +3150,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3188,7 +3166,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3205,7 +3182,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3222,7 +3198,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3239,7 +3214,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3267,16 +3241,16 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
- "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.10",
- "@vitest/utils": "4.1.10",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -3285,13 +3259,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
- "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.10",
+ "@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -3312,9 +3286,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
- "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3325,13 +3299,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
- "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.10",
+ "@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@@ -3339,14 +3313,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
- "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.10",
- "@vitest/utils": "4.1.10",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3355,9 +3329,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
- "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3365,13 +3339,13 @@
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
- "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.10",
+ "@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -3415,7 +3389,6 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz",
"integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.41",
"@vue/shared": "3.5.41"
@@ -3469,6 +3442,7 @@
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz",
"integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/devtools-shared": "^8.2.1",
"birpc": "^2.6.1",
@@ -3480,7 +3454,8 @@
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz",
"integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@vue/eslint-config-prettier": {
"version": "10.2.0",
@@ -3567,9 +3542,9 @@
}
},
"node_modules/@wailsio/runtime": {
- "version": "3.0.0-beta.8",
- "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-beta.8.tgz",
- "integrity": "sha512-c9PZJcOR9z1a6cxtBS2q5cygNajlxDE8Oxv/vvcTFYRbZSoOGBszq4uzlkPTOd0Bot1xkM81jnpaBgKWRpBh2g==",
+ "version": "3.0.0-beta.11",
+ "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-beta.11.tgz",
+ "integrity": "sha512-pMHts/Qunm4jPh67f+FCGb4YngMag7VSxol0Gw+8Ka5E/qn4StGX0w72haONH8bjgJZYnwgZvydIKIcoMsnAgA==",
"dev": true,
"license": "MIT"
},
@@ -3589,7 +3564,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3854,6 +3828,7 @@
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/antfu"
}
@@ -3909,7 +3884,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001803",
@@ -4058,6 +4032,27 @@
"node": ">=8"
}
},
+ "node_modules/chrome-remote-interface": {
+ "version": "0.33.3",
+ "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.33.3.tgz",
+ "integrity": "sha512-zNnn0prUL86Teru6UCAZ1yU1XeXljHl3gj7OrfPcarEfU62OUU4IujDPdTDW3dAWwRqN3ZMG/Chhkh2gPL/wiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "commander": "2.11.x",
+ "ws": "^7.2.0"
+ },
+ "bin": {
+ "chrome-remote-interface": "bin/client.js"
+ }
+ },
+ "node_modules/chrome-remote-interface/node_modules/commander": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz",
+ "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/ci-info": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
@@ -4351,13 +4346,12 @@
"license": "MIT"
},
"node_modules/cypress": {
- "version": "15.20.1",
- "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.20.1.tgz",
- "integrity": "sha512-7GV3O7vQQG+EVVv9BGs6lCHY49x25jVi/z1+zdjuuq/o3eZeDwmGPJpRYnOWeGvQxTCtkydXWHBPhV8xuGXPLg==",
+ "version": "15.21.0",
+ "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.21.0.tgz",
+ "integrity": "sha512-6uPy5GUjao97t/QDIwlPyJz4JUqPpFIPq5lNazN95LSA3ynJoYC1sHZ3W+aj4f+K1OFGbMfno0+OX2Gx0OmlPA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@cypress/request": "^4.0.0",
"@cypress/xvfb": "^1.2.4",
@@ -4370,6 +4364,7 @@
"buffer": "^5.7.1",
"cachedir": "^2.4.0",
"chalk": "^4.1.0",
+ "chrome-remote-interface": "0.33.3",
"ci-info": "^4.1.0",
"cli-table3": "0.6.1",
"commander": "^6.2.1",
@@ -4784,7 +4779,6 @@
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"workspaces": [
"packages/*"
],
@@ -4844,7 +4838,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -5764,7 +5757,8 @@
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
@@ -6006,7 +6000,7 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -7414,7 +7408,8 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/performance-now": {
"version": "2.1.0",
@@ -7496,7 +7491,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
@@ -7543,7 +7537,6 @@
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -8374,7 +8367,6 @@
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc"
},
@@ -8515,17 +8507,16 @@
}
},
"node_modules/vite": {
- "version": "8.2.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
- "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
- "postcss": "^8.5.25",
- "rolldown": "~1.2.1",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -8542,7 +8533,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.4.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -8594,19 +8585,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
- "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.10",
- "@vitest/mocker": "4.1.10",
- "@vitest/pretty-format": "4.1.10",
- "@vitest/runner": "4.1.10",
- "@vitest/snapshot": "4.1.10",
- "@vitest/spy": "4.1.10",
- "@vitest/utils": "4.1.10",
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -8634,12 +8625,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.10",
- "@vitest/browser-preview": "4.1.10",
- "@vitest/browser-webdriverio": "4.1.10",
- "@vitest/coverage-istanbul": "4.1.10",
- "@vitest/coverage-v8": "4.1.10",
- "@vitest/ui": "4.1.10",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -8688,7 +8679,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz",
"integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.41",
"@vue/compiler-sfc": "3.5.41",
@@ -8718,7 +8708,6 @@
"integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0 || ^9.0.0",
@@ -8989,6 +8978,28 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/ws": {
+ "version": "7.5.13",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
+ "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.3.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index c41f0fd8..e3fbb0cb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -47,9 +47,9 @@
"@vitejs/plugin-vue": "^6.0.8",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/test-utils": "^2.4.11",
- "@wailsio/runtime": "^3.0.0-beta.8",
+ "@wailsio/runtime": "^3.0.0-beta.11",
"autoprefixer": "^10.5.4",
- "cypress": "^15.20.1",
+ "cypress": "^15.21.0",
"eslint": "^10.8.1",
"eslint-plugin-vue": "^10.10.0",
"husky": "^9.1.7",
@@ -59,8 +59,8 @@
"postcss": "^8.5.26",
"prettier": "^3.9.6",
"typescript": "^7.0.2",
- "vite": "^8.2.1",
- "vitest": "^4.1.10",
+ "vite": "^8.2.2",
+ "vitest": "^4.1.11",
"vue-eslint-parser": "^10.4.1"
},
"lint-staged": {
From 361be046e35f22ff3d2aaaca643cc3c404464062 Mon Sep 17 00:00:00 2001
From: marcomarcogd <35049765+marcomarcogd@users.noreply.github.com>
Date: Mon, 24 Aug 2026 19:05:11 +0800
Subject: [PATCH 04/59] fix(search): explain AI results and open selected
articles
---
frontend/cypress/e2e/article-operations.cy.ts | 146 ++++++
.../src/components/article/AISearchBar.vue | 7 +
.../components/article/ArticleDetailModal.vue | 7 +-
.../src/components/article/ArticleList.vue | 184 ++++++-
.../composables/article/useArticleDetail.ts | 16 +-
.../composables/ui/useKeyboardShortcuts.ts | 14 +-
frontend/src/i18n/locales/en.ts | 6 +
frontend/src/i18n/locales/zh.ts | 6 +
frontend/src/stores/app.ts | 16 +
frontend/src/types/models.ts | 4 +
internal/database/article_search_db.go | 476 +++++++++++++++---
internal/database/sqlite_test.go | 131 +++++
internal/handlers/ai/ai_search.go | 202 ++++----
13 files changed, 991 insertions(+), 224 deletions(-)
diff --git a/frontend/cypress/e2e/article-operations.cy.ts b/frontend/cypress/e2e/article-operations.cy.ts
index 5b17e440..8cb0d71c 100644
--- a/frontend/cypress/e2e/article-operations.cy.ts
+++ b/frontend/cypress/e2e/article-operations.cy.ts
@@ -163,4 +163,150 @@ describe('Article Operations', () => {
}
});
});
+
+ it('should explain AI search results and keep list and card navigation in search context', () => {
+ const settingsState: Record = {
+ language: 'en-US',
+ theme: 'light',
+ layout_mode: 'normal',
+ default_view_mode: 'rendered',
+ ai_search_enabled: 'true',
+ translation_mode: 'off',
+ summary_enabled: 'false',
+ full_text_fetch_enabled: 'false',
+ update_check_enabled: 'false',
+ };
+ const feed = {
+ id: 1,
+ title: 'Search Feed',
+ url: 'https://example.com/feed.xml',
+ category: '',
+ article_view_mode: 'global',
+ };
+ const timelineArticle = {
+ id: 1,
+ feed_id: 1,
+ feed_title: feed.title,
+ title: 'Timeline article outside search results',
+ url: 'https://example.com/timeline',
+ published_at: '2026-08-20T00:00:00Z',
+ is_read: false,
+ is_favorite: false,
+ is_hidden: false,
+ is_read_later: false,
+ };
+ const searchArticles = [101, 102, 103].map((id, index) => ({
+ id,
+ feed_id: 1,
+ feed_title: feed.title,
+ title: `Search result ${index + 1}`,
+ url: `https://example.com/search/${id}`,
+ published_at: `2026-08-${23 - index}T00:00:00Z`,
+ is_read: false,
+ is_favorite: false,
+ is_hidden: false,
+ is_read_later: false,
+ relevance_score: 90 - index,
+ matched_terms: ['privacy'],
+ matched_fields: index === 0 ? ['title', 'summary'] : ['content'],
+ excerpt: `This privacy excerpt explains result ${index + 1}`,
+ }));
+
+ cy.intercept('/api/**', { statusCode: 200, body: {} });
+ cy.intercept('GET', '/api/settings', (req) => {
+ req.reply({ statusCode: 200, body: settingsState });
+ });
+ cy.intercept('GET', '/api/feeds', { statusCode: 200, body: [feed] }).as('searchFeeds');
+ cy.intercept('GET', '/api/tags', { statusCode: 200, body: [] });
+ cy.intercept('GET', '/api/saved-filters', { statusCode: 200, body: [] });
+ cy.intercept(
+ { method: 'GET', pathname: '/api/articles' },
+ { statusCode: 200, body: [timelineArticle] }
+ ).as('timelineArticles');
+ cy.intercept('GET', '/api/articles/unread-counts', { statusCode: 200, body: {} });
+ cy.intercept('GET', '/api/articles/filter-counts', { statusCode: 200, body: {} });
+ cy.intercept('GET', '/api/progress', { statusCode: 200, body: { is_running: false } });
+ cy.intercept('POST', '/api/ai/search', (req) => {
+ const noResults = req.body?.query === 'nothing matches';
+ req.reply({
+ statusCode: 200,
+ body: {
+ success: true,
+ articles: noResults ? [] : searchArticles,
+ total_count: noResults ? 0 : searchArticles.length,
+ },
+ });
+ }).as('aiSearch');
+ cy.intercept('GET', '/api/articles/content*', (req) => {
+ req.reply({
+ statusCode: 200,
+ body: { content: `Body for search result ${req.query.id}
`, cached: true },
+ });
+ }).as('searchArticleContent');
+ cy.intercept('POST', '/api/articles/read*', { statusCode: 200, body: { success: true } });
+ cy.intercept('POST', '/api/articles/favorite*', {
+ statusCode: 200,
+ body: { success: true },
+ });
+ cy.intercept('POST', '/api/articles/toggle-read-later*', {
+ statusCode: 200,
+ body: { success: true },
+ });
+
+ cy.window().then((win) => win.localStorage.setItem('showOnlyUnread', 'true'));
+ cy.reload();
+ cy.wait('@searchFeeds');
+ cy.wait('@timelineArticles');
+
+ cy.get('input[placeholder="Describe what you want to find..."]').type('privacy');
+ cy.contains('button', /^AI Search$/).click();
+ cy.wait('@aiSearch');
+ cy.contains('Title match').should('be.visible');
+ cy.contains('Summary match').should('be.visible');
+ cy.contains('This privacy excerpt explains result 1').should('be.visible');
+
+ cy.get('[data-article-id="101"]').click();
+ cy.wait('@searchArticleContent');
+ cy.contains('Body for search result 101').should('be.visible');
+
+ // The next result is marked read when opened, but must remain selected and
+ // renderable while the unread-only preference is active.
+ cy.get('button[title="Next Article"]').click();
+ cy.wait('@searchArticleContent');
+ cy.contains('Body for search result 102').should('be.visible');
+ cy.get('[data-article-id="102"]').should('exist');
+
+ // Global shortcuts must use the same ordered search context instead of the
+ // separately paginated timeline.
+ cy.get('body').trigger('keydown', { key: 'j' });
+ cy.wait('@searchArticleContent');
+ cy.contains('Body for search result 103').should('be.visible');
+ cy.get('button[title="Previous Article"]').click();
+ cy.wait('@searchArticleContent');
+ cy.contains('Body for search result 102').should('be.visible');
+
+ cy.contains('button', /^Clear$/).click();
+ cy.get('input[placeholder="Describe what you want to find..."]').type('nothing matches');
+ cy.contains('button', /^AI Search$/).click();
+ cy.wait('@aiSearch');
+ cy.contains('No articles found matching your search').should('be.visible');
+
+ // Card mode uses ArticleDetailModal, which must use the same navigation
+ // context instead of hiding navigation for off-page search results.
+ cy.then(() => {
+ settingsState.layout_mode = 'card';
+ });
+ cy.reload();
+ cy.wait('@searchFeeds');
+ cy.wait('@timelineArticles');
+ cy.get('input[placeholder="Describe what you want to find..."]').type('privacy');
+ cy.contains('button', /^AI Search$/).click();
+ cy.wait('@aiSearch');
+ cy.get('.article-card-item[data-article-id="101"]').click();
+ cy.wait('@searchArticleContent');
+ cy.contains('Body for search result 101').should('be.visible');
+ cy.get('button[title="Next Article"]').should('be.visible').click();
+ cy.wait('@searchArticleContent');
+ cy.contains('Body for search result 102').should('be.visible');
+ });
});
diff --git a/frontend/src/components/article/AISearchBar.vue b/frontend/src/components/article/AISearchBar.vue
index d1d60708..517f1f24 100644
--- a/frontend/src/components/article/AISearchBar.vue
+++ b/frontend/src/components/article/AISearchBar.vue
@@ -60,6 +60,13 @@ async function performAISearch() {
author: item.author as string,
translated_title: item.translated_title as string,
summary: item.summary as string,
+ original_summary: item.original_summary as string,
+ relevance_score: item.relevance_score as number,
+ matched_terms: Array.isArray(item.matched_terms) ? (item.matched_terms as string[]) : [],
+ matched_fields: Array.isArray(item.matched_fields)
+ ? (item.matched_fields as Article['matched_fields'])
+ : [],
+ excerpt: typeof item.excerpt === 'string' ? item.excerpt : '',
}));
hasResults.value = true;
diff --git a/frontend/src/components/article/ArticleDetailModal.vue b/frontend/src/components/article/ArticleDetailModal.vue
index 08100ef8..8f6fd6a8 100644
--- a/frontend/src/components/article/ArticleDetailModal.vue
+++ b/frontend/src/components/article/ArticleDetailModal.vue
@@ -151,14 +151,17 @@ async function exportToZotero() {
}
// Navigation
+const navigationArticles = computed(() => store.navigableArticles);
const currentArticleIndex = computed(() => {
if (!props.article) return -1;
- return store.articles.findIndex((a) => a.id === props.article.id);
+ return navigationArticles.value.findIndex((a) => a.id === props.article.id);
});
const hasPreviousArticle = computed(() => currentArticleIndex.value > 0);
const hasNextArticle = computed(
- () => currentArticleIndex.value >= 0 && currentArticleIndex.value < store.articles.length - 1
+ () =>
+ currentArticleIndex.value >= 0 &&
+ currentArticleIndex.value < navigationArticles.value.length - 1
);
// Load default view mode on mount
diff --git a/frontend/src/components/article/ArticleList.vue b/frontend/src/components/article/ArticleList.vue
index 75e4b60e..0c00fc1b 100644
--- a/frontend/src/components/article/ArticleList.vue
+++ b/frontend/src/components/article/ArticleList.vue
@@ -94,14 +94,18 @@ const isFilterLoading = computed(() => store.isFilterLoading);
// Computed filtered articles - optimized to avoid excessive recomputation
const filteredArticles = computed(() => {
- const usesClientSideUnreadFilter =
- activeFilters.value.length > 0 || (isAISearchActive.value && aiSearchResults.value.length > 0);
+ const usesClientSideUnreadFilter = activeFilters.value.length > 0 || isAISearchActive.value;
// If AI search is active, use AI search results
- if (isAISearchActive.value && aiSearchResults.value.length > 0) {
+ if (isAISearchActive.value) {
let articles = aiSearchResults.value;
if (store.showOnlyUnread) {
- articles = articles.filter((article) => !article.is_read);
+ articles = articles.filter(
+ (article) =>
+ !article.is_read ||
+ temporarilyKeepArticles.value.has(article.id) ||
+ article.id === store.currentArticleId
+ );
}
return articles;
}
@@ -129,13 +133,66 @@ const filteredArticles = computed(() => {
// AI Search handlers
function handleAISearchResults(articles: Article[]) {
+ temporarilyKeepArticles.value.clear();
aiSearchResults.value = articles;
isAISearchActive.value = true;
+ store.setArticleNavigationContext(articles);
}
function handleAISearchClear() {
+ temporarilyKeepArticles.value.clear();
aiSearchResults.value = [];
isAISearchActive.value = false;
+ store.setArticleNavigationContext(null);
+ if (
+ store.currentArticleId !== null &&
+ !store.articles.some((article) => article.id === store.currentArticleId)
+ ) {
+ store.currentArticleId = null;
+ }
+}
+
+interface SearchExcerptPart {
+ text: string;
+ matched: boolean;
+}
+
+function searchExcerptParts(article: Article): SearchExcerptPart[] {
+ const excerpt = article.excerpt || '';
+ const terms = (article.matched_terms || [])
+ .map((term) => term.replaceAll('%', ' ').replace(/\s+/g, ' ').trim())
+ .filter(Boolean)
+ .sort((a, b) => b.length - a.length);
+ if (!excerpt || terms.length === 0) return excerpt ? [{ text: excerpt, matched: false }] : [];
+
+ const result: SearchExcerptPart[] = [];
+ const lowerExcerpt = excerpt.toLocaleLowerCase();
+ let position = 0;
+ while (position < excerpt.length) {
+ let nextIndex = -1;
+ let nextTerm = '';
+ for (const term of terms) {
+ const index = lowerExcerpt.indexOf(term.toLocaleLowerCase(), position);
+ if (index >= 0 && (nextIndex < 0 || index < nextIndex)) {
+ nextIndex = index;
+ nextTerm = term;
+ }
+ }
+ if (nextIndex < 0) {
+ result.push({ text: excerpt.slice(position), matched: false });
+ break;
+ }
+ if (nextIndex > position) {
+ result.push({ text: excerpt.slice(position, nextIndex), matched: false });
+ }
+ result.push({ text: excerpt.slice(nextIndex, nextIndex + nextTerm.length), matched: true });
+ position = nextIndex + nextTerm.length;
+ }
+ return result;
+}
+
+function searchFieldLabel(field: 'title' | 'summary' | 'content'): string {
+ return t(`aiSearch.matchFields.${field}`);
}
const { showArticleContextMenu } = useArticleActions(t, defaultViewMode, async () => {
@@ -308,7 +365,34 @@ watch(
}
);
+// Keep detail navigation aligned with the currently visible AI result order,
+// including the unread-only preference, without replacing the main timeline.
+watch(
+ () => (isAISearchActive.value ? filteredArticles.value.map((article) => article.id) : []),
+ () => {
+ if (isAISearchActive.value) {
+ store.setArticleNavigationContext([...filteredArticles.value]);
+ }
+ }
+);
+
+// Detail buttons and global shortcuts can move inside the search result set
+// without going through selectArticle(). Keep only the newly selected result
+// visible when the unread-only filter marks it as read.
+watch(
+ () => store.currentArticleId,
+ (articleId) => {
+ if (!isAISearchActive.value) return;
+ if (articleId !== null && aiSearchResults.value.some((article) => article.id === articleId)) {
+ temporarilyKeepArticles.value.add(articleId);
+ }
+ }
+);
+
onBeforeUnmount(() => {
+ if (isAISearchActive.value) {
+ store.setArticleNavigationContext(null);
+ }
cleanupTranslation();
// Clear scroll throttle timer
if (scrollThrottleTimer) {
@@ -452,7 +536,7 @@ function selectArticle(article: Article): void {
// Normal article selection - show in app
// If switching from one article to another, remove the previous one from temp list
- if (store.currentArticleId) {
+ if (store.currentArticleId && !isAISearchActive.value) {
temporarilyKeepArticles.value.delete(store.currentArticleId);
}
@@ -1021,27 +1105,85 @@ async function markAllVisibleAsRead(): Promise {
-
showArticleContextMenu(e, article)"
- />
+ class="min-w-0 overflow-hidden rounded-lg"
+ >
+ showArticleContextMenu(e, article)"
+ />
+
+
+
+ {{
+ t('aiSearch.relevanceScore', { score: Math.round(article.relevance_score || 0) })
+ }}
+
+
+ {{ searchFieldLabel(field) }}
+
+
+
+
+ {{
+ part.text
+ }}
+ {{ part.text }}
+
+
+
+
-
showArticleContextMenu(e, article)"
- @observe-element="observeArticle"
- @hover-mark-as-read="handleHoverMarkAsRead"
- />
+
+
showArticleContextMenu(e, article)"
+ @observe-element="observeArticle"
+ @hover-mark-as-read="handleHoverMarkAsRead"
+ />
+
+
+
+ {{
+ t('aiSearch.relevanceScore', { score: Math.round(article.relevance_score || 0) })
+ }}
+
+
+ {{ searchFieldLabel(field) }}
+
+
+
+
+ {{
+ part.text
+ }}
+ {{ part.text }}
+
+
+
+
diff --git a/frontend/src/composables/article/useArticleDetail.ts b/frontend/src/composables/article/useArticleDetail.ts
index 64b3656e..1279ef5c 100644
--- a/frontend/src/composables/article/useArticleDetail.ts
+++ b/frontend/src/composables/article/useArticleDetail.ts
@@ -24,14 +24,16 @@ export function useArticleDetail() {
const store = useAppStore();
const { t, locale } = useI18n();
+ const navigationArticles = computed(() => store.navigableArticles);
+
const article = computed(() =>
- store.articles.find((a) => a.id === store.currentArticleId)
+ navigationArticles.value.find((a) => a.id === store.currentArticleId)
);
// Get current article index in the filtered list
const currentArticleIndex = computed(() => {
if (!store.currentArticleId) return -1;
- return store.articles.findIndex((a) => a.id === store.currentArticleId);
+ return navigationArticles.value.findIndex((a) => a.id === store.currentArticleId);
});
// Check if there's a previous article
@@ -39,13 +41,15 @@ export function useArticleDetail() {
// Check if there's a next article
const hasNextArticle = computed(
- () => currentArticleIndex.value >= 0 && currentArticleIndex.value < store.articles.length - 1
+ () =>
+ currentArticleIndex.value >= 0 &&
+ currentArticleIndex.value < navigationArticles.value.length - 1
);
// Navigate to previous article
function goToPreviousArticle() {
if (hasPreviousArticle.value) {
- const prevArticle = store.articles[currentArticleIndex.value - 1];
+ const prevArticle = navigationArticles.value[currentArticleIndex.value - 1];
store.currentArticleId = prevArticle.id;
markAsReadIfNeeded(prevArticle);
scrollArticleIntoView(prevArticle.id);
@@ -55,7 +59,7 @@ export function useArticleDetail() {
// Navigate to next article
function goToNextArticle() {
if (hasNextArticle.value) {
- const nextArticle = store.articles[currentArticleIndex.value + 1];
+ const nextArticle = navigationArticles.value[currentArticleIndex.value + 1];
store.currentArticleId = nextArticle.id;
markAsReadIfNeeded(nextArticle);
scrollArticleIntoView(nextArticle.id);
@@ -88,7 +92,7 @@ export function useArticleDetail() {
}
// Expose articles list and index for UI display
- const articles = computed(() => store.articles);
+ const articles = computed(() => navigationArticles.value);
const currentArticleIndexForDisplay = computed(() => currentArticleIndex.value + 1);
// Get effective view mode based on feed settings and global settings
diff --git a/frontend/src/composables/ui/useKeyboardShortcuts.ts b/frontend/src/composables/ui/useKeyboardShortcuts.ts
index 140e22f5..135f096e 100644
--- a/frontend/src/composables/ui/useKeyboardShortcuts.ts
+++ b/frontend/src/composables/ui/useKeyboardShortcuts.ts
@@ -83,7 +83,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) {
}
function navigateArticle(direction: number): void {
- const articles = store.articles;
+ const articles = store.navigableArticles;
if (!articles || articles.length === 0) return;
const currentIndex = store.currentArticleId
@@ -103,7 +103,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) {
}
function selectArticleByIndex(index: number): void {
- const article = store.articles[index];
+ const article = store.navigableArticles[index];
if (!article) return;
store.currentArticleId = article.id;
@@ -126,7 +126,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) {
}
function toggleCurrentArticleRead(): void {
- const article = store.articles.find((a) => a.id === store.currentArticleId);
+ const article = store.navigableArticles.find((a) => a.id === store.currentArticleId);
if (!article) return;
const newState = !article.is_read;
@@ -140,7 +140,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) {
}
function toggleCurrentArticleFavorite(): void {
- const article = store.articles.find((a) => a.id === store.currentArticleId);
+ const article = store.navigableArticles.find((a) => a.id === store.currentArticleId);
if (!article) return;
const newState = !article.is_favorite;
@@ -152,7 +152,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) {
}
function toggleCurrentArticleReadLater(): void {
- const article = store.articles.find((a) => a.id === store.currentArticleId);
+ const article = store.navigableArticles.find((a) => a.id === store.currentArticleId);
if (!article) return;
const newState = !article.is_read_later;
@@ -170,7 +170,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) {
}
function openCurrentArticleInBrowser(): void {
- const article = store.articles.find((a) => a.id === store.currentArticleId);
+ const article = store.navigableArticles.find((a) => a.id === store.currentArticleId);
if (article && article.url) {
openInBrowser(article.url);
}
@@ -372,7 +372,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutCallbacks) {
navigateArticle(-1);
break;
case 'openArticle':
- if (store.articles.length > 0 && !store.currentArticleId) {
+ if (store.navigableArticles.length > 0 && !store.currentArticleId) {
selectArticleByIndex(0);
}
break;
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 97e28877..6cbb8d34 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -137,6 +137,12 @@ const en: TranslationMessages = {
foundResults: 'Found {count} articles',
noResults: 'No articles found matching your search',
placeholder: 'Describe what you want to find...',
+ relevanceScore: 'Relevance {score}',
+ matchFields: {
+ title: 'Title match',
+ summary: 'Summary match',
+ content: 'Content match',
+ },
searchFailed: 'AI search failed. Please check your AI settings.',
showingResults: 'Showing AI search results',
},
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 726d3267..7ce00d29 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -132,6 +132,12 @@ const zh: TranslationMessages = {
foundResults: '找到 {count} 篇文章',
noResults: '没有找到符合搜索条件的文章',
placeholder: '描述你想要查找的内容...',
+ relevanceScore: '相关度 {score}',
+ matchFields: {
+ title: '标题命中',
+ summary: '摘要命中',
+ content: '正文命中',
+ },
searchFailed: 'AI 搜索失败,请检查 AI 设置。',
showingResults: '正在显示 AI 搜索结果',
},
diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts
index e90a4de2..1f42c2b3 100644
--- a/frontend/src/stores/app.ts
+++ b/frontend/src/stores/app.ts
@@ -33,6 +33,7 @@ export interface AppState {
showOnlyUnread: Ref;
activeFilters: Ref;
filteredArticlesFromServer: Ref;
+ articleNavigationContext: Ref;
isFilterLoading: Ref;
}
@@ -57,6 +58,7 @@ export interface AppActions {
startAutoRefresh: (minutes: number) => void;
toggleShowOnlyUnread: () => void;
setActiveFilters: (filters: FilterCondition[]) => void;
+ setArticleNavigationContext: (articles: Article[] | null) => void;
}
export const useAppStore = defineStore('app', () => {
@@ -99,6 +101,13 @@ export const useAppStore = defineStore('app', () => {
const showOnlyUnread = ref(localStorage.getItem('showOnlyUnread') === 'true');
const activeFilters = ref([]);
const filteredArticlesFromServer = ref([]);
+ // A temporary ordered list used by result views (for example AI search).
+ // Keeping this in the store lets ArticleDetail resolve and navigate articles
+ // which are not part of the currently paginated timeline.
+ const articleNavigationContext = ref(null);
+ const navigableArticles = computed(
+ () => articleNavigationContext.value ?? articles.value
+ );
const isFilterLoading = ref(false);
// Article view mode preferences (persisted across component mounts)
@@ -782,6 +791,10 @@ export const useAppStore = defineStore('app', () => {
filteredArticlesFromServer.value = articles;
}
+ function setArticleNavigationContext(articles: Article[] | null): void {
+ articleNavigationContext.value = articles;
+ }
+
function setIsFilterLoading(loading: boolean): void {
isFilterLoading.value = loading;
}
@@ -826,6 +839,8 @@ export const useAppStore = defineStore('app', () => {
showOnlyUnread,
activeFilters,
filteredArticlesFromServer,
+ articleNavigationContext,
+ navigableArticles,
isFilterLoading,
articleViewModePreferences,
@@ -855,6 +870,7 @@ export const useAppStore = defineStore('app', () => {
toggleShowOnlyUnread,
setActiveFilters,
setFilteredArticlesFromServer,
+ setArticleNavigationContext,
setIsFilterLoading,
fetchTaskDetails,
};
diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts
index 088d9025..05fee289 100644
--- a/frontend/src/types/models.ts
+++ b/frontend/src/types/models.ts
@@ -28,6 +28,10 @@ export interface Article {
summary?: string; // Cached AI-generated summary
original_summary?: string; // Summary/description provided by the RSS item
freshrss_item_id?: string; // FreshRSS/Google Reader item ID
+ relevance_score?: number; // AI search relevance score
+ matched_terms?: string[]; // Terms that matched this article
+ matched_fields?: Array<'title' | 'summary' | 'content'>; // Explainable match locations
+ excerpt?: string; // Sanitized AI search context excerpt
}
export interface Feed {
diff --git a/internal/database/article_search_db.go b/internal/database/article_search_db.go
index 09e5077e..faaf9ef3 100644
--- a/internal/database/article_search_db.go
+++ b/internal/database/article_search_db.go
@@ -3,10 +3,16 @@ package database
import (
"database/sql"
"fmt"
+ stdhtml "html"
"log"
+ "sort"
+ "strings"
"time"
+ "unicode"
"MrRSS/internal/models"
+
+ "golang.org/x/net/html"
)
// GetImageGalleryArticles retrieves articles from image mode feeds with pagination.
@@ -85,110 +91,432 @@ func (db *DB) GetImageGalleryArticles(feedID int64, category string, showHidden
return articles, nil
}
-// SearchArticlesWithAI executes a search query with an AI-generated WHERE clause.
-// The whereClause should be pre-validated to prevent SQL injection.
-func (db *DB) SearchArticlesWithAI(whereClause string, limit int) ([]models.Article, error) {
+// AISearchQuery contains bounded terms parsed from the AI response. AI output
+// is always passed to SQLite as data and never executed as SQL.
+type AISearchQuery struct {
+ Original string
+ Required []string
+ Optional []string
+ Patterns []string
+ Limit int
+}
+
+// AISearchResult adds explainable ranking metadata without changing Article.
+type AISearchResult struct {
+ Article models.Article
+ RelevanceScore float64
+ MatchedTerms []string
+ MatchedFields []string
+ Excerpt string
+}
+
+type aiSearchCandidate struct {
+ article models.Article
+ content string
+ summaryText string
+}
+
+// SearchArticlesWithTerms performs a parameterized candidate lookup and then
+// calculates a deterministic, explainable relevance score locally.
+func (db *DB) SearchArticlesWithTerms(search AISearchQuery) ([]AISearchResult, error) {
db.WaitForReady()
+ if search.Limit <= 0 {
+ search.Limit = 100
+ }
+ if search.Limit > 500 {
+ search.Limit = 500
+ }
+
+ originalTerms := uniqueSearchTerms([]string{search.Original}, 1)
+ requiredTerms := uniqueSearchTerms(search.Required, 8)
+ plainTerms := uniqueSearchTerms(append(append([]string{}, originalTerms...), requiredTerms...), 12)
+ patternTerms := validSearchPatterns(search.Patterns, 4)
+ if len(plainTerms) == 0 && len(patternTerms) == 0 {
+ return []AISearchResult{}, nil
+ }
- if limit <= 0 {
- limit = 100
+ conditions := make([]string, 0, len(plainTerms)+len(patternTerms))
+ conditionArgs := make([]any, 0, (len(plainTerms)+len(patternTerms))*3)
+ appendCondition := func(term string, allowWildcard bool) {
+ pattern := searchLikePattern(term, allowWildcard)
+ conditions = append(conditions, `(LOWER(a.title) LIKE ? ESCAPE '\' OR LOWER(COALESCE(a.summary, '') || ' ' || COALESCE(a.original_summary, '')) LIKE ? ESCAPE '\' OR LOWER(COALESCE(c.content, '')) LIKE ? ESCAPE '\')`)
+ conditionArgs = append(conditionArgs, pattern, pattern, pattern)
+ }
+ for _, term := range plainTerms {
+ appendCondition(term, false)
}
- if limit > 500 {
- limit = 500
+ for _, term := range patternTerms {
+ appendCondition(term, true)
}
- // Build the complete query with the AI-generated WHERE clause
- query := fmt.Sprintf(`
- SELECT a.id, a.feed_id, a.title, a.url, a.image_url, a.audio_url, a.video_url,
- a.published_at, a.is_read, a.is_favorite, a.is_hidden, a.is_read_later,
- a.translated_title, a.summary, a.freshrss_item_id, f.title, a.author
- FROM articles a
- JOIN feeds f ON a.feed_id = f.id
- WHERE %s
- ORDER BY a.published_at DESC
- LIMIT %d
- `, whereClause, limit)
+ // Rank candidates in SQLite before applying LIMIT. Without this preliminary
+ // ordering, a broad AI-expanded term could fill the arbitrary row set and
+ // hide a later exact-title match from the deterministic Go ranker.
+ scoreParts := make([]string, 0, 1+len(requiredTerms)+len(patternTerms))
+ scoreArgs := make([]any, 0, (1+len(requiredTerms)+len(patternTerms))*3)
+ appendScore := func(term string, allowWildcard bool, titleWeight, summaryWeight, contentWeight int) {
+ pattern := searchLikePattern(term, allowWildcard)
+ scoreParts = append(scoreParts, fmt.Sprintf(
+ `CASE WHEN LOWER(a.title) LIKE ? ESCAPE '\' THEN %d ELSE 0 END + CASE WHEN LOWER(COALESCE(a.summary, '') || ' ' || COALESCE(a.original_summary, '')) LIKE ? ESCAPE '\' THEN %d ELSE 0 END + CASE WHEN LOWER(COALESCE(c.content, '')) LIKE ? ESCAPE '\' THEN %d ELSE 0 END`,
+ titleWeight, summaryWeight, contentWeight,
+ ))
+ scoreArgs = append(scoreArgs, pattern, pattern, pattern)
+ }
+ for _, term := range originalTerms {
+ appendScore(term, false, 120, 70, 30)
+ }
+ for _, term := range requiredTerms {
+ appendScore(term, false, 24, 12, 5)
+ }
+ for _, term := range patternTerms {
+ appendScore(term, true, 30, 15, 7)
+ }
+ // Optional terms never broaden the candidate set. A bounded subset only
+ // breaks ties among articles which already match the user's query or a core
+ // expansion term.
+ for _, term := range uniqueSearchTerms(search.Optional, 6) {
+ appendScore(term, false, 6, 3, 1)
+ }
+ preliminaryScore := "0"
+ if len(scoreParts) > 0 {
+ preliminaryScore = strings.Join(scoreParts, " + ")
+ }
+
+ candidateLimit := search.Limit * 2
+ if candidateLimit < 200 {
+ candidateLimit = 200
+ }
+ if candidateLimit > 500 {
+ candidateLimit = 500
+ }
+ args := append(scoreArgs, conditionArgs...)
+ args = append(args, candidateLimit)
+
+ // The limited CTE carries only article IDs and scores. Full cached bodies are
+ // loaded for at most candidateLimit rows in the outer query, rather than
+ // becoming part of the temporary sort payload.
+ query := `
+ WITH ranked_candidates AS MATERIALIZED (
+ SELECT a.id AS article_id,
+ (` + preliminaryScore + `) AS candidate_score,
+ a.published_at AS candidate_time
+ FROM articles a
+ LEFT JOIN article_contents c ON a.id = c.article_id
+ WHERE a.is_hidden = 0 AND (` + strings.Join(conditions, " OR ") + `)
+ ORDER BY candidate_score DESC, candidate_time DESC, a.id DESC
+ LIMIT ?
+ )
+ SELECT a.id, a.feed_id, a.title, a.url, a.image_url, a.audio_url, a.video_url,
+ a.published_at, a.is_read, a.is_favorite, a.is_hidden,
+ a.is_read_later, a.translated_title, a.summary, a.original_summary, a.freshrss_item_id,
+ f.title, a.author, COALESCE(c.content, '')
+ FROM ranked_candidates ranked
+ JOIN articles a ON a.id = ranked.article_id
+ JOIN feeds f ON a.feed_id = f.id
+ LEFT JOIN article_contents c ON a.id = c.article_id
+ ORDER BY ranked.candidate_score DESC, ranked.candidate_time DESC, a.id DESC`
- rows, err := db.Query(query)
+ rows, err := db.Query(query, args...)
if err != nil {
- return nil, fmt.Errorf("query execution failed: %w", err)
+ return nil, fmt.Errorf("execute parameterized AI search: %w", err)
}
defer rows.Close()
- var articles []models.Article
+ candidates := make([]aiSearchCandidate, 0)
for rows.Next() {
- var a models.Article
- var imageURL, audioURL, videoURL, translatedTitle, summary, freshrssItemID, author sql.NullString
- var publishedAt sql.NullTime
- if err := rows.Scan(&a.ID, &a.FeedID, &a.Title, &a.URL, &imageURL, &audioURL, &videoURL, &publishedAt, &a.IsRead, &a.IsFavorite, &a.IsHidden, &a.IsReadLater, &translatedTitle, &summary, &freshrssItemID, &a.FeedTitle, &author); err != nil {
- log.Println("Error scanning article in AI search:", err)
+ candidate, scanErr := scanAISearchCandidate(rows)
+ if scanErr != nil {
+ return nil, fmt.Errorf("scan AI search article: %w", scanErr)
+ }
+ candidates = append(candidates, candidate)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate AI search articles: %w", err)
+ }
+
+ results := make([]AISearchResult, 0, len(candidates))
+ for _, candidate := range candidates {
+ result := rankAISearchCandidate(candidate, search)
+ if result.RelevanceScore > 0 {
+ results = append(results, result)
+ }
+ }
+ sort.SliceStable(results, func(i, j int) bool {
+ if results[i].RelevanceScore != results[j].RelevanceScore {
+ return results[i].RelevanceScore > results[j].RelevanceScore
+ }
+ return results[i].Article.PublishedAt.After(results[j].Article.PublishedAt)
+ })
+ if len(results) > search.Limit {
+ results = results[:search.Limit]
+ }
+ return results, nil
+}
+
+func scanAISearchCandidate(rows *sql.Rows) (aiSearchCandidate, error) {
+ var candidate aiSearchCandidate
+ a := &candidate.article
+ var imageURL, audioURL, videoURL, translatedTitle, summary, originalSummary, freshrssItemID, author sql.NullString
+ var publishedAt sql.NullTime
+ if err := rows.Scan(
+ &a.ID, &a.FeedID, &a.Title, &a.URL, &imageURL, &audioURL, &videoURL,
+ &publishedAt, &a.IsRead, &a.IsFavorite, &a.IsHidden,
+ &a.IsReadLater, &translatedTitle, &summary, &originalSummary, &freshrssItemID,
+ &a.FeedTitle, &author, &candidate.content,
+ ); err != nil {
+ return candidate, err
+ }
+ a.ImageURL = imageURL.String
+ a.AudioURL = audioURL.String
+ a.VideoURL = videoURL.String
+ a.TranslatedTitle = translatedTitle.String
+ a.Summary = summary.String
+ a.OriginalSummary = originalSummary.String
+ candidate.summaryText = strings.TrimSpace(summary.String + " " + originalSummary.String)
+ a.FreshRSSItemID = freshrssItemID.String
+ a.Author = author.String
+ if publishedAt.Valid {
+ a.PublishedAt = publishedAt.Time
+ }
+ return candidate, nil
+}
+
+func rankAISearchCandidate(candidate aiSearchCandidate, search AISearchQuery) AISearchResult {
+ title := cleanSearchText(candidate.article.Title)
+ summary := cleanSearchText(candidate.summaryText)
+ content := cleanSearchText(candidate.content)
+ fields := map[string]string{"title": title, "summary": summary, "content": content}
+ matchedTerms := make([]string, 0)
+ matchedFields := make([]string, 0, 3)
+ fieldSeen := make(map[string]bool)
+ score := 0.0
+
+ apply := func(term string, wildcard bool, weights map[string]float64) bool {
+ term = strings.TrimSpace(term)
+ if normalizeSearchNeedle(term) == "" {
+ return false
+ }
+ matched := false
+ for field, value := range fields {
+ if searchTermMatches(value, term, wildcard) {
+ score += weights[field]
+ matched = true
+ if !fieldSeen[field] {
+ fieldSeen[field] = true
+ matchedFields = append(matchedFields, field)
+ }
+ }
+ }
+ if matched && !containsFold(matchedTerms, term) {
+ matchedTerms = append(matchedTerms, term)
+ }
+ return matched
+ }
+
+ apply(search.Original, false, map[string]float64{"title": 36, "summary": 20, "content": 10})
+ required := uniqueSearchTerms(search.Required, 8)
+ requiredMatches := 0
+ for _, term := range required {
+ if apply(term, false, map[string]float64{"title": 14, "summary": 8, "content": 4}) {
+ requiredMatches++
+ }
+ }
+ if len(required) > 0 {
+ score += 10 * float64(requiredMatches) / float64(len(required))
+ }
+ for _, term := range uniqueSearchTerms(search.Patterns, 4) {
+ apply(term, true, map[string]float64{"title": 18, "summary": 10, "content": 5})
+ }
+ for _, term := range uniqueSearchTerms(search.Optional, 12) {
+ apply(term, false, map[string]float64{"title": 4, "summary": 2, "content": 1})
+ }
+
+ fieldOrder := map[string]int{"title": 0, "summary": 1, "content": 2}
+ sort.SliceStable(matchedFields, func(i, j int) bool {
+ return fieldOrder[matchedFields[i]] < fieldOrder[matchedFields[j]]
+ })
+ excerptSource := summary
+ if excerptSource == "" || !containsAnyFold(excerptSource, matchedTerms) {
+ excerptSource = content
+ }
+ if excerptSource == "" {
+ excerptSource = title
+ }
+
+ return AISearchResult{
+ Article: candidate.article,
+ RelevanceScore: score,
+ MatchedTerms: matchedTerms,
+ MatchedFields: matchedFields,
+ Excerpt: makeSearchExcerpt(excerptSource, matchedTerms, 220),
+ }
+}
+
+func uniqueSearchTerms(terms []string, max int) []string {
+ result := make([]string, 0, len(terms))
+ for _, term := range terms {
+ term = strings.TrimSpace(term)
+ if term == "" || len([]rune(term)) > 80 || containsFold(result, term) {
continue
}
- a.ImageURL = imageURL.String
- a.AudioURL = audioURL.String
- a.VideoURL = videoURL.String
- if publishedAt.Valid {
- a.PublishedAt = publishedAt.Time
- } else {
- a.PublishedAt = time.Time{}
+ result = append(result, term)
+ if len(result) >= max {
+ break
}
- a.TranslatedTitle = translatedTitle.String
- a.Summary = summary.String
- a.FreshRSSItemID = freshrssItemID.String
- a.Author = author.String
- articles = append(articles, a)
}
+ return result
+}
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("row iteration failed: %w", err)
+func validSearchPatterns(patterns []string, max int) []string {
+ result := make([]string, 0, len(patterns))
+ for _, pattern := range uniqueSearchTerms(patterns, max) {
+ // A pattern containing only '%' (or whitespace between wildcards) is
+ // equivalent to an unbounded table scan and offers no relevance evidence.
+ if normalizeSearchNeedle(pattern) == "" {
+ continue
+ }
+ result = append(result, pattern)
}
+ return result
+}
- return articles, nil
+func containsFold(values []string, target string) bool {
+ for _, value := range values {
+ if strings.EqualFold(value, target) {
+ return true
+ }
+ }
+ return false
}
-// SearchArticlesWithSQL executes a complete SQL query for AI search with content and relevance scoring.
-// The query should include all necessary SELECT fields and be pre-validated.
-func (db *DB) SearchArticlesWithSQL(query string) ([]models.Article, error) {
- db.WaitForReady()
+func normalizeSearchNeedle(term string) string {
+ term = strings.ToLower(strings.ReplaceAll(term, "%", " "))
+ return strings.Join(strings.Fields(term), " ")
+}
- if query == "" {
- return nil, fmt.Errorf("empty query")
+func containsAnyFold(text string, terms []string) bool {
+ lower := strings.ToLower(text)
+ for _, term := range terms {
+ if needle := normalizeSearchNeedle(term); needle != "" && strings.Contains(lower, needle) {
+ return true
+ }
}
+ return false
+}
- rows, err := db.Query(query)
- if err != nil {
- return nil, fmt.Errorf("query execution failed: %w", err)
+func searchLikePattern(term string, allowWildcard bool) string {
+ term = strings.ToLower(strings.TrimSpace(term))
+ var builder strings.Builder
+ for _, char := range term {
+ switch char {
+ case '\\':
+ builder.WriteString(`\\`)
+ case '_':
+ builder.WriteString(`\_`)
+ case '%':
+ if allowWildcard {
+ builder.WriteRune('%')
+ } else {
+ builder.WriteString(`\%`)
+ }
+ default:
+ builder.WriteRune(char)
+ }
}
- defer rows.Close()
+ return "%" + builder.String() + "%"
+}
- var articles []models.Article
- for rows.Next() {
- var a models.Article
- var imageURL, audioURL, videoURL, translatedTitle, summary, freshrssItemID, author sql.NullString
- var publishedAt sql.NullTime
- var relevanceScore float64
- if err := rows.Scan(&a.ID, &a.FeedID, &a.Title, &a.URL, &imageURL, &audioURL, &videoURL, &publishedAt, &a.IsRead, &a.IsFavorite, &a.IsHidden, &a.IsReadLater, &translatedTitle, &summary, &freshrssItemID, &a.FeedTitle, &author, &relevanceScore); err != nil {
- log.Println("Error scanning article in AI search with SQL:", err)
+func searchTermMatches(text, term string, wildcard bool) bool {
+ lower := strings.ToLower(text)
+ if !wildcard || !strings.Contains(term, "%") {
+ return strings.Contains(lower, strings.ToLower(strings.TrimSpace(term)))
+ }
+ position := 0
+ for _, part := range strings.Split(strings.ToLower(term), "%") {
+ part = strings.TrimSpace(part)
+ if part == "" {
continue
}
- a.ImageURL = imageURL.String
- a.AudioURL = audioURL.String
- a.VideoURL = videoURL.String
- if publishedAt.Valid {
- a.PublishedAt = publishedAt.Time
- } else {
- a.PublishedAt = time.Time{}
+ index := strings.Index(lower[position:], part)
+ if index < 0 {
+ return false
}
- a.TranslatedTitle = translatedTitle.String
- a.Summary = summary.String
- a.FreshRSSItemID = freshrssItemID.String
- a.Author = author.String
- articles = append(articles, a)
+ position += index + len(part)
}
+ return true
+}
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("row iteration failed: %w", err)
+func cleanSearchText(raw string) string {
+ if strings.TrimSpace(raw) == "" {
+ return ""
+ }
+ root, err := html.Parse(strings.NewReader(raw))
+ if err != nil {
+ return strings.Join(strings.Fields(stdhtml.UnescapeString(raw)), " ")
+ }
+ var builder strings.Builder
+ var walk func(*html.Node, bool)
+ walk = func(node *html.Node, skip bool) {
+ if node.Type == html.ElementNode {
+ switch strings.ToLower(node.Data) {
+ case "script", "style", "noscript", "template":
+ skip = true
+ case "p", "div", "br", "li", "section", "article", "h1", "h2", "h3":
+ builder.WriteByte(' ')
+ }
+ }
+ if node.Type == html.TextNode && !skip {
+ builder.WriteString(node.Data)
+ builder.WriteByte(' ')
+ }
+ for child := node.FirstChild; child != nil; child = child.NextSibling {
+ walk(child, skip)
+ }
}
+ walk(root, false)
+ return strings.Join(strings.Fields(stdhtml.UnescapeString(builder.String())), " ")
+}
- return articles, nil
+func makeSearchExcerpt(text string, terms []string, maxRunes int) string {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ""
+ }
+ runes := []rune(text)
+ if len(runes) <= maxRunes {
+ return text
+ }
+ lower := strings.ToLower(text)
+ matchByte := -1
+ for _, term := range terms {
+ if needle := normalizeSearchNeedle(term); needle != "" {
+ if index := strings.Index(lower, needle); index >= 0 && (matchByte < 0 || index < matchByte) {
+ matchByte = index
+ }
+ }
+ }
+ matchRune := 0
+ if matchByte > 0 {
+ matchRune = len([]rune(text[:matchByte]))
+ }
+ start := matchRune - maxRunes/3
+ if start < 0 {
+ start = 0
+ }
+ end := start + maxRunes
+ if end > len(runes) {
+ end = len(runes)
+ start = end - maxRunes
+ }
+ for start > 0 && !unicode.IsSpace(runes[start]) {
+ start--
+ }
+ prefix, suffix := "", ""
+ if start > 0 {
+ prefix = "…"
+ }
+ if end < len(runes) {
+ suffix = "…"
+ }
+ return prefix + strings.TrimSpace(string(runes[start:end])) + suffix
}
diff --git a/internal/database/sqlite_test.go b/internal/database/sqlite_test.go
index cb961fff..e616506e 100644
--- a/internal/database/sqlite_test.go
+++ b/internal/database/sqlite_test.go
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -477,3 +478,133 @@ func TestCleanupUnimportantArticles(t *testing.T) {
t.Fatalf("Expected idempotent cleanup to delete 0 articles, deleted %d", secondCount)
}
}
+
+func TestSearchArticlesWithTermsRanksAndExplainsMatches(t *testing.T) {
+ db, err := NewDB(":memory:")
+ if err != nil {
+ t.Fatalf("NewDB error: %v", err)
+ }
+ defer db.Close()
+ if err := db.Init(); err != nil {
+ t.Fatalf("Init error: %v", err)
+ }
+
+ result, err := db.Exec(`INSERT INTO feeds (url, title) VALUES (?, ?)`, "https://example.com/feed", "Example")
+ if err != nil {
+ t.Fatalf("insert feed: %v", err)
+ }
+ feedID, _ := result.LastInsertId()
+ insertArticle := func(title, originalSummary string, hidden bool) int64 {
+ t.Helper()
+ row, insertErr := db.Exec(`
+ INSERT INTO articles (feed_id, title, url, published_at, original_summary, is_hidden)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `, feedID, title, "https://example.com/"+title, time.Now(), originalSummary, hidden)
+ if insertErr != nil {
+ t.Fatalf("insert article %q: %v", title, insertErr)
+ }
+ id, _ := row.LastInsertId()
+ return id
+ }
+
+ titleID := insertArticle("AI safety engineering guide", "Practical controls", false)
+ summaryID := insertArticle("Engineering notes", "A guide to AI safety evaluations", false)
+ contentID := insertArticle("Research digest", "Weekly research", false)
+ hiddenID := insertArticle("AI safety hidden article", "must stay hidden", true)
+ if err := db.SetArticleContent(contentID, `Hands-on AI safety testing.
`); err != nil {
+ t.Fatalf("set content: %v", err)
+ }
+
+ results, err := db.SearchArticlesWithTerms(AISearchQuery{
+ Original: "AI safety",
+ Required: []string{"AI", "safety"},
+ Optional: []string{"engineering", "testing"},
+ Patterns: []string{"AI%safety"},
+ Limit: 10,
+ })
+ if err != nil {
+ t.Fatalf("search: %v", err)
+ }
+ if len(results) != 3 {
+ t.Fatalf("expected 3 visible results, got %d: %+v", len(results), results)
+ }
+ if results[0].Article.ID != titleID {
+ t.Fatalf("expected title phrase match first, got article %d", results[0].Article.ID)
+ }
+ seen := map[int64]AISearchResult{}
+ for _, searchResult := range results {
+ seen[searchResult.Article.ID] = searchResult
+ if searchResult.Article.ID == hiddenID {
+ t.Fatal("hidden article must not be returned")
+ }
+ if searchResult.RelevanceScore <= 0 || len(searchResult.MatchedTerms) == 0 || len(searchResult.MatchedFields) == 0 {
+ t.Fatalf("missing relevance explanation: %+v", searchResult)
+ }
+ }
+ if _, ok := seen[summaryID]; !ok {
+ t.Fatal("expected original RSS summary match")
+ }
+ contentResult, ok := seen[contentID]
+ if !ok {
+ t.Fatal("expected article content match")
+ }
+ if strings.Contains(contentResult.Excerpt, "<") || strings.Contains(contentResult.Excerpt, "secret()") {
+ t.Fatalf("excerpt was not sanitized: %q", contentResult.Excerpt)
+ }
+
+ injectionResults, err := db.SearchArticlesWithTerms(AISearchQuery{
+ Original: `' OR 1=1 --`,
+ Required: []string{`%' OR 1=1 --`},
+ Limit: 10,
+ })
+ if err != nil {
+ t.Fatalf("parameterized search rejected input unexpectedly: %v", err)
+ }
+ if len(injectionResults) != 0 {
+ t.Fatalf("SQL-like input must not broaden results: %+v", injectionResults)
+ }
+
+ // A broad expansion can match more rows than the bounded candidate set.
+ // The exact original phrase must be prioritized before LIMIT, even when it
+ // is older and inserted after all broad matches.
+ for i := 0; i < 230; i++ {
+ if _, err := db.Exec(`
+ INSERT INTO articles (feed_id, title, url, published_at, original_summary)
+ VALUES (?, ?, ?, ?, ?)
+ `, feedID, fmt.Sprintf("Broad result %03d", i), fmt.Sprintf("https://example.com/broad/%d", i), time.Now(), "common expansion"); err != nil {
+ t.Fatalf("insert broad search candidate %d: %v", i, err)
+ }
+ }
+ exactResult, err := db.Exec(`
+ INSERT INTO articles (feed_id, title, url, published_at, original_summary)
+ VALUES (?, ?, ?, ?, ?)
+ `, feedID, "rare exact phrase", "https://example.com/exact", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), "common expansion")
+ if err != nil {
+ t.Fatalf("insert exact search candidate: %v", err)
+ }
+ exactID, _ := exactResult.LastInsertId()
+
+ boundedResults, err := db.SearchArticlesWithTerms(AISearchQuery{
+ Original: "rare exact phrase",
+ Required: []string{"common"},
+ Limit: 10,
+ })
+ if err != nil {
+ t.Fatalf("bounded candidate search: %v", err)
+ }
+ if len(boundedResults) == 0 || boundedResults[0].Article.ID != exactID {
+ t.Fatalf("expected exact phrase result before bounded broad matches, got %+v", boundedResults)
+ }
+
+ wildcardOnlyResults, err := db.SearchArticlesWithTerms(AISearchQuery{
+ Original: "missing literal phrase",
+ Patterns: []string{"%", " % % "},
+ Limit: 10,
+ })
+ if err != nil {
+ t.Fatalf("wildcard-only pattern search: %v", err)
+ }
+ if len(wildcardOnlyResults) != 0 {
+ t.Fatalf("wildcard-only patterns must not broaden results: %+v", wildcardOnlyResults)
+ }
+}
diff --git a/internal/handlers/ai/ai_search.go b/internal/handlers/ai/ai_search.go
index e453df5b..1601f6bb 100644
--- a/internal/handlers/ai/ai_search.go
+++ b/internal/handlers/ai/ai_search.go
@@ -5,12 +5,14 @@ import (
"fmt"
"log"
"net/http"
+ "net/url"
"regexp"
"strings"
"time"
"MrRSS/internal/ai"
"MrRSS/internal/config"
+ "MrRSS/internal/database"
"MrRSS/internal/handlers/core"
"MrRSS/internal/handlers/response"
)
@@ -22,11 +24,12 @@ type AISearchRequest struct {
// AISearchResponse represents the response from AI search
type AISearchResponse struct {
- Success bool `json:"success"`
- Articles []map[string]any `json:"articles,omitempty"`
- SearchTerms string `json:"search_terms,omitempty"`
- Error string `json:"error,omitempty"`
- TotalCount int `json:"total_count"`
+ Success bool `json:"success"`
+ Articles []map[string]any `json:"articles,omitempty"`
+ SearchTerms string `json:"search_terms,omitempty"`
+ ExpandedTerms *SearchTerms `json:"expanded_terms,omitempty"`
+ Error string `json:"error,omitempty"`
+ TotalCount int `json:"total_count"`
}
// extractSearchTerms extracts the search terms from AI response
@@ -78,9 +81,57 @@ func parseSearchTermsAdvanced(response string) (*SearchTerms, error) {
return nil, fmt.Errorf("no search terms extracted")
}
+ terms.Required = normalizeTerms(terms.Required, 8)
+ terms.Optional = normalizeTerms(terms.Optional, 12)
+ terms.Patterns = normalizeSearchPatterns(terms.Patterns, 4)
return &terms, nil
}
+func normalizeSearchPatterns(values []string, max int) []string {
+ result := make([]string, 0, len(values))
+ for _, value := range normalizeTerms(values, max) {
+ plain := strings.Join(strings.Fields(strings.ReplaceAll(value, "%", " ")), " ")
+ if plain == "" {
+ continue
+ }
+ result = append(result, value)
+ }
+ return result
+}
+
+func endpointForLog(rawEndpoint string) string {
+ parsed, err := url.Parse(strings.TrimSpace(rawEndpoint))
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return ""
+ }
+ parsed.User = nil
+ parsed.RawQuery = ""
+ parsed.ForceQuery = false
+ parsed.Fragment = ""
+ return parsed.String()
+}
+
+func normalizeTerms(values []string, max int) []string {
+ result := make([]string, 0, len(values))
+ seen := make(map[string]struct{})
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ key := strings.ToLower(value)
+ if value == "" || len([]rune(value)) > 80 {
+ continue
+ }
+ if _, exists := seen[key]; exists {
+ continue
+ }
+ seen[key] = struct{}{}
+ result = append(result, value)
+ if len(result) >= max {
+ break
+ }
+ }
+ return result
+}
+
// buildAISearchPrompt creates a concise system prompt for keyword expansion
func buildAISearchPrompt() string {
return `Expand search query into structured search terms. Output ONLY a JSON object:
@@ -104,95 +155,6 @@ Input: "Python web框架"
Output: {"required":["Python","web框架","web framework"],"optional":["Django","Flask","FastAPI","后端","backend"],"patterns":["Python%web","Python%框架"]}`
}
-// buildSearchSQL builds the SQL query from search terms with relevance scoring
-func buildSearchSQL(terms *SearchTerms, limit int) string {
- if terms == nil || (len(terms.Required) == 0 && len(terms.Patterns) == 0) {
- return ""
- }
-
- // Build required conditions (must match at least one)
- var requiredConditions []string
- for _, term := range terms.Required {
- escapedTerm := strings.ReplaceAll(term, "'", "''")
- condition := fmt.Sprintf(
- "(a.title LIKE '%%%s%%' OR c.content LIKE '%%%s%%' OR a.summary LIKE '%%%s%%')",
- escapedTerm, escapedTerm, escapedTerm,
- )
- requiredConditions = append(requiredConditions, condition)
- }
-
- // Build pattern conditions
- for _, pattern := range terms.Patterns {
- escapedPattern := strings.ReplaceAll(pattern, "'", "''")
- condition := fmt.Sprintf(
- "(a.title LIKE '%%%s%%' OR c.content LIKE '%%%s%%' OR a.summary LIKE '%%%s%%')",
- escapedPattern, escapedPattern, escapedPattern,
- )
- requiredConditions = append(requiredConditions, condition)
- }
-
- // Build relevance score with weighted scoring
- var scoreTerms []string
-
- // Required terms get higher base weight
- for _, term := range terms.Required {
- escapedTerm := strings.ReplaceAll(term, "'", "''")
- scoreTerm := fmt.Sprintf(
- "(CASE WHEN a.title LIKE '%%%s%%' THEN 5 ELSE 0 END + "+
- "CASE WHEN c.content LIKE '%%%s%%' THEN 2 ELSE 0 END + "+
- "CASE WHEN a.summary LIKE '%%%s%%' THEN 3 ELSE 0 END)",
- escapedTerm, escapedTerm, escapedTerm,
- )
- scoreTerms = append(scoreTerms, scoreTerm)
- }
-
- // Patterns get highest weight (exact phrase match)
- for _, pattern := range terms.Patterns {
- escapedPattern := strings.ReplaceAll(pattern, "'", "''")
- scoreTerm := fmt.Sprintf(
- "(CASE WHEN a.title LIKE '%%%s%%' THEN 8 ELSE 0 END + "+
- "CASE WHEN c.content LIKE '%%%s%%' THEN 4 ELSE 0 END + "+
- "CASE WHEN a.summary LIKE '%%%s%%' THEN 5 ELSE 0 END)",
- escapedPattern, escapedPattern, escapedPattern,
- )
- scoreTerms = append(scoreTerms, scoreTerm)
- }
-
- // Optional terms get lower weight
- for _, term := range terms.Optional {
- escapedTerm := strings.ReplaceAll(term, "'", "''")
- scoreTerm := fmt.Sprintf(
- "(CASE WHEN a.title LIKE '%%%s%%' THEN 2 ELSE 0 END + "+
- "CASE WHEN c.content LIKE '%%%s%%' THEN 1 ELSE 0 END + "+
- "CASE WHEN a.summary LIKE '%%%s%%' THEN 1 ELSE 0 END)",
- escapedTerm, escapedTerm, escapedTerm,
- )
- scoreTerms = append(scoreTerms, scoreTerm)
- }
-
- relevanceScore := "0"
- if len(scoreTerms) > 0 {
- relevanceScore = strings.Join(scoreTerms, " + ")
- }
-
- // Build full query with LEFT JOIN to article_contents for content search
- whereClause := strings.Join(requiredConditions, " OR ")
- query := fmt.Sprintf(`
- SELECT a.id, a.feed_id, a.title, a.url, a.image_url, a.audio_url, a.video_url,
- a.published_at, a.is_read, a.is_favorite, a.is_hidden, a.is_read_later,
- a.translated_title, a.summary, a.freshrss_item_id, f.title AS feed_title, a.author,
- (%s) AS relevance_score
- FROM articles a
- JOIN feeds f ON a.feed_id = f.id
- LEFT JOIN article_contents c ON a.id = c.article_id
- WHERE a.is_hidden = 0 AND (%s)
- ORDER BY relevance_score DESC, a.published_at DESC
- LIMIT %d
- `, relevanceScore, whereClause, limit)
-
- return query
-}
-
// HandleAISearch handles POST /api/ai/search for AI-powered article search
// @Summary AI-powered article search
// @Description Use AI to expand keywords and search articles with relevance ranking
@@ -228,7 +190,8 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
return
}
- log.Printf("[AI Search] User query: %s", req.Query)
+ query := strings.TrimSpace(req.Query)
+ log.Printf("[AI Search] Starting search (query length: %d)", len([]rune(query)))
// Get AI settings - try ProfileProvider first
var apiKey, endpoint, model string
@@ -238,7 +201,7 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
apiKey = cfg.APIKey
endpoint = cfg.Endpoint
model = cfg.Model
- log.Printf("[AI Search] Using AI profile for search (endpoint: %s, model: %s)", endpoint, model)
+ log.Printf("[AI Search] Using AI profile for search (endpoint: %s, model: %s)", endpointForLog(endpoint), model)
}
}
@@ -256,7 +219,7 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
if model == "" {
model = defaults.AIModel
}
- log.Printf("[AI Search] Using global AI settings for search (endpoint: %s, model: %s)", endpoint, model)
+ log.Printf("[AI Search] Using global AI settings for search (endpoint: %s, model: %s)", endpointForLog(endpoint), model)
}
// Validate AI configuration
@@ -298,8 +261,6 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
return
}
- log.Printf("[AI Search] AI response: %s", aiResponse)
-
// Parse search terms from AI response
searchTerms, err := parseSearchTermsAdvanced(aiResponse)
if err != nil {
@@ -315,14 +276,20 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
allTerms = append(allTerms, searchTerms.Required...)
allTerms = append(allTerms, searchTerms.Optional...)
allTerms = append(allTerms, searchTerms.Patterns...)
- log.Printf("[AI Search] Required: %v, Optional: %v, Patterns: %v", searchTerms.Required, searchTerms.Optional, searchTerms.Patterns)
-
- // Build and execute search query
- searchSQL := buildSearchSQL(searchTerms, 100)
- log.Printf("[AI Search] SQL query:\n%s", searchSQL)
-
- // Execute search
- articles, err := h.DB.SearchArticlesWithSQL(searchSQL)
+ log.Printf(
+ "[AI Search] Expanded terms (required=%d optional=%d patterns=%d)",
+ len(searchTerms.Required), len(searchTerms.Optional), len(searchTerms.Patterns),
+ )
+
+ // Execute a parameterized candidate query and deterministic local ranking.
+ // The AI response is never treated as SQL.
+ results, err := h.DB.SearchArticlesWithTerms(database.AISearchQuery{
+ Original: query,
+ Required: searchTerms.Required,
+ Optional: searchTerms.Optional,
+ Patterns: searchTerms.Patterns,
+ Limit: 100,
+ })
if err != nil {
log.Printf("[AI Search] Query error: %v", err)
response.JSON(w, AISearchResponse{
@@ -333,11 +300,12 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
return
}
- log.Printf("[AI Search] Found %d articles", len(articles))
+ log.Printf("[AI Search] Found %d articles", len(results))
// Convert articles to response format
- articleMaps := make([]map[string]any, len(articles))
- for i, article := range articles {
+ articleMaps := make([]map[string]any, len(results))
+ for i, result := range results {
+ article := result.Article
articleMaps[i] = map[string]any{
"id": article.ID,
"feed_id": article.FeedID,
@@ -355,13 +323,19 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
"author": article.Author,
"translated_title": article.TranslatedTitle,
"summary": article.Summary,
+ "original_summary": article.OriginalSummary,
+ "relevance_score": result.RelevanceScore,
+ "matched_terms": result.MatchedTerms,
+ "matched_fields": result.MatchedFields,
+ "excerpt": result.Excerpt,
}
}
response.JSON(w, AISearchResponse{
- Success: true,
- Articles: articleMaps,
- SearchTerms: strings.Join(allTerms, ", "),
- TotalCount: len(articles),
+ Success: true,
+ Articles: articleMaps,
+ SearchTerms: strings.Join(allTerms, ", "),
+ ExpandedTerms: searchTerms,
+ TotalCount: len(results),
})
}
From 15b413f5d66dbcb8f62532f1dbaffc729d0745ee Mon Sep 17 00:00:00 2001
From: marcomarcogd <35049765+marcomarcogd@users.noreply.github.com>
Date: Mon, 24 Aug 2026 19:11:34 +0800
Subject: [PATCH 05/59] fix(settings): update AI usage limit display
immediately
---
.../cypress/e2e/settings-persistence.cy.ts | 61 ++++++++
.../modals/settings/ai/AIUsageSettings.vue | 132 +++++++++++++-----
2 files changed, 159 insertions(+), 34 deletions(-)
diff --git a/frontend/cypress/e2e/settings-persistence.cy.ts b/frontend/cypress/e2e/settings-persistence.cy.ts
index 638fec30..8fafebf7 100644
--- a/frontend/cypress/e2e/settings-persistence.cy.ts
+++ b/frontend/cypress/e2e/settings-persistence.cy.ts
@@ -487,4 +487,65 @@ describe('Settings Persistence', () => {
expect(layoutMode).to.eq('normal');
});
});
+
+ it('updates the AI usage limit immediately and refreshes usage while visible', () => {
+ let usage = 120;
+ let savedLimit = '20000';
+ let usageRequests = 0;
+
+ cy.intercept('GET', '/api/ai/profiles', { statusCode: 200, body: [] });
+ cy.intercept('GET', '/api/ai-usage', (req) => {
+ usageRequests += 1;
+ const limit = Number(savedLimit);
+ req.reply({
+ statusCode: 200,
+ body: {
+ usage,
+ limit,
+ limit_reached: limit > 0 && usage >= limit,
+ },
+ });
+ }).as('getAIUsage');
+ cy.intercept('POST', '/api/settings', (req) => {
+ if (req.body.ai_usage_limit !== undefined) {
+ savedLimit = String(req.body.ai_usage_limit);
+ }
+ req.reply({ statusCode: 200, body: req.body });
+ }).as('saveAIUsageLimit');
+
+ cy.clock();
+ cy.get('button')
+ .filter('[title="Settings"], [title="设置"]')
+ .should('exist')
+ .click({ force: true });
+ cy.wait('@getSettings');
+ cy.contains('button', /^AI$/i).click({ force: true });
+ cy.wait('@getAIUsage');
+
+ cy.get('[data-testid="ai-usage-status"]').should('contain.text', '120 / 20,000');
+ cy.get('[data-testid="ai-usage-limit-input"]').clear().type('400');
+ cy.get('[data-testid="ai-usage-status"]').should('contain.text', '120 / 400');
+
+ cy.tick(500);
+ cy.wait('@saveAIUsageLimit').its('request.body.ai_usage_limit').should('eq', '400');
+ cy.wait('@getAIUsage');
+ cy.get('[data-testid="ai-usage-status"]').should('contain.text', '120 / 400');
+
+ cy.then(() => {
+ usage = 300;
+ });
+ cy.tick(15_000);
+ cy.wait('@getAIUsage');
+ cy.get('[data-testid="ai-usage-status"]').should('contain.text', '300 / 400');
+
+ let requestsBeforeUnmount = 0;
+ cy.then(() => {
+ requestsBeforeUnmount = usageRequests;
+ });
+ cy.contains('button', /^(General|常规)$/i).click({ force: true });
+ cy.tick(30_000);
+ cy.then(() => {
+ expect(usageRequests).to.eq(requestsBeforeUnmount);
+ });
+ });
});
diff --git a/frontend/src/components/modals/settings/ai/AIUsageSettings.vue b/frontend/src/components/modals/settings/ai/AIUsageSettings.vue
index 73ad0051..cbb8d655 100644
--- a/frontend/src/components/modals/settings/ai/AIUsageSettings.vue
+++ b/frontend/src/components/modals/settings/ai/AIUsageSettings.vue
@@ -1,5 +1,5 @@
@@ -97,12 +159,13 @@ onMounted(() => {
{
onClick: resetAIUsage,
}"
:status-info="
- aiUsage.limit > 0
+ currentUsageLimit > 0
? {
label: t('common.text.progress'),
- time: getUsagePercentage().toFixed(2) + '%',
+ time: usagePercentage.toFixed(2) + '%',
}
: undefined
"
@@ -128,6 +191,7 @@ onMounted(() => {
:description="t('setting.ai.setUsageLimitDesc')"
>
Date: Mon, 24 Aug 2026 19:13:24 +0800
Subject: [PATCH 06/59] fix(database): retry locked article batch writes
---
internal/database/article_db.go | 90 ++++++++++++++--
internal/database/article_db_test.go | 120 ++++++++++++++++++++++
internal/feed/intelligent_refresh_test.go | 47 ++++++---
3 files changed, 234 insertions(+), 23 deletions(-)
diff --git a/internal/database/article_db.go b/internal/database/article_db.go
index 9e290a6e..92d87c87 100644
--- a/internal/database/article_db.go
+++ b/internal/database/article_db.go
@@ -3,6 +3,7 @@ package database
import (
"context"
"database/sql"
+ "errors"
"fmt"
"log"
"strings"
@@ -10,8 +11,17 @@ import (
"MrRSS/internal/models"
"MrRSS/internal/utils/urlutil"
+
+ "modernc.org/sqlite"
)
+const saveArticlesMaxAttempts = 3
+
+var saveArticlesRetryDelays = [...]time.Duration{
+ 100 * time.Millisecond,
+ 250 * time.Millisecond,
+}
+
// SaveArticle saves a single article to the database.
func (db *DB) SaveArticle(article *models.Article) error {
db.WaitForReady()
@@ -45,9 +55,43 @@ func (db *DB) SaveArticles(ctx context.Context, articles []*models.Article) erro
}
}
+ for attempt := 1; attempt <= saveArticlesMaxAttempts; attempt++ {
+ err := db.saveArticlesOnce(ctx, articles)
+ if err == nil {
+ return nil
+ }
+ if !isRetryableSQLiteWriteError(err) || attempt == saveArticlesMaxAttempts {
+ return fmt.Errorf("save %d articles after %d attempt(s): %w", len(articles), attempt, err)
+ }
+
+ delay := saveArticlesRetryDelays[attempt-1]
+ log.Printf(
+ "SaveArticles write contention on attempt %d/%d; retrying in %s: %v",
+ attempt,
+ saveArticlesMaxAttempts,
+ delay,
+ err,
+ )
+ timer := time.NewTimer(delay)
+ select {
+ case <-ctx.Done():
+ if !timer.Stop() {
+ <-timer.C
+ }
+ return fmt.Errorf("save %d articles while waiting to retry: %w", len(articles), ctx.Err())
+ case <-timer.C:
+ }
+ }
+
+ return fmt.Errorf("save %d articles: retry attempts exhausted", len(articles))
+}
+
+// saveArticlesOnce performs one all-or-nothing transaction. Any error aborts
+// the batch so callers never observe a successful partial refresh.
+func (db *DB) saveArticlesOnce(ctx context.Context, articles []*models.Article) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
- return err
+ return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
@@ -77,15 +121,15 @@ func (db *DB) SaveArticles(ctx context.Context, articles []*models.Article) erro
author = excluded.author
`)
if err != nil {
- return err
+ return fmt.Errorf("prepare article upsert: %w", err)
}
defer stmt.Close()
- for _, article := range articles {
+ for index, article := range articles {
// Check context before each insert
select {
case <-ctx.Done():
- return ctx.Err()
+ return fmt.Errorf("article %d/%d: %w", index+1, len(articles), ctx.Err())
default:
}
@@ -128,6 +172,14 @@ func (db *DB) SaveArticles(ctx context.Context, articles []*models.Article) erro
article.OriginalSummary != existingOriginalSummary ||
article.Author != existingAuthor
updatePublishedAt = article.HasValidPublishedTime || articleChanged
+ } else if !errors.Is(err, sql.ErrNoRows) {
+ return fmt.Errorf(
+ "article %d/%d (feed %d): load existing state: %w",
+ index+1,
+ len(articles),
+ article.FeedID,
+ err,
+ )
}
updateTime := 0
@@ -136,12 +188,36 @@ func (db *DB) SaveArticles(ctx context.Context, articles []*models.Article) erro
}
_, err = stmt.ExecContext(ctx, article.FeedID, article.Title, article.URL, article.ImageURL, article.AudioURL, article.VideoURL, article.PublishedAt, article.TranslatedTitle, isRead, isFavorite, isHidden, isReadLater, article.Summary, article.OriginalSummary, uniqueID, article.Author, updateTime)
if err != nil {
- log.Println("Error saving article in batch:", err)
- // Continue even if one fails
+ return fmt.Errorf(
+ "article %d/%d (feed %d): upsert: %w",
+ index+1,
+ len(articles),
+ article.FeedID,
+ err,
+ )
}
}
- return tx.Commit()
+ if err := stmt.Close(); err != nil {
+ return fmt.Errorf("close article upsert statement: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit transaction: %w", err)
+ }
+ return nil
+}
+
+func isRetryableSQLiteWriteError(err error) bool {
+ var sqliteErr *sqlite.Error
+ if !errors.As(err, &sqliteErr) {
+ return false
+ }
+
+ // SQLite extended result codes retain the primary result in the low byte.
+ // This covers SQLITE_BUSY (5), SQLITE_LOCKED (6), and variants such as
+ // SQLITE_BUSY_SNAPSHOT (517).
+ primaryCode := sqliteErr.Code() & 0xff
+ return primaryCode == 5 || primaryCode == 6
}
// GetArticles retrieves articles with filtering, pagination, and sorting.
diff --git a/internal/database/article_db_test.go b/internal/database/article_db_test.go
index ac2eda5f..9d24be96 100644
--- a/internal/database/article_db_test.go
+++ b/internal/database/article_db_test.go
@@ -2,7 +2,11 @@ package database_test
import (
"context"
+ "database/sql"
+ "errors"
"fmt"
+ "log"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -418,6 +422,122 @@ func TestSaveArticlesBatchContextCancel(t *testing.T) {
}
}
+func TestSaveArticlesRollsBackEntireBatchOnPermanentError(t *testing.T) {
+ db := setupDBWithFeed(t)
+
+ var feedID int64
+ if err := db.QueryRow(`SELECT id FROM feeds WHERE url = ?`, "https://example.com/feed").Scan(&feedID); err != nil {
+ t.Fatalf("scan feed id: %v", err)
+ }
+
+ articles := []*models.Article{
+ {
+ FeedID: feedID,
+ Title: "valid article must be rolled back",
+ URL: "https://example.com/atomic-valid",
+ PublishedAt: time.Now().UTC(),
+ HasValidPublishedTime: true,
+ },
+ {
+ FeedID: feedID + 10000,
+ Title: "invalid feed",
+ URL: "https://example.com/atomic-invalid",
+ PublishedAt: time.Now().UTC(),
+ HasValidPublishedTime: true,
+ },
+ }
+
+ err := db.SaveArticles(context.Background(), articles)
+ if err == nil {
+ t.Fatal("expected the invalid foreign key to fail the batch")
+ }
+ if !strings.Contains(err.Error(), "article 2/2") || !strings.Contains(err.Error(), "attempt(s)") {
+ t.Fatalf("expected batch and attempt context, got %v", err)
+ }
+
+ var count int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM articles WHERE url IN (?, ?)`, articles[0].URL, articles[1].URL).Scan(&count); err != nil {
+ t.Fatalf("count batch articles: %v", err)
+ }
+ if count != 0 {
+ t.Fatalf("batch committed partially: got %d stored articles, want 0", count)
+ }
+}
+
+func TestSaveArticlesRetriesSQLiteWriteContention(t *testing.T) {
+ databasePath := filepath.Join(t.TempDir(), "write-contention.db")
+ db, err := dbpkg.NewDB(databasePath)
+ if err != nil {
+ t.Fatalf("NewDB: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ if err := db.Init(); err != nil {
+ t.Fatalf("Init: %v", err)
+ }
+ db.SetMaxOpenConns(1)
+ db.SetMaxIdleConns(1)
+ if _, err := db.Exec(`PRAGMA busy_timeout = 1`); err != nil {
+ t.Fatalf("set short busy timeout: %v", err)
+ }
+ result, err := db.Exec(`INSERT INTO feeds (title, url, category) VALUES (?, ?, ?)`, "Locked Feed", "https://example.com/locked-feed", "news")
+ if err != nil {
+ t.Fatalf("insert feed: %v", err)
+ }
+ feedID, err := result.LastInsertId()
+ if err != nil {
+ t.Fatalf("feed id: %v", err)
+ }
+
+ locker, err := dbpkg.NewDB(databasePath)
+ if err != nil {
+ t.Fatalf("NewDB locker: %v", err)
+ }
+ t.Cleanup(func() { _ = locker.Close() })
+ lockTx, err := locker.BeginTx(context.Background(), nil)
+ if err != nil {
+ t.Fatalf("begin lock transaction: %v", err)
+ }
+ if _, err := lockTx.Exec(`UPDATE settings SET value = value WHERE key = 'theme'`); err != nil {
+ t.Fatalf("acquire write lock: %v", err)
+ }
+
+ previousLogWriter := log.Writer()
+ var retryLog strings.Builder
+ log.SetOutput(&retryLog)
+ t.Cleanup(func() { log.SetOutput(previousLogWriter) })
+
+ releaseDone := make(chan error, 1)
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ releaseDone <- lockTx.Rollback()
+ }()
+
+ article := &models.Article{
+ FeedID: feedID,
+ Title: "saved after lock retry",
+ URL: "https://example.com/saved-after-lock-retry",
+ PublishedAt: time.Now().UTC(),
+ HasValidPublishedTime: true,
+ }
+ if err := db.SaveArticles(context.Background(), []*models.Article{article}); err != nil {
+ t.Fatalf("SaveArticles after temporary lock: %v", err)
+ }
+ if err := <-releaseDone; err != nil && !errors.Is(err, sql.ErrTxDone) {
+ t.Fatalf("release lock: %v", err)
+ }
+ if !strings.Contains(retryLog.String(), "retrying") {
+ t.Fatalf("expected a contention retry, logs: %s", retryLog.String())
+ }
+
+ var count int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM articles WHERE url = ?`, article.URL).Scan(&count); err != nil {
+ t.Fatalf("count retried article: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("retried article count = %d, want 1", count)
+ }
+}
+
func TestSaveArticlesUpdatePreservesRelatedData(t *testing.T) {
db := setupDBWithFeed(t)
diff --git a/internal/feed/intelligent_refresh_test.go b/internal/feed/intelligent_refresh_test.go
index a7641320..4e228fec 100644
--- a/internal/feed/intelligent_refresh_test.go
+++ b/internal/feed/intelligent_refresh_test.go
@@ -2,6 +2,7 @@ package feed
import (
"context"
+ "fmt"
"testing"
"time"
@@ -9,6 +10,17 @@ import (
"MrRSS/internal/models"
)
+func addIntelligentRefreshTestFeed(t *testing.T, db *database.DB) models.Feed {
+ t.Helper()
+ feed := models.Feed{Title: "Test Feed", URL: "https://example.com/test-feed", Type: "rss"}
+ feedID, err := db.AddFeed(&feed)
+ if err != nil {
+ t.Fatalf("AddFeed error: %v", err)
+ }
+ feed.ID = feedID
+ return feed
+}
+
func TestIntelligentRefreshCalculator_NoArticles(t *testing.T) {
tmpFile := t.TempDir() + "/test.db"
db, err := database.NewDB(tmpFile)
@@ -21,7 +33,7 @@ func TestIntelligentRefreshCalculator_NoArticles(t *testing.T) {
defer db.Close()
calculator := NewIntelligentRefreshCalculator(db)
- feed := models.Feed{ID: 1, Title: "Test Feed"}
+ feed := addIntelligentRefreshTestFeed(t, db)
// Test with no articles - should return default interval
interval := calculator.CalculateInterval(feed)
@@ -57,17 +69,18 @@ func TestIntelligentRefreshCalculator_Bounds(t *testing.T) {
defer db.Close()
calculator := NewIntelligentRefreshCalculator(db)
- feed := models.Feed{ID: 1, Title: "Test Feed"}
+ feed := addIntelligentRefreshTestFeed(t, db)
// Create articles with very high frequency (every 10 seconds)
now := time.Now()
articles := make([]*models.Article, 10)
for i := 0; i < 10; i++ {
articles[i] = &models.Article{
- FeedID: 1,
- Title: "Article",
- URL: "http://example.com/article",
- PublishedAt: now.Add(time.Duration(-i) * 10 * time.Second),
+ FeedID: feed.ID,
+ Title: fmt.Sprintf("Article %d", i),
+ URL: fmt.Sprintf("http://example.com/article/%d", i),
+ PublishedAt: now.Add(time.Duration(-i) * 10 * time.Second),
+ HasValidPublishedTime: true,
}
}
@@ -100,17 +113,18 @@ func TestIntelligentRefreshCalculator_LowFrequency(t *testing.T) {
defer db.Close()
calculator := NewIntelligentRefreshCalculator(db)
- feed := models.Feed{ID: 1, Title: "Test Feed"}
+ feed := addIntelligentRefreshTestFeed(t, db)
// Create articles with very low frequency (every 48 hours)
now := time.Now()
articles := make([]*models.Article, 10)
for i := 0; i < 10; i++ {
articles[i] = &models.Article{
- FeedID: 1,
- Title: "Article",
- URL: "http://example.com/article",
- PublishedAt: now.Add(time.Duration(-i) * 48 * time.Hour),
+ FeedID: feed.ID,
+ Title: fmt.Sprintf("Article %d", i),
+ URL: fmt.Sprintf("http://example.com/article/%d", i),
+ PublishedAt: now.Add(time.Duration(-i) * 48 * time.Hour),
+ HasValidPublishedTime: true,
}
}
@@ -156,17 +170,18 @@ func TestIntelligentRefreshCalculator_WithArticles(t *testing.T) {
defer db.Close()
calculator := NewIntelligentRefreshCalculator(db)
- feed := models.Feed{ID: 1, Title: "Test Feed"}
+ feed := addIntelligentRefreshTestFeed(t, db)
// Create articles published every 2 hours
now := time.Now()
articles := make([]*models.Article, 20)
for i := 0; i < 20; i++ {
articles[i] = &models.Article{
- FeedID: 1,
- Title: "Article",
- URL: "http://example.com/article",
- PublishedAt: now.Add(time.Duration(-i*2) * time.Hour),
+ FeedID: feed.ID,
+ Title: fmt.Sprintf("Article %d", i),
+ URL: fmt.Sprintf("http://example.com/article/%d", i),
+ PublishedAt: now.Add(time.Duration(-i*2) * time.Hour),
+ HasValidPublishedTime: true,
}
}
From 80340b684c033f4ed2a5919b047701e2c797f323 Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Mon, 24 Aug 2026 20:05:50 +0800
Subject: [PATCH 07/59] feat(macos): add native SwiftUI client
---
.github/workflows/release.yml | 41 ++
.github/workflows/test.yml | 16 +
.gitignore | 22 +
frontend-swift/Package.swift | 23 +
frontend-swift/README.md | 61 ++
frontend-swift/Sources/Models/Models.swift | 242 ++++++++
.../Sources/Models/SettingsCatalog.swift | 173 ++++++
frontend-swift/Sources/MrRSSApp.swift | 31 ++
.../Sources/Services/APIService.swift | 396 +++++++++++++
.../Sources/Services/BackendManager.swift | 52 ++
.../Sources/ViewModels/AppViewModel.swift | 527 ++++++++++++++++++
.../Sources/Views/AddFeedView.swift | 63 +++
.../Sources/Views/ArticleDetailView.swift | 339 +++++++++++
.../Sources/Views/ArticleListView.swift | 91 +++
.../Sources/Views/ArticleRowView.swift | 115 ++++
.../Sources/Views/ContentView.swift | 50 ++
.../Sources/Views/RulesSettingsView.swift | 286 ++++++++++
.../Sources/Views/ServerSettingsView.swift | 39 ++
.../Sources/Views/SettingsRootView.swift | 212 +++++++
.../Sources/Views/SidebarView.swift | 159 ++++++
frontend-swift/Sources/Views/WebView.swift | 222 ++++++++
frontend-swift/Tests/APIServiceTests.swift | 178 ++++++
frontend-swift/Tests/AppViewModelTests.swift | 178 ++++++
frontend-swift/Tests/HTMLDocumentTests.swift | 24 +
frontend-swift/Tests/ModelsTests.swift | 41 ++
frontend-swift/Tests/WebViewLayoutTests.swift | 145 +++++
frontend-swift/build-app.sh | 90 +++
frontend-swift/packaging/Info.plist | 39 ++
frontend-swift/run.sh | 49 ++
29 files changed, 3904 insertions(+)
create mode 100644 frontend-swift/Package.swift
create mode 100644 frontend-swift/README.md
create mode 100644 frontend-swift/Sources/Models/Models.swift
create mode 100644 frontend-swift/Sources/Models/SettingsCatalog.swift
create mode 100644 frontend-swift/Sources/MrRSSApp.swift
create mode 100644 frontend-swift/Sources/Services/APIService.swift
create mode 100644 frontend-swift/Sources/Services/BackendManager.swift
create mode 100644 frontend-swift/Sources/ViewModels/AppViewModel.swift
create mode 100644 frontend-swift/Sources/Views/AddFeedView.swift
create mode 100644 frontend-swift/Sources/Views/ArticleDetailView.swift
create mode 100644 frontend-swift/Sources/Views/ArticleListView.swift
create mode 100644 frontend-swift/Sources/Views/ArticleRowView.swift
create mode 100644 frontend-swift/Sources/Views/ContentView.swift
create mode 100644 frontend-swift/Sources/Views/RulesSettingsView.swift
create mode 100644 frontend-swift/Sources/Views/ServerSettingsView.swift
create mode 100644 frontend-swift/Sources/Views/SettingsRootView.swift
create mode 100644 frontend-swift/Sources/Views/SidebarView.swift
create mode 100644 frontend-swift/Sources/Views/WebView.swift
create mode 100644 frontend-swift/Tests/APIServiceTests.swift
create mode 100644 frontend-swift/Tests/AppViewModelTests.swift
create mode 100644 frontend-swift/Tests/HTMLDocumentTests.swift
create mode 100644 frontend-swift/Tests/ModelsTests.swift
create mode 100644 frontend-swift/Tests/WebViewLayoutTests.swift
create mode 100755 frontend-swift/build-app.sh
create mode 100644 frontend-swift/packaging/Info.plist
create mode 100755 frontend-swift/run.sh
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 52d7e527..6b5b3414 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -605,3 +605,44 @@ jobs:
build/bin/MrRSS-${{ needs.create-release.outputs.version }}-*
token: ${{ secrets.GITHUB_TOKEN }}
fail_on_unmatched_files: false
+
+ build-swiftui-macos:
+ name: Build SwiftUI macOS Universal Client
+ needs: create-release
+ runs-on: macos-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.create-release.outputs.tag }}
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Build embedded web assets
+ working-directory: ./frontend
+ run: |
+ npm ci
+ npm run build
+
+ - name: Build SwiftUI application bundle
+ run: |
+ chmod +x frontend-swift/build-app.sh
+ frontend-swift/build-app.sh "${{ needs.create-release.outputs.version }}"
+
+ - name: Upload SwiftUI macOS release asset
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ needs.create-release.outputs.tag }}
+ files: frontend-swift/dist/MrRSS-${{ needs.create-release.outputs.version }}-macos-swiftui-universal.dmg
+ token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b51e2381..41935d22 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -101,6 +101,22 @@ jobs:
working-directory: ./frontend
run: npm run build
+ test-swiftui-macos:
+ name: Test SwiftUI macOS Client
+ runs-on: macos-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Run Swift tests
+ working-directory: ./frontend-swift
+ run: swift test
+
+ - name: Build release executable
+ working-directory: ./frontend-swift
+ run: swift build -c release
+
build-check:
name: Build Check
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index 0ff73339..188a7324 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,3 +79,25 @@ frontend/cypress/downloads/
# Claude
.claude/
.plan/
+
+# Swift Frontend
+#
+# Packages
+.swiftpm
+.build
+
+# Xcode
+#
+# Any generated Xcode project or workspace
+*.xcodeproj
+*.xcworkspace
+# Derived data (includes intermediate build results)
+DerivedData/
+# Swift Package Manager checkouts (if managed by Xcode)
+Checkouts/
+
+# MacOS
+.DS_Store
+
+
+
diff --git a/frontend-swift/Package.swift b/frontend-swift/Package.swift
new file mode 100644
index 00000000..bd3b7447
--- /dev/null
+++ b/frontend-swift/Package.swift
@@ -0,0 +1,23 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "MrRSS",
+ platforms: [
+ .macOS(.v14)
+ ],
+ products: [
+ .executable(name: "MrRSS", targets: ["MrRSS"])
+ ],
+ targets: [
+ .executableTarget(
+ name: "MrRSS",
+ path: "Sources"
+ ),
+ .testTarget(
+ name: "MrRSSTests",
+ dependencies: ["MrRSS"],
+ path: "Tests"
+ )
+ ]
+)
diff --git a/frontend-swift/README.md b/frontend-swift/README.md
new file mode 100644
index 00000000..f5ed806f
--- /dev/null
+++ b/frontend-swift/README.md
@@ -0,0 +1,61 @@
+# MrRSS SwiftUI Frontend
+
+This directory contains the native macOS 14 SwiftUI frontend for MrRSS. It uses the existing Go HTTP API and does not modify the Vue/Wails frontend.
+
+## Run directly
+
+From the repository root:
+
+```bash
+./frontend-swift/run.sh
+```
+
+The launcher builds and starts the Go server at `http://127.0.0.1:1234`, waits for the API to become available, and then starts the SwiftUI application. If a server is already listening on that address, the launcher reuses it.
+
+Requirements:
+
+- macOS 14 or later
+- Xcode 15 or later
+- Go 1.24 or later
+- The existing `frontend/dist` directory, which is embedded by the server build
+
+## Run components separately
+
+Start the backend from the repository root:
+
+```bash
+go run -tags server . -host 127.0.0.1 -port 1234
+```
+
+In another terminal, start the macOS frontend:
+
+```bash
+swift run --package-path frontend-swift MrRSS
+```
+
+The backend address can be changed in MrRSS Settings. It can also be set before launch:
+
+```bash
+MRRSS_API_BASE_URL=http://127.0.0.1:8080/api swift run --package-path frontend-swift MrRSS
+```
+
+## Build and test
+
+```bash
+swift build --package-path frontend-swift
+swift test --package-path frontend-swift
+```
+
+To create the universal `.app` bundle and DMG used by GitHub Releases:
+
+```bash
+cd frontend
+npm ci
+npm run build
+cd ../frontend-swift
+./build-app.sh 1.2.3
+```
+
+The release application contains both the SwiftUI frontend and reusable Go backend, so it can launch without a separately installed server.
+
+The application provides native three-column navigation, feed subscription management, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content.
diff --git a/frontend-swift/Sources/Models/Models.swift b/frontend-swift/Sources/Models/Models.swift
new file mode 100644
index 00000000..5e677ba8
--- /dev/null
+++ b/frontend-swift/Sources/Models/Models.swift
@@ -0,0 +1,242 @@
+import Foundation
+
+struct Feed: Identifiable, Codable, Hashable {
+ let id: Int
+ let url: String
+ let title: String
+ let category: String
+ let lastUpdated: String
+ let iconURL: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id, url, title, category
+ case lastUpdated = "last_updated"
+ case iconURL = "image_url"
+ }
+
+ init(
+ id: Int,
+ url: String,
+ title: String,
+ category: String,
+ lastUpdated: String = "",
+ iconURL: String? = nil
+ ) {
+ self.id = id
+ self.url = url
+ self.title = title
+ self.category = category
+ self.lastUpdated = lastUpdated
+ self.iconURL = iconURL?.nilIfEmpty
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(Int.self, forKey: .id)
+ url = try container.decodeIfPresent(String.self, forKey: .url) ?? ""
+ title = try container.decodeIfPresent(String.self, forKey: .title) ?? url
+ category = try container.decodeIfPresent(String.self, forKey: .category) ?? ""
+ lastUpdated = try container.decodeIfPresent(String.self, forKey: .lastUpdated) ?? ""
+ iconURL = try container.decodeIfPresent(String.self, forKey: .iconURL)?.nilIfEmpty
+ }
+}
+
+struct Article: Identifiable, Codable, Hashable {
+ let id: Int
+ let feedID: Int
+ let feedTitle: String?
+ let title: String
+ let url: String
+ let imageURL: String?
+ let publishedAt: String
+ var isRead: Bool
+ var isFavorite: Bool
+ var isHidden: Bool
+ var isReadLater: Bool
+ var translatedTitle: String?
+ var summary: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id, title, url, summary
+ case feedID = "feed_id"
+ case feedTitle = "feed_title"
+ case imageURL = "image_url"
+ case publishedAt = "published_at"
+ case isRead = "is_read"
+ case isFavorite = "is_favorite"
+ case isHidden = "is_hidden"
+ case isReadLater = "is_read_later"
+ case translatedTitle = "translated_title"
+ }
+
+ init(
+ id: Int,
+ feedID: Int,
+ feedTitle: String? = nil,
+ title: String,
+ url: String,
+ imageURL: String? = nil,
+ publishedAt: String,
+ isRead: Bool = false,
+ isFavorite: Bool = false,
+ isHidden: Bool = false,
+ isReadLater: Bool = false,
+ translatedTitle: String? = nil,
+ summary: String? = nil
+ ) {
+ self.id = id
+ self.feedID = feedID
+ self.feedTitle = feedTitle?.nilIfEmpty
+ self.title = title
+ self.url = url
+ self.imageURL = imageURL?.nilIfEmpty
+ self.publishedAt = publishedAt
+ self.isRead = isRead
+ self.isFavorite = isFavorite
+ self.isHidden = isHidden
+ self.isReadLater = isReadLater
+ self.translatedTitle = translatedTitle?.nilIfEmpty
+ self.summary = summary?.nilIfEmpty
+ }
+}
+
+struct UnreadCounts: Codable, Equatable {
+ let total: Int
+ let feedCounts: [Int: Int]
+
+ enum CodingKeys: String, CodingKey {
+ case total
+ case feedCounts = "feed_counts"
+ }
+
+ static let empty = UnreadCounts(total: 0, feedCounts: [:])
+
+ init(total: Int, feedCounts: [Int: Int]) {
+ self.total = total
+ self.feedCounts = feedCounts
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ total = try container.decodeIfPresent(Int.self, forKey: .total) ?? 0
+
+ let rawCounts = try container.decodeIfPresent([String: Int].self, forKey: .feedCounts) ?? [:]
+ feedCounts = Dictionary(uniqueKeysWithValues: rawCounts.compactMap { key, value in
+ Int(key).map { ($0, value) }
+ })
+ }
+}
+
+struct ArticleContent: Codable, Equatable {
+ let content: String
+ let feedURL: String?
+
+ enum CodingKeys: String, CodingKey {
+ case content
+ case feedURL = "feed_url"
+ }
+}
+
+struct RefreshProgress: Codable, Equatable {
+ let isRunning: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case isRunning = "is_running"
+ }
+}
+
+struct TitleTranslationResponse: Codable, Equatable {
+ let translatedTitle: String
+ let limitReached: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case translatedTitle = "translated_title"
+ case limitReached = "limit_reached"
+ }
+}
+
+struct TextTranslationResponse: Codable, Equatable {
+ let translatedText: String
+ let html: String
+
+ enum CodingKeys: String, CodingKey {
+ case translatedText = "translated_text"
+ case html
+ }
+}
+
+struct SummaryResult: Codable, Equatable {
+ let summary: String
+ let html: String?
+ let sentenceCount: Int?
+ let isTooShort: Bool
+ let limitReached: Bool?
+ let usedFallback: Bool?
+ let thinking: String?
+ let error: String?
+ let cached: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case summary, html, thinking, error, cached
+ case sentenceCount = "sentence_count"
+ case isTooShort = "is_too_short"
+ case limitReached = "limit_reached"
+ case usedFallback = "used_fallback"
+ }
+}
+
+struct RuleCondition: Codable, Hashable, Identifiable {
+ var id: Int
+ var logic: String?
+ var negate: Bool
+ var field: String
+ var `operator`: String
+ var value: String
+ var values: [String]
+
+ static func empty(id: Int = Int(Date().timeIntervalSince1970 * 1_000)) -> RuleCondition {
+ RuleCondition(
+ id: id,
+ logic: "and",
+ negate: false,
+ field: "article_title",
+ operator: "contains",
+ value: "",
+ values: []
+ )
+ }
+}
+
+struct AutomationRule: Codable, Hashable, Identifiable {
+ var id: Int
+ var name: String
+ var enabled: Bool
+ var conditions: [RuleCondition]
+ var actions: [String]
+
+ static func empty(id: Int = Int(Date().timeIntervalSince1970 * 1_000)) -> AutomationRule {
+ AutomationRule(id: id, name: "New Rule", enabled: true, conditions: [], actions: [])
+ }
+}
+
+struct RuleApplicationResult: Codable, Equatable {
+ let success: Bool
+ let affected: Int
+}
+
+struct AIUsage: Codable, Equatable {
+ let usage: Int
+ let limit: Int
+ let limitReached: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case usage, limit
+ case limitReached = "limit_reached"
+ }
+}
+
+private extension String {
+ var nilIfEmpty: String? {
+ trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
+ }
+}
diff --git a/frontend-swift/Sources/Models/SettingsCatalog.swift b/frontend-swift/Sources/Models/SettingsCatalog.swift
new file mode 100644
index 00000000..1b676976
--- /dev/null
+++ b/frontend-swift/Sources/Models/SettingsCatalog.swift
@@ -0,0 +1,173 @@
+import Foundation
+
+enum SettingsPane: String, CaseIterable, Identifiable {
+ case connection
+ case general
+ case reading
+ case translation
+ case summaryAI
+ case storageNetwork
+ case integrations
+ case rules
+ case advanced
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .connection: "Connection"
+ case .general: "General"
+ case .reading: "Reading"
+ case .translation: "Translation"
+ case .summaryAI: "Summary & AI"
+ case .storageNetwork: "Storage & Network"
+ case .integrations: "Integrations"
+ case .rules: "Rules"
+ case .advanced: "All Settings"
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .connection: "server.rack"
+ case .general: "gearshape"
+ case .reading: "text.book.closed"
+ case .translation: "character.book.closed"
+ case .summaryAI: "sparkles"
+ case .storageNetwork: "externaldrive.connected.to.line.below"
+ case .integrations: "link"
+ case .rules: "bolt.badge.clock"
+ case .advanced: "slider.horizontal.3"
+ }
+ }
+}
+
+enum SettingControl {
+ case toggle
+ case text
+ case secure
+ case picker([(String, String)])
+}
+
+struct SettingDefinition: Identifiable {
+ let key: String
+ let pane: SettingsPane
+ let section: String
+ let control: SettingControl
+ let detail: String?
+
+ var id: String { key }
+ var title: String { key.settingTitle }
+}
+
+enum SettingsCatalog {
+ static let definitions: [SettingDefinition] = [
+ d("update_interval", .general, "Updates", .text, "Automatic refresh interval in minutes."),
+ d("refresh_mode", .general, "Updates", .picker([("fixed", "Fixed interval"), ("smart", "Intelligent")])),
+ d("language", .general, "Appearance", .picker([("en-US", "English"), ("zh-CN", "简体中文")])),
+ d("theme", .general, "Appearance", .picker([("auto", "System"), ("light", "Light"), ("dark", "Dark")])),
+ d("startup_on_boot", .general, "Application", .toggle),
+ d("close_to_tray", .general, "Application", .toggle),
+ d("auto_update", .general, "Application", .toggle),
+ d("shortcuts_enabled", .general, "Application", .toggle),
+ d("image_gallery_enabled", .general, "Application", .toggle),
+
+ d("default_view_mode", .reading, "Article display", .picker([("rendered", "Rendered content"), ("webpage", "Original webpage")])),
+ d("show_hidden_articles", .reading, "Article display", .toggle),
+ d("hover_mark_as_read", .reading, "Article display", .toggle),
+ d("show_article_preview_images", .reading, "Article display", .toggle),
+ d("full_text_fetch_enabled", .reading, "Full text", .toggle),
+ d("auto_show_all_content", .reading, "Full text", .toggle),
+ d("custom_css_file", .reading, "Customization", .text),
+
+ d("translation_enabled", .translation, "Translation", .toggle),
+ d("target_language", .translation, "Translation", .picker([("zh", "Chinese"), ("en", "English"), ("ja", "Japanese"), ("ko", "Korean"), ("fr", "French"), ("de", "German"), ("es", "Spanish")])),
+ d("translation_provider", .translation, "Translation", .picker([("google", "Google"), ("deepl", "DeepL"), ("baidu", "Baidu"), ("ai", "AI")])),
+ d("google_translate_endpoint", .translation, "Google", .text),
+ d("deepl_endpoint", .translation, "DeepL", .text),
+ d("deepl_api_key", .translation, "DeepL", .secure),
+ d("baidu_app_id", .translation, "Baidu", .text),
+ d("baidu_secret_key", .translation, "Baidu", .secure),
+
+ d("summary_enabled", .summaryAI, "Summary", .toggle),
+ d("summary_length", .summaryAI, "Summary", .picker([("short", "Short"), ("medium", "Medium"), ("long", "Long")])),
+ d("summary_provider", .summaryAI, "Summary", .picker([("local", "Local TextRank"), ("ai", "AI")])),
+ d("summary_trigger_mode", .summaryAI, "Summary", .picker([("manual", "Manual"), ("auto", "Automatic")])),
+ d("ai_api_key", .summaryAI, "AI provider", .secure),
+ d("ai_endpoint", .summaryAI, "AI provider", .text),
+ d("ai_model", .summaryAI, "AI provider", .text),
+ d("ai_usage_limit", .summaryAI, "AI provider", .text),
+ d("ai_chat_enabled", .summaryAI, "AI provider", .toggle),
+ d("ai_custom_headers", .summaryAI, "AI prompts", .text),
+ d("ai_translation_prompt", .summaryAI, "AI prompts", .text),
+ d("ai_summary_prompt", .summaryAI, "AI prompts", .text),
+
+ d("auto_cleanup_enabled", .storageNetwork, "Storage", .toggle),
+ d("max_cache_size_mb", .storageNetwork, "Storage", .text),
+ d("max_article_age_days", .storageNetwork, "Storage", .text),
+ d("media_cache_enabled", .storageNetwork, "Media cache", .toggle),
+ d("media_proxy_fallback", .storageNetwork, "Media cache", .toggle),
+ d("media_cache_max_size_mb", .storageNetwork, "Media cache", .text),
+ d("media_cache_max_age_days", .storageNetwork, "Media cache", .text),
+ d("proxy_enabled", .storageNetwork, "Proxy", .toggle),
+ d("proxy_type", .storageNetwork, "Proxy", .picker([("http", "HTTP"), ("https", "HTTPS"), ("socks5", "SOCKS5")])),
+ d("proxy_host", .storageNetwork, "Proxy", .text),
+ d("proxy_port", .storageNetwork, "Proxy", .text),
+ d("proxy_username", .storageNetwork, "Proxy", .secure),
+ d("proxy_password", .storageNetwork, "Proxy", .secure),
+ d("max_concurrent_refreshes", .storageNetwork, "Network", .text),
+ d("retry_timeout_seconds", .storageNetwork, "Network", .text),
+
+ d("obsidian_enabled", .integrations, "Obsidian", .toggle),
+ d("obsidian_vault", .integrations, "Obsidian", .text),
+ d("obsidian_vault_path", .integrations, "Obsidian", .text),
+ d("freshrss_enabled", .integrations, "FreshRSS", .toggle),
+ d("freshrss_server_url", .integrations, "FreshRSS", .text),
+ d("freshrss_username", .integrations, "FreshRSS", .text),
+ d("freshrss_api_password", .integrations, "FreshRSS", .secure),
+ d("freshrss_auto_sync_interval", .integrations, "FreshRSS", .text),
+ d("freshrss_sync_on_startup", .integrations, "FreshRSS", .toggle)
+ ]
+
+ static let secureKeys: Set = [
+ "ai_api_key", "baidu_secret_key", "deepl_api_key", "freshrss_api_password",
+ "proxy_password", "proxy_username"
+ ]
+
+ static let boolKeys: Set = Set(definitions.compactMap { definition in
+ if case .toggle = definition.control { return definition.key }
+ return nil
+ })
+
+ static func definitions(for pane: SettingsPane) -> [SettingDefinition] {
+ definitions.filter { $0.pane == pane }
+ }
+
+ private static func d(
+ _ key: String,
+ _ pane: SettingsPane,
+ _ section: String,
+ _ control: SettingControl,
+ _ detail: String? = nil
+ ) -> SettingDefinition {
+ SettingDefinition(key: key, pane: pane, section: section, control: control, detail: detail)
+ }
+}
+
+private extension String {
+ var settingTitle: String {
+ split(separator: "_")
+ .map { word in
+ switch word.lowercased() {
+ case "ai": "AI"
+ case "api": "API"
+ case "rss": "RSS"
+ case "url": "URL"
+ case "css": "CSS"
+ case "mb": "MB"
+ default: word.capitalized
+ }
+ }
+ .joined(separator: " ")
+ }
+}
diff --git a/frontend-swift/Sources/MrRSSApp.swift b/frontend-swift/Sources/MrRSSApp.swift
new file mode 100644
index 00000000..de61b60a
--- /dev/null
+++ b/frontend-swift/Sources/MrRSSApp.swift
@@ -0,0 +1,31 @@
+import SwiftUI
+
+@main
+struct MrRSSApp: App {
+ @NSApplicationDelegateAdaptor(BackendManager.self) private var backendManager
+ @StateObject private var viewModel = AppViewModel(autoLoad: false)
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView(viewModel: viewModel)
+ .preferredColorScheme(viewModel.preferredColorScheme)
+ .task {
+ await viewModel.start()
+ }
+ }
+ .defaultSize(width: 1280, height: 780)
+ .commands {
+ SidebarCommands()
+ CommandGroup(after: .newItem) {
+ Button("Refresh") {
+ viewModel.refreshFromSources()
+ }
+ .keyboardShortcut("r", modifiers: .command)
+ }
+ }
+
+ Settings {
+ SettingsRootView(viewModel: viewModel)
+ }
+ }
+}
diff --git a/frontend-swift/Sources/Services/APIService.swift b/frontend-swift/Sources/Services/APIService.swift
new file mode 100644
index 00000000..010f770e
--- /dev/null
+++ b/frontend-swift/Sources/Services/APIService.swift
@@ -0,0 +1,396 @@
+import Foundation
+
+enum ServerConfiguration {
+ static let storageKey = "mrrss.apiBaseURL"
+ static let fallbackURL = URL(string: "http://127.0.0.1:1234/api")!
+
+ static var savedBaseURL: URL {
+ if let environmentValue = ProcessInfo.processInfo.environment["MRRSS_API_BASE_URL"],
+ let url = normalizedURL(from: environmentValue) {
+ return url
+ }
+
+ if let savedValue = UserDefaults.standard.string(forKey: storageKey),
+ let url = normalizedURL(from: savedValue) {
+ return url
+ }
+
+ return fallbackURL
+ }
+
+ static func normalizedURL(from value: String) -> URL? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+
+ let candidate = trimmed.contains("://") ? trimmed : "http://\(trimmed)"
+ guard var components = URLComponents(string: candidate),
+ let scheme = components.scheme?.lowercased(),
+ ["http", "https"].contains(scheme),
+ components.host != nil else {
+ return nil
+ }
+
+ var path = components.path
+ while path.hasSuffix("/") {
+ path.removeLast()
+ }
+ if !path.hasSuffix("/api") {
+ path += "/api"
+ }
+ components.path = path
+ components.query = nil
+ components.fragment = nil
+ return components.url
+ }
+}
+
+enum APIError: LocalizedError, Equatable {
+ case invalidURL
+ case invalidResponse
+ case server(statusCode: Int, message: String)
+ case decoding(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL:
+ return "The server address is invalid."
+ case .invalidResponse:
+ return "The server returned an invalid response."
+ case .server(let statusCode, let message):
+ return message.isEmpty
+ ? "The server returned HTTP \(statusCode)."
+ : "The server returned HTTP \(statusCode): \(message)"
+ case .decoding(let message):
+ return "The server response could not be read: \(message)"
+ }
+ }
+}
+
+protocol APIClient: AnyObject {
+ var baseURL: URL { get }
+ func checkConnection() async throws
+ func fetchFeeds() async throws -> [Feed]
+ func fetchUnreadCounts() async throws -> UnreadCounts
+ func addFeed(url: String, title: String, category: String) async throws
+ func deleteFeed(id: Int) async throws
+ func refreshAllFeeds() async throws
+ func fetchRefreshProgress() async throws -> RefreshProgress
+ func fetchArticles(
+ feedID: Int?,
+ category: String?,
+ filter: String,
+ page: Int,
+ limit: Int
+ ) async throws -> [Article]
+ func setArticleRead(id: Int, read: Bool) async throws
+ func toggleFavorite(id: Int) async throws
+ func fetchArticleContent(id: Int) async throws -> ArticleContent
+ func fetchSettings() async throws -> [String: String]
+ func updateSettings(_ settings: [String: String]) async throws
+ func translateTitle(articleID: Int, title: String, targetLanguage: String) async throws -> TitleTranslationResponse
+ func translateText(_ text: String, targetLanguage: String) async throws -> TextTranslationResponse
+ func summarize(articleID: Int, length: String, content: String?) async throws -> SummaryResult
+ func clearTranslations() async throws
+ func clearSummaries() async throws
+ func applyRule(_ rule: AutomationRule) async throws -> RuleApplicationResult
+ func fetchAIUsage() async throws -> AIUsage
+ func resetAIUsage() async throws
+}
+
+final class APIService: APIClient {
+ static let shared = APIService()
+
+ private(set) var baseURL: URL
+ private let session: URLSession
+
+ init(baseURL: URL = ServerConfiguration.savedBaseURL, session: URLSession = .shared) {
+ self.baseURL = baseURL
+ self.session = session
+ }
+
+ func updateBaseURL(_ url: URL, persist: Bool = true) {
+ baseURL = url
+ if persist {
+ UserDefaults.standard.set(url.absoluteString, forKey: ServerConfiguration.storageKey)
+ }
+ }
+
+ func checkConnection() async throws {
+ let url = try makeURL(endpoint: "version")
+ _ = try await data(for: URLRequest(url: url))
+ }
+
+ func fetchFeeds() async throws -> [Feed] {
+ try await get("feeds")
+ }
+
+ func fetchUnreadCounts() async throws -> UnreadCounts {
+ try await get("articles/unread-counts")
+ }
+
+ func addFeed(url: String, title: String, category: String) async throws {
+ try await post(
+ "feeds/add",
+ jsonBody: [
+ "url": url,
+ "title": title,
+ "category": category
+ ]
+ )
+ }
+
+ func deleteFeed(id: Int) async throws {
+ try await post(
+ "feeds/delete",
+ queryItems: [URLQueryItem(name: "id", value: String(id))]
+ )
+ }
+
+ func refreshAllFeeds() async throws {
+ try await post("refresh")
+ }
+
+ func fetchRefreshProgress() async throws -> RefreshProgress {
+ try await get("progress")
+ }
+
+ func fetchArticles(
+ feedID: Int? = nil,
+ category: String? = nil,
+ filter: String,
+ page: Int = 1,
+ limit: Int = 50
+ ) async throws -> [Article] {
+ var queryItems = [
+ URLQueryItem(name: "page", value: String(page)),
+ URLQueryItem(name: "limit", value: String(limit)),
+ URLQueryItem(name: "filter", value: filter)
+ ]
+
+ if let feedID {
+ queryItems.append(URLQueryItem(name: "feed_id", value: String(feedID)))
+ }
+ if let category, !category.isEmpty {
+ queryItems.append(URLQueryItem(name: "category", value: category))
+ }
+
+ return try await get("articles", queryItems: queryItems)
+ }
+
+ func setArticleRead(id: Int, read: Bool) async throws {
+ try await post(
+ "articles/read",
+ queryItems: [
+ URLQueryItem(name: "id", value: String(id)),
+ URLQueryItem(name: "read", value: String(read))
+ ]
+ )
+ }
+
+ func toggleFavorite(id: Int) async throws {
+ try await post(
+ "articles/favorite",
+ queryItems: [URLQueryItem(name: "id", value: String(id))]
+ )
+ }
+
+ func fetchArticleContent(id: Int) async throws -> ArticleContent {
+ try await get(
+ "articles/content",
+ queryItems: [URLQueryItem(name: "id", value: String(id))]
+ )
+ }
+
+ func fetchSettings() async throws -> [String: String] {
+ try await get("settings")
+ }
+
+ func updateSettings(_ settings: [String: String]) async throws {
+ try await sendJSON("settings", body: settings)
+ }
+
+ func translateTitle(
+ articleID: Int,
+ title: String,
+ targetLanguage: String
+ ) async throws -> TitleTranslationResponse {
+ struct Request: Encodable {
+ let articleID: Int
+ let title: String
+ let targetLanguage: String
+
+ enum CodingKeys: String, CodingKey {
+ case articleID = "article_id"
+ case title
+ case targetLanguage = "target_language"
+ }
+ }
+
+ return try await postJSON(
+ "articles/translate",
+ body: Request(articleID: articleID, title: title, targetLanguage: targetLanguage)
+ )
+ }
+
+ func translateText(_ text: String, targetLanguage: String) async throws -> TextTranslationResponse {
+ struct Request: Encodable {
+ let text: String
+ let targetLanguage: String
+
+ enum CodingKeys: String, CodingKey {
+ case text
+ case targetLanguage = "target_language"
+ }
+ }
+
+ return try await postJSON(
+ "articles/translate-text",
+ body: Request(text: text, targetLanguage: targetLanguage)
+ )
+ }
+
+ func summarize(articleID: Int, length: String, content: String?) async throws -> SummaryResult {
+ struct Request: Encodable {
+ let articleID: Int
+ let length: String
+ let content: String?
+
+ enum CodingKeys: String, CodingKey {
+ case articleID = "article_id"
+ case length, content
+ }
+ }
+
+ return try await postJSON(
+ "articles/summarize",
+ body: Request(articleID: articleID, length: length, content: content)
+ )
+ }
+
+ func clearTranslations() async throws {
+ try await post("articles/clear-translations")
+ }
+
+ func clearSummaries() async throws {
+ try await send("articles/clear-summaries", method: "DELETE")
+ }
+
+ func applyRule(_ rule: AutomationRule) async throws -> RuleApplicationResult {
+ try await postJSON("rules/apply", body: rule)
+ }
+
+ func fetchAIUsage() async throws -> AIUsage {
+ try await get("ai-usage")
+ }
+
+ func resetAIUsage() async throws {
+ try await post("ai-usage/reset")
+ }
+
+ private func get(
+ _ endpoint: String,
+ queryItems: [URLQueryItem] = []
+ ) async throws -> T {
+ let url = try makeURL(endpoint: endpoint, queryItems: queryItems)
+ let data = try await data(for: URLRequest(url: url))
+
+ if data.trimmingWhitespace == Data("null".utf8),
+ let emptyArray = [] as? T {
+ return emptyArray
+ }
+
+ do {
+ return try JSONDecoder().decode(T.self, from: data)
+ } catch {
+ throw APIError.decoding(error.localizedDescription)
+ }
+ }
+
+ private func post(
+ _ endpoint: String,
+ queryItems: [URLQueryItem] = [],
+ jsonBody: [String: String]? = nil
+ ) async throws {
+ let url = try makeURL(endpoint: endpoint, queryItems: queryItems)
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ if let jsonBody {
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONSerialization.data(withJSONObject: jsonBody)
+ }
+ _ = try await data(for: request)
+ }
+
+ private func postJSON(
+ _ endpoint: String,
+ body: Body
+ ) async throws -> Response {
+ let url = try makeURL(endpoint: endpoint)
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONEncoder().encode(body)
+ let responseData = try await data(for: request)
+ do {
+ return try JSONDecoder().decode(Response.self, from: responseData)
+ } catch {
+ throw APIError.decoding(error.localizedDescription)
+ }
+ }
+
+ private func sendJSON(
+ _ endpoint: String,
+ method: String = "POST",
+ body: Body
+ ) async throws {
+ let url = try makeURL(endpoint: endpoint)
+ var request = URLRequest(url: url)
+ request.httpMethod = method
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONEncoder().encode(body)
+ _ = try await data(for: request)
+ }
+
+ private func send(_ endpoint: String, method: String) async throws {
+ let url = try makeURL(endpoint: endpoint)
+ var request = URLRequest(url: url)
+ request.httpMethod = method
+ _ = try await data(for: request)
+ }
+
+ private func makeURL(
+ endpoint: String,
+ queryItems: [URLQueryItem] = []
+ ) throws -> URL {
+ let cleanEndpoint = endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ let endpointURL = baseURL.appendingPathComponent(cleanEndpoint)
+ guard var components = URLComponents(url: endpointURL, resolvingAgainstBaseURL: false) else {
+ throw APIError.invalidURL
+ }
+ components.queryItems = queryItems.isEmpty ? nil : queryItems
+ guard let url = components.url else {
+ throw APIError.invalidURL
+ }
+ return url
+ }
+
+ private func data(for request: URLRequest) async throws -> Data {
+ let (data, response) = try await session.data(for: request)
+ guard let response = response as? HTTPURLResponse else {
+ throw APIError.invalidResponse
+ }
+ guard (200...299).contains(response.statusCode) else {
+ let message = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ throw APIError.server(statusCode: response.statusCode, message: message)
+ }
+ return data
+ }
+}
+
+private extension Data {
+ var trimmingWhitespace: Data {
+ guard let string = String(data: self, encoding: .utf8) else { return self }
+ return Data(string.trimmingCharacters(in: .whitespacesAndNewlines).utf8)
+ }
+}
diff --git a/frontend-swift/Sources/Services/BackendManager.swift b/frontend-swift/Sources/Services/BackendManager.swift
new file mode 100644
index 00000000..11d6d552
--- /dev/null
+++ b/frontend-swift/Sources/Services/BackendManager.swift
@@ -0,0 +1,52 @@
+import AppKit
+import Foundation
+
+final class BackendManager: NSObject, NSApplicationDelegate {
+ private var process: Process?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ startBundledBackendIfNeeded()
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ stopBundledBackend()
+ }
+
+ private func startBundledBackendIfNeeded() {
+ let baseURL = ServerConfiguration.savedBaseURL
+ guard ["127.0.0.1", "localhost"].contains(baseURL.host?.lowercased() ?? ""),
+ baseURL.port ?? 80 == 1234,
+ let executableURL = Bundle.main.url(forResource: "mrrss-server", withExtension: nil) else {
+ return
+ }
+
+ let supportDirectory = FileManager.default.urls(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask
+ )[0].appendingPathComponent("MrRSS-SwiftUI", isDirectory: true)
+
+ do {
+ try FileManager.default.createDirectory(
+ at: supportDirectory.appendingPathComponent("data", isDirectory: true),
+ withIntermediateDirectories: true
+ )
+ let process = Process()
+ process.executableURL = executableURL
+ process.arguments = ["-host", "127.0.0.1", "-port", "1234"]
+ process.currentDirectoryURL = supportDirectory
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = FileHandle.nullDevice
+ try process.run()
+ self.process = process
+ } catch {
+ NSLog("Unable to start bundled MrRSS backend: %@", error.localizedDescription)
+ }
+ }
+
+ private func stopBundledBackend() {
+ guard let process, process.isRunning else { return }
+ process.terminate()
+ process.waitUntilExit()
+ self.process = nil
+ }
+}
diff --git a/frontend-swift/Sources/ViewModels/AppViewModel.swift b/frontend-swift/Sources/ViewModels/AppViewModel.swift
new file mode 100644
index 00000000..656feb44
--- /dev/null
+++ b/frontend-swift/Sources/ViewModels/AppViewModel.swift
@@ -0,0 +1,527 @@
+import Foundation
+import SwiftUI
+
+enum ArticleFilter: String, CaseIterable, Identifiable {
+ case all
+ case unread
+ case favorites
+ case readLater
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .all: "All Articles"
+ case .unread: "Unread"
+ case .favorites: "Favorites"
+ case .readLater: "Read Later"
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .all: "rectangle.stack"
+ case .unread: "circle.fill"
+ case .favorites: "star.fill"
+ case .readLater: "clock.fill"
+ }
+ }
+}
+
+enum SidebarItem: Hashable {
+ case filter(ArticleFilter)
+ case feed(Int)
+}
+
+enum ConnectionState: Equatable {
+ case connecting
+ case connected
+ case disconnected
+
+ var title: String {
+ switch self {
+ case .connecting: "Connecting"
+ case .connected: "Connected"
+ case .disconnected: "Offline"
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .connecting: .orange
+ case .connected: .green
+ case .disconnected: .red
+ }
+ }
+}
+
+@MainActor
+final class AppViewModel: ObservableObject {
+ @Published private(set) var feeds: [Feed] = []
+ @Published private(set) var articles: [Article] = []
+ @Published private(set) var unreadCounts = UnreadCounts.empty
+ @Published private(set) var isLoadingArticles = false
+ @Published private(set) var isLoadingFeeds = false
+ @Published private(set) var isRefreshingSources = false
+ @Published private(set) var connectionState: ConnectionState = .connecting
+ @Published private(set) var settings: [String: String] = [:]
+ @Published private(set) var rules: [AutomationRule] = []
+ @Published private(set) var aiUsage: AIUsage?
+ @Published private(set) var isLoadingSettings = false
+ @Published private(set) var isSavingSettings = false
+ @Published var statusMessage: String?
+ @Published var errorMessage: String?
+ @Published var selectedArticleID: Int?
+ @Published var serverURLText: String
+
+ @Published var selection: SidebarItem? = .filter(.all) {
+ didSet {
+ guard selection != oldValue else { return }
+ selectedArticleID = nil
+ reloadArticles()
+ }
+ }
+
+ private var page = 0
+ private var hasMore = true
+ private let limit: Int
+ private var api: APIClient
+ private var feedTask: Task?
+ private var sourceRefreshTask: Task?
+ private var feedRequestID = UUID()
+ private var articleTask: Task?
+ private var articleRequestID = UUID()
+
+ init(api: APIClient = APIService.shared, limit: Int = 50, autoLoad: Bool = true) {
+ self.api = api
+ self.limit = limit
+ serverURLText = api.baseURL.absoluteString
+
+ if autoLoad {
+ refreshAll()
+ }
+ }
+
+ func refreshAll() {
+ refreshFeeds()
+ reloadArticles()
+ Task { await loadSettings() }
+ }
+
+ func start() async {
+ connectionState = .connecting
+ for attempt in 0..<30 {
+ do {
+ try await api.checkConnection()
+ connectionState = .connected
+ refreshAll()
+ return
+ } catch {
+ if attempt == 29 {
+ connectionState = .disconnected
+ errorMessage = error.localizedDescription
+ return
+ }
+ try? await Task.sleep(for: .milliseconds(200))
+ }
+ }
+ }
+
+ func refreshFeeds() {
+ feedTask?.cancel()
+ feedRequestID = UUID()
+ let requestID = feedRequestID
+ isLoadingFeeds = true
+ connectionState = .connecting
+
+ feedTask = Task { [weak self] in
+ guard let self else { return }
+ do {
+ async let feedsRequest = api.fetchFeeds()
+ async let countsRequest = api.fetchUnreadCounts()
+ let (loadedFeeds, loadedCounts) = try await (feedsRequest, countsRequest)
+ try Task.checkCancellation()
+ guard requestID == feedRequestID else { return }
+ feeds = loadedFeeds
+ unreadCounts = loadedCounts
+ connectionState = .connected
+ isLoadingFeeds = false
+ } catch is CancellationError {
+ guard requestID == feedRequestID else { return }
+ isLoadingFeeds = false
+ } catch {
+ guard requestID == feedRequestID else { return }
+ connectionState = .disconnected
+ errorMessage = error.localizedDescription
+ isLoadingFeeds = false
+ }
+ }
+ }
+
+ func refreshFromSources() {
+ guard !isRefreshingSources else { return }
+ sourceRefreshTask?.cancel()
+ isRefreshingSources = true
+
+ sourceRefreshTask = Task { [weak self] in
+ guard let self else { return }
+ do {
+ try await api.refreshAllFeeds()
+
+ for _ in 0..<180 {
+ try Task.checkCancellation()
+ let progress = try await api.fetchRefreshProgress()
+ if !progress.isRunning { break }
+ try await Task.sleep(for: .seconds(1))
+ }
+
+ try Task.checkCancellation()
+ isRefreshingSources = false
+ refreshAll()
+ } catch is CancellationError {
+ isRefreshingSources = false
+ } catch {
+ isRefreshingSources = false
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ func addFeed(url: String, title: String, category: String) async -> Bool {
+ guard let parsedURL = URL(string: url),
+ let scheme = parsedURL.scheme?.lowercased(),
+ ["http", "https"].contains(scheme),
+ parsedURL.host != nil else {
+ errorMessage = "Enter a valid HTTP or HTTPS feed URL."
+ return false
+ }
+
+ do {
+ try await api.addFeed(
+ url: url.trimmingCharacters(in: .whitespacesAndNewlines),
+ title: title.trimmingCharacters(in: .whitespacesAndNewlines),
+ category: category.trimmingCharacters(in: .whitespacesAndNewlines)
+ )
+ refreshAll()
+ return true
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ func deleteFeed(_ feed: Feed) async {
+ do {
+ try await api.deleteFeed(id: feed.id)
+ if selection == .feed(feed.id) {
+ selection = .filter(.all)
+ }
+ refreshAll()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func reloadArticles() {
+ articleTask?.cancel()
+ articleRequestID = UUID()
+ page = 0
+ hasMore = true
+ fetchPage(1, replacing: true)
+ }
+
+ func selectArticle(_ article: Article) {
+ selectedArticleID = article.id
+ }
+
+ func loadMore() {
+ guard hasMore, !isLoadingArticles else { return }
+ fetchPage(page + 1, replacing: false)
+ }
+
+ func setArticleRead(_ article: Article, read: Bool) {
+ guard article.isRead != read,
+ let index = articles.firstIndex(where: { $0.id == article.id }) else {
+ return
+ }
+
+ let previousArticle = articles[index]
+ articles[index].isRead = read
+ if read {
+ articles[index].isReadLater = false
+ }
+
+ Task { [weak self] in
+ guard let self else { return }
+ do {
+ try await api.setArticleRead(id: article.id, read: read)
+ unreadCounts = try await api.fetchUnreadCounts()
+ } catch {
+ if let currentIndex = articles.firstIndex(where: { $0.id == article.id }),
+ articles[currentIndex].isRead == read {
+ articles[currentIndex] = previousArticle
+ }
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ func setArticleRead(id: Int, read: Bool) {
+ guard let article = articles.first(where: { $0.id == id }) else { return }
+ setArticleRead(article, read: read)
+ }
+
+ func toggleFavorite(_ article: Article) {
+ guard let index = articles.firstIndex(where: { $0.id == article.id }) else { return }
+ let previousValue = articles[index].isFavorite
+ articles[index].isFavorite.toggle()
+
+ Task { [weak self] in
+ guard let self else { return }
+ do {
+ try await api.toggleFavorite(id: article.id)
+ } catch {
+ if let currentIndex = articles.firstIndex(where: { $0.id == article.id }),
+ articles[currentIndex].isFavorite != previousValue {
+ articles[currentIndex].isFavorite = previousValue
+ }
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ @discardableResult
+ func saveServerAddress() -> Bool {
+ guard let url = ServerConfiguration.normalizedURL(from: serverURLText) else {
+ errorMessage = "Enter a valid HTTP or HTTPS server address."
+ return false
+ }
+
+ UserDefaults.standard.set(url.absoluteString, forKey: ServerConfiguration.storageKey)
+ api = APIService(baseURL: url)
+ serverURLText = url.absoluteString
+ selectedArticleID = nil
+ errorMessage = nil
+ refreshAll()
+ return true
+ }
+
+ func clearError() {
+ errorMessage = nil
+ }
+
+ func article(withID id: Int?) -> Article? {
+ guard let id else { return nil }
+ return articles.first(where: { $0.id == id })
+ }
+
+ func fetchArticleContent(id: Int) async throws -> ArticleContent {
+ try await api.fetchArticleContent(id: id)
+ }
+
+ func loadSettings() async {
+ guard !isLoadingSettings else { return }
+ isLoadingSettings = true
+ do {
+ settings = try await api.fetchSettings()
+ decodeRules()
+ aiUsage = try? await api.fetchAIUsage()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ isLoadingSettings = false
+ }
+
+ func setting(_ key: String, default defaultValue: String = "") -> String {
+ settings[key] ?? defaultValue
+ }
+
+ func updateSetting(_ key: String, value: String) {
+ settings[key] = value
+ }
+
+ func boolSetting(_ key: String, default defaultValue: Bool = false) -> Bool {
+ guard let value = settings[key] else { return defaultValue }
+ return value == "true" || value == "1"
+ }
+
+ func updateBoolSetting(_ key: String, value: Bool) {
+ settings[key] = value ? "true" : "false"
+ }
+
+ @discardableResult
+ func saveSettings() async -> Bool {
+ guard !isSavingSettings else { return false }
+ isSavingSettings = true
+ do {
+ try await api.updateSettings(settings)
+ statusMessage = "Settings saved."
+ decodeRules()
+ isSavingSettings = false
+ return true
+ } catch {
+ errorMessage = error.localizedDescription
+ isSavingSettings = false
+ return false
+ }
+ }
+
+ func saveRules(_ newRules: [AutomationRule]) async -> Bool {
+ do {
+ let data = try JSONEncoder().encode(newRules)
+ guard let json = String(data: data, encoding: .utf8) else { return false }
+ rules = newRules
+ settings["rules"] = json
+ return await saveSettings()
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ func applyRule(_ rule: AutomationRule) async -> RuleApplicationResult? {
+ do {
+ let result = try await api.applyRule(rule)
+ statusMessage = "Rule applied to \(result.affected) articles."
+ refreshAll()
+ return result
+ } catch {
+ errorMessage = error.localizedDescription
+ return nil
+ }
+ }
+
+ func translateTitle(for article: Article) async throws -> String {
+ let language = setting("target_language", default: "zh")
+ let result = try await api.translateTitle(
+ articleID: article.id,
+ title: article.title,
+ targetLanguage: language
+ )
+ if let index = articles.firstIndex(where: { $0.id == article.id }) {
+ articles[index].translatedTitle = result.translatedTitle
+ }
+ if result.limitReached {
+ statusMessage = "The AI usage limit was reached; a fallback provider was used."
+ }
+ return result.translatedTitle
+ }
+
+ func translateContent(_ content: String) async throws -> TextTranslationResponse {
+ try await api.translateText(
+ content,
+ targetLanguage: setting("target_language", default: "zh")
+ )
+ }
+
+ func summarize(article: Article, content: String?) async throws -> SummaryResult {
+ let result = try await api.summarize(
+ articleID: article.id,
+ length: setting("summary_length", default: "medium"),
+ content: content
+ )
+ if let index = articles.firstIndex(where: { $0.id == article.id }) {
+ articles[index].summary = result.summary
+ }
+ return result
+ }
+
+ func clearGeneratedContent() async {
+ do {
+ try await api.clearTranslations()
+ try await api.clearSummaries()
+ statusMessage = "Cached translations and summaries cleared."
+ reloadArticles()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func resetAIUsage() async {
+ do {
+ try await api.resetAIUsage()
+ aiUsage = try await api.fetchAIUsage()
+ statusMessage = "AI usage reset."
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ var preferredColorScheme: ColorScheme? {
+ switch setting("theme", default: "auto") {
+ case "light": .light
+ case "dark": .dark
+ default: nil
+ }
+ }
+
+ func clearStatusMessage() {
+ statusMessage = nil
+ }
+
+ private func decodeRules() {
+ guard let rawRules = settings["rules"], !rawRules.isEmpty,
+ let data = rawRules.data(using: .utf8),
+ let decoded = try? JSONDecoder().decode([AutomationRule].self, from: data) else {
+ rules = []
+ return
+ }
+ rules = decoded
+ }
+
+ private func fetchPage(_ targetPage: Int, replacing: Bool) {
+ guard !isLoadingArticles || replacing else { return }
+
+ let requestID = articleRequestID
+ let query = articleQuery
+ isLoadingArticles = true
+ if replacing {
+ connectionState = .connecting
+ }
+
+ articleTask = Task { [weak self] in
+ guard let self else { return }
+ do {
+ let loadedArticles = try await api.fetchArticles(
+ feedID: query.feedID,
+ category: nil,
+ filter: query.filter,
+ page: targetPage,
+ limit: limit
+ )
+ try Task.checkCancellation()
+ guard requestID == articleRequestID else { return }
+
+ if replacing {
+ articles = loadedArticles
+ } else {
+ let existingIDs = Set(articles.map(\.id))
+ articles.append(contentsOf: loadedArticles.filter { !existingIDs.contains($0.id) })
+ }
+ page = targetPage
+ hasMore = loadedArticles.count == limit
+ connectionState = .connected
+ isLoadingArticles = false
+ } catch is CancellationError {
+ guard requestID == articleRequestID else { return }
+ isLoadingArticles = false
+ } catch {
+ guard requestID == articleRequestID else { return }
+ connectionState = .disconnected
+ errorMessage = error.localizedDescription
+ isLoadingArticles = false
+ }
+ }
+ }
+
+ private var articleQuery: (feedID: Int?, filter: String) {
+ switch selection {
+ case .filter(let filter):
+ return (nil, filter.rawValue)
+ case .feed(let id):
+ return (id, "")
+ case .none:
+ return (nil, ArticleFilter.all.rawValue)
+ }
+ }
+}
diff --git a/frontend-swift/Sources/Views/AddFeedView.swift b/frontend-swift/Sources/Views/AddFeedView.swift
new file mode 100644
index 00000000..943c6860
--- /dev/null
+++ b/frontend-swift/Sources/Views/AddFeedView.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+
+struct AddFeedView: View {
+ @ObservedObject var viewModel: AppViewModel
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var url = ""
+ @State private var title = ""
+ @State private var category = ""
+ @State private var isSubmitting = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ VStack(alignment: .leading, spacing: 5) {
+ Text("Add Feed")
+ .font(.title2.bold())
+ Text("Subscribe using an RSS, Atom, or compatible feed URL.")
+ .foregroundStyle(.secondary)
+ }
+
+ Form {
+ TextField("Feed URL", text: $url, prompt: Text("https://example.com/feed.xml"))
+ TextField("Title (optional)", text: $title)
+ TextField("Category (optional)", text: $category)
+ }
+ .formStyle(.grouped)
+
+ HStack {
+ Spacer()
+ Button("Cancel", role: .cancel) {
+ dismiss()
+ }
+ Button("Add Feed") {
+ submit()
+ }
+ .keyboardShortcut(.defaultAction)
+ .disabled(url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting)
+ }
+ }
+ .padding(24)
+ .frame(width: 500, height: 300)
+ .overlay {
+ if isSubmitting {
+ ZStack {
+ Color.black.opacity(0.08)
+ ProgressView("Adding feed")
+ .padding(20)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+ }
+ }
+ }
+
+ private func submit() {
+ isSubmitting = true
+ Task {
+ if await viewModel.addFeed(url: url, title: title, category: category) {
+ dismiss()
+ }
+ isSubmitting = false
+ }
+ }
+}
diff --git a/frontend-swift/Sources/Views/ArticleDetailView.swift b/frontend-swift/Sources/Views/ArticleDetailView.swift
new file mode 100644
index 00000000..c2f9cc13
--- /dev/null
+++ b/frontend-swift/Sources/Views/ArticleDetailView.swift
@@ -0,0 +1,339 @@
+import SwiftUI
+import AppKit
+
+struct ArticleDetailView: View {
+ let article: Article
+ @ObservedObject var viewModel: AppViewModel
+
+ @State private var articleContent = ArticleContent(content: "", feedURL: nil)
+ @State private var isLoading = false
+ @State private var loadError: String?
+ @State private var translatedTitle: String?
+ @State private var translatedContent: TextTranslationResponse?
+ @State private var summaryResult: SummaryResult?
+ @State private var displayTranslation = false
+ @State private var activeOperation: ArticleOperation?
+ @State private var operationError: String?
+ @State private var operationNotice: String?
+
+ // NavigationSplitView measures its columns, and a column whose ideal size
+ // follows the article text makes the split view lay itself out around that
+ // size instead of around the window, which pushes the sidebar and the
+ // article list out of the visible area. GeometryReader keeps the reported
+ // size independent of the header, the summary card, and the web content.
+ var body: some View {
+ GeometryReader { geometry in
+ VStack(spacing: 0) {
+ articleHeader
+ Divider()
+ contentView
+ }
+ .frame(width: geometry.size.width, height: geometry.size.height)
+ .clipped()
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+ .toolbar {
+ ToolbarItemGroup(placement: .primaryAction) {
+ Menu {
+ Button {
+ Task { await translateTitle() }
+ } label: {
+ Label("Translate Title", systemImage: "character.book.closed")
+ }
+
+ Button {
+ Task { await translateArticle() }
+ } label: {
+ Label("Translate Content", systemImage: "text.bubble")
+ }
+
+ Button {
+ Task { await summarizeArticle() }
+ } label: {
+ Label("Generate Summary", systemImage: "text.quote")
+ }
+
+ if translatedContent != nil {
+ Divider()
+ Toggle("Show Translation", isOn: $displayTranslation)
+ }
+ } label: {
+ if activeOperation != nil {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Label("Language Tools", systemImage: "sparkles")
+ }
+ }
+ .disabled(activeOperation != nil)
+
+ Button {
+ viewModel.toggleFavorite(article)
+ } label: {
+ Label(
+ article.isFavorite ? "Remove Favorite" : "Favorite",
+ systemImage: article.isFavorite ? "star.fill" : "star"
+ )
+ }
+ .help(article.isFavorite ? "Remove from favorites" : "Add to favorites")
+
+ if let url = URL(string: article.url) {
+ Link(destination: url) {
+ Label("Open in Browser", systemImage: "safari")
+ }
+ .help("Open the original article in your browser")
+ }
+ }
+ }
+ .task(id: article.id) {
+ translatedTitle = article.translatedTitle
+ summaryResult = article.summary.map {
+ SummaryResult(
+ summary: $0,
+ html: nil,
+ sentenceCount: nil,
+ isTooShort: false,
+ limitReached: nil,
+ usedFallback: nil,
+ thinking: nil,
+ error: nil,
+ cached: true
+ )
+ }
+ viewModel.setArticleRead(article, read: true)
+ await loadContent()
+ if viewModel.boolSetting("summary_enabled"),
+ viewModel.setting("summary_trigger_mode", default: "manual") == "auto",
+ summaryResult == nil {
+ await summarizeArticle()
+ }
+ }
+ .alert("Language Tool Error", isPresented: Binding(
+ get: { operationError != nil },
+ set: { if !$0 { operationError = nil } }
+ )) {
+ Button("Dismiss", role: .cancel) { operationError = nil }
+ } message: {
+ Text(operationError ?? "")
+ }
+ .alert("Language Tools", isPresented: Binding(
+ get: { operationNotice != nil },
+ set: { if !$0 { operationNotice = nil } }
+ )) {
+ Button("OK", role: .cancel) { operationNotice = nil }
+ } message: {
+ Text(operationNotice ?? "")
+ }
+ }
+
+ private var articleHeader: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(translatedTitle ?? article.translatedTitle ?? article.title)
+ .font(.system(size: 25, weight: .bold, design: .rounded))
+ .textSelection(.enabled)
+ .lineLimit(4)
+ .fixedSize(horizontal: false, vertical: true)
+
+ if let translatedTitle, translatedTitle != article.title {
+ Text(article.title)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ .textSelection(.enabled)
+ }
+
+ HStack(spacing: 8) {
+ if let feedTitle = article.feedTitle {
+ Text(feedTitle)
+ .fontWeight(.medium)
+ .foregroundStyle(.tint)
+ }
+
+ Text(ArticleDateFormatter.fullString(from: article.publishedAt))
+ .foregroundStyle(.secondary)
+
+ Spacer()
+ }
+ .font(.subheadline)
+ }
+ .padding(.horizontal, 28)
+ .padding(.top, 24)
+ .padding(.bottom, 20)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.bar)
+ }
+
+ @ViewBuilder
+ private var contentView: some View {
+ if isLoading {
+ ProgressView("Loading article")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if let loadError {
+ ContentUnavailableView {
+ Label("Unable to Load Article", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(loadError)
+ } actions: {
+ Button("Retry") {
+ Task { await loadContent() }
+ }
+ }
+ } else {
+ VStack(spacing: 0) {
+ if let summaryResult, !summaryResult.summary.isEmpty {
+ SummaryCard(result: summaryResult)
+ Divider()
+ }
+
+ if translatedContent != nil {
+ Picker("Content", selection: $displayTranslation) {
+ Text("Original").tag(false)
+ Text("Translation").tag(true)
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ .frame(maxWidth: 280)
+ .padding(.vertical, 8)
+ }
+
+ WebView(
+ html: displayedContent,
+ baseURL: URL(string: articleContent.feedURL ?? article.url)
+ )
+ .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
+ .clipped()
+ }
+ }
+ }
+
+ private var displayedContent: String {
+ if displayTranslation, let translatedContent {
+ if !translatedContent.html.isEmpty { return translatedContent.html }
+ return "\(HTMLDocument.escape(translatedContent.translatedText))
"
+ }
+ if !articleContent.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return articleContent.content
+ }
+ if let summary = article.summary, !summary.isEmpty {
+ return "\(HTMLDocument.escape(summary))
"
+ }
+ return "No article content is available.
"
+ }
+
+ private func loadContent() async {
+ isLoading = true
+ loadError = nil
+ do {
+ articleContent = try await viewModel.fetchArticleContent(id: article.id)
+ } catch is CancellationError {
+ return
+ } catch {
+ loadError = error.localizedDescription
+ }
+ isLoading = false
+ }
+
+ private func translateTitle() async {
+ guard activeOperation == nil else { return }
+ activeOperation = .translateTitle
+ defer { activeOperation = nil }
+ do {
+ translatedTitle = try await viewModel.translateTitle(for: article)
+ if translatedTitle == article.title {
+ operationNotice = "The title already matches the selected target language."
+ }
+ } catch {
+ operationError = error.localizedDescription
+ }
+ }
+
+ private func translateArticle() async {
+ guard activeOperation == nil else { return }
+ activeOperation = .translateContent
+ defer { activeOperation = nil }
+ do {
+ let source = plainText(from: articleContent.content)
+ translatedContent = try await viewModel.translateContent(source)
+ if translatedContent?.translatedText == source {
+ operationNotice = "The article already primarily matches the selected target language."
+ } else {
+ displayTranslation = true
+ }
+ } catch {
+ operationError = error.localizedDescription
+ }
+ }
+
+ private func summarizeArticle() async {
+ guard activeOperation == nil else { return }
+ activeOperation = .summarize
+ defer { activeOperation = nil }
+ do {
+ summaryResult = try await viewModel.summarize(
+ article: article,
+ content: articleContent.content.isEmpty ? nil : plainText(from: articleContent.content)
+ )
+ } catch {
+ operationError = error.localizedDescription
+ }
+ }
+
+ private func plainText(from html: String) -> String {
+ guard let data = html.data(using: .utf8),
+ let attributed = try? NSAttributedString(
+ data: data,
+ options: [
+ .documentType: NSAttributedString.DocumentType.html,
+ .characterEncoding: String.Encoding.utf8.rawValue
+ ],
+ documentAttributes: nil
+ ) else {
+ return html
+ }
+ return attributed.string
+ }
+}
+
+private enum ArticleOperation {
+ case translateTitle
+ case translateContent
+ case summarize
+}
+
+private struct SummaryCard: View {
+ let result: SummaryResult
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Label("Summary", systemImage: "text.quote")
+ .font(.headline)
+ Spacer()
+ if result.cached == true {
+ Text("Cached")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ if result.usedFallback == true {
+ Text("Local fallback")
+ .font(.caption)
+ .foregroundStyle(.orange)
+ }
+ }
+ Text(result.summary)
+ .font(.callout)
+ .textSelection(.enabled)
+ .lineLimit(8)
+ .fixedSize(horizontal: false, vertical: true)
+ if let error = result.error, !error.isEmpty {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+ .padding(.horizontal, 24)
+ .padding(.vertical, 14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.accentColor.opacity(0.06))
+ }
+}
diff --git a/frontend-swift/Sources/Views/ArticleListView.swift b/frontend-swift/Sources/Views/ArticleListView.swift
new file mode 100644
index 00000000..7e3da44d
--- /dev/null
+++ b/frontend-swift/Sources/Views/ArticleListView.swift
@@ -0,0 +1,91 @@
+import SwiftUI
+
+struct ArticleListView: View {
+ @ObservedObject var viewModel: AppViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.articles) { article in
+ Button {
+ viewModel.selectArticle(article)
+ } label: {
+ ArticleRowView(article: article)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+ .listRowBackground(
+ viewModel.selectedArticleID == article.id
+ ? Color.accentColor.opacity(0.14)
+ : Color.clear
+ )
+ .onAppear {
+ if article.id == viewModel.articles.last?.id {
+ viewModel.loadMore()
+ }
+ }
+ .contextMenu {
+ Button {
+ viewModel.setArticleRead(article, read: !article.isRead)
+ } label: {
+ Label(
+ article.isRead ? "Mark as Unread" : "Mark as Read",
+ systemImage: article.isRead ? "circle" : "checkmark.circle"
+ )
+ }
+
+ Button {
+ viewModel.toggleFavorite(article)
+ } label: {
+ Label(
+ article.isFavorite ? "Remove Favorite" : "Favorite",
+ systemImage: article.isFavorite ? "star.slash" : "star"
+ )
+ }
+ }
+ }
+
+ if viewModel.isLoadingArticles && !viewModel.articles.isEmpty {
+ HStack {
+ Spacer()
+ ProgressView()
+ .controlSize(.small)
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.inset)
+ .overlay {
+ if viewModel.isLoadingArticles && viewModel.articles.isEmpty {
+ ProgressView("Loading articles")
+ } else if viewModel.articles.isEmpty {
+ ContentUnavailableView(
+ "No Articles",
+ systemImage: "tray",
+ description: Text("Refresh or choose another source.")
+ )
+ }
+ }
+ .navigationTitle(currentTitle)
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button("Refresh articles", systemImage: "arrow.clockwise") {
+ viewModel.reloadArticles()
+ }
+ .disabled(viewModel.isLoadingArticles)
+ .help("Reload the current article list")
+ }
+ }
+ }
+
+ private var currentTitle: String {
+ switch viewModel.selection {
+ case .filter(let filter):
+ filter.title
+ case .feed(let id):
+ viewModel.feeds.first(where: { $0.id == id })?.title ?? "Feed"
+ case .none:
+ "Articles"
+ }
+ }
+}
diff --git a/frontend-swift/Sources/Views/ArticleRowView.swift b/frontend-swift/Sources/Views/ArticleRowView.swift
new file mode 100644
index 00000000..4b692a27
--- /dev/null
+++ b/frontend-swift/Sources/Views/ArticleRowView.swift
@@ -0,0 +1,115 @@
+import SwiftUI
+
+struct ArticleRowView: View {
+ let article: Article
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ unreadIndicator
+
+ VStack(alignment: .leading, spacing: 7) {
+ Text(article.translatedTitle?.isEmpty == false ? article.translatedTitle! : article.title)
+ .font(.system(size: 14, weight: article.isRead ? .regular : .semibold))
+ .foregroundStyle(article.isRead ? .secondary : .primary)
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
+
+ if let translatedTitle = article.translatedTitle,
+ !translatedTitle.isEmpty,
+ translatedTitle != article.title {
+ Text(article.title)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+
+ HStack(spacing: 6) {
+ if let feedTitle = article.feedTitle {
+ Text(feedTitle)
+ .lineLimit(1)
+ }
+
+ if article.feedTitle != nil {
+ Text("·")
+ }
+
+ Text(ArticleDateFormatter.relativeString(from: article.publishedAt))
+
+ Spacer(minLength: 4)
+
+ if article.isReadLater {
+ Image(systemName: "clock.fill")
+ .foregroundStyle(.tint)
+ }
+ if article.isFavorite {
+ Image(systemName: "star.fill")
+ .foregroundStyle(.yellow)
+ }
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ if let imageURL = article.imageURL,
+ let url = URL(string: imageURL) {
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .empty:
+ imagePlaceholder
+ .overlay { ProgressView().controlSize(.mini) }
+ case .success(let image):
+ image
+ .resizable()
+ .scaledToFill()
+ case .failure:
+ imagePlaceholder
+ .overlay { Image(systemName: "photo") }
+ @unknown default:
+ imagePlaceholder
+ }
+ }
+ .frame(width: 76, height: 58)
+ .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
+ }
+ }
+ .padding(.vertical, 7)
+ .contentShape(Rectangle())
+ }
+
+ private var unreadIndicator: some View {
+ Circle()
+ .fill(article.isRead ? Color.clear : Color.accentColor)
+ .frame(width: 6, height: 6)
+ .padding(.top, 6)
+ }
+
+ private var imagePlaceholder: some View {
+ Rectangle()
+ .fill(.quaternary)
+ .foregroundStyle(.secondary)
+ }
+}
+
+enum ArticleDateFormatter {
+ private static let fractionalFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }()
+
+ private static let standardFormatter = ISO8601DateFormatter()
+
+ static func date(from string: String) -> Date? {
+ fractionalFormatter.date(from: string) ?? standardFormatter.date(from: string)
+ }
+
+ static func relativeString(from string: String) -> String {
+ guard let date = date(from: string) else { return string }
+ return date.formatted(.relative(presentation: .named))
+ }
+
+ static func fullString(from string: String) -> String {
+ guard let date = date(from: string) else { return string }
+ return date.formatted(date: .abbreviated, time: .shortened)
+ }
+}
diff --git a/frontend-swift/Sources/Views/ContentView.swift b/frontend-swift/Sources/Views/ContentView.swift
new file mode 100644
index 00000000..597bf739
--- /dev/null
+++ b/frontend-swift/Sources/Views/ContentView.swift
@@ -0,0 +1,50 @@
+import SwiftUI
+
+struct ContentView: View {
+ @ObservedObject var viewModel: AppViewModel
+
+ var body: some View {
+ NavigationSplitView {
+ SidebarView(viewModel: viewModel)
+ .navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 300)
+ } content: {
+ ArticleListView(viewModel: viewModel)
+ .navigationSplitViewColumnWidth(min: 330, ideal: 410, max: 520)
+ } detail: {
+ if let article = viewModel.article(withID: viewModel.selectedArticleID) {
+ ArticleDetailView(article: article, viewModel: viewModel)
+ .id(article.id)
+ } else {
+ EmptyArticleView()
+ }
+ }
+ .frame(minWidth: 980, minHeight: 620)
+ .alert(
+ "MrRSS",
+ isPresented: Binding(
+ get: { viewModel.errorMessage != nil },
+ set: { if !$0 { viewModel.clearError() } }
+ )
+ ) {
+ Button("Dismiss", role: .cancel) {
+ viewModel.clearError()
+ }
+ Button("Retry") {
+ viewModel.clearError()
+ viewModel.refreshAll()
+ }
+ } message: {
+ Text(viewModel.errorMessage ?? "")
+ }
+ }
+}
+
+private struct EmptyArticleView: View {
+ var body: some View {
+ ContentUnavailableView {
+ Label("Choose an article", systemImage: "newspaper")
+ } description: {
+ Text("Select an article from the list to read it here.")
+ }
+ }
+}
diff --git a/frontend-swift/Sources/Views/RulesSettingsView.swift b/frontend-swift/Sources/Views/RulesSettingsView.swift
new file mode 100644
index 00000000..ddac0f6a
--- /dev/null
+++ b/frontend-swift/Sources/Views/RulesSettingsView.swift
@@ -0,0 +1,286 @@
+import SwiftUI
+
+struct RulesSettingsView: View {
+ @ObservedObject var viewModel: AppViewModel
+ @State private var editingRule: AutomationRule?
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Automation Rules")
+ .font(.title2.weight(.semibold))
+ Text("Match incoming or existing articles and apply actions automatically.")
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button {
+ editingRule = .empty()
+ } label: {
+ Label("Add Rule", systemImage: "plus")
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ .padding()
+
+ Divider()
+
+ if viewModel.rules.isEmpty {
+ ContentUnavailableView {
+ Label("No Rules", systemImage: "bolt.badge.clock")
+ } description: {
+ Text("Create a rule to favorite, hide, mark, or defer matching articles.")
+ } actions: {
+ Button("Create Rule") { editingRule = .empty() }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ List {
+ ForEach(viewModel.rules) { rule in
+ RuleRow(
+ rule: rule,
+ onToggle: { enabled in update(rule, enabled: enabled) },
+ onEdit: { editingRule = rule },
+ onApply: { Task { await viewModel.applyRule(rule) } },
+ onDelete: { delete(rule) }
+ )
+ }
+ }
+ }
+ }
+ .sheet(item: $editingRule) { rule in
+ RuleEditorView(rule: rule) { savedRule in
+ save(savedRule)
+ }
+ }
+ }
+
+ private func update(_ rule: AutomationRule, enabled: Bool) {
+ var updated = rule
+ updated.enabled = enabled
+ save(updated)
+ }
+
+ private func save(_ rule: AutomationRule) {
+ var rules = viewModel.rules
+ if let index = rules.firstIndex(where: { $0.id == rule.id }) {
+ rules[index] = rule
+ } else {
+ rules.append(rule)
+ }
+ Task { await viewModel.saveRules(rules) }
+ }
+
+ private func delete(_ rule: AutomationRule) {
+ Task { await viewModel.saveRules(viewModel.rules.filter { $0.id != rule.id }) }
+ }
+}
+
+private struct RuleRow: View {
+ let rule: AutomationRule
+ let onToggle: (Bool) -> Void
+ let onEdit: () -> Void
+ let onApply: () -> Void
+ let onDelete: () -> Void
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Toggle("", isOn: Binding(get: { rule.enabled }, set: onToggle))
+ .labelsHidden()
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(rule.name)
+ .font(.headline)
+ Text("\(rule.conditions.count) conditions · \(rule.actions.count) actions")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Button("Apply Now", action: onApply)
+ Button("Edit", action: onEdit)
+ Menu {
+ Button("Delete", role: .destructive, action: onDelete)
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize()
+ }
+ .padding(.vertical, 5)
+ }
+}
+
+private struct RuleEditorView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State var rule: AutomationRule
+ let onSave: (AutomationRule) -> Void
+
+ private let fields = [
+ ("article_title", "Article title"),
+ ("feed_name", "Feed name"),
+ ("feed_category", "Feed category"),
+ ("feed_type", "Feed type"),
+ ("is_freshrss_feed", "FreshRSS feed"),
+ ("is_image_mode_feed", "Image-mode feed"),
+ ("published_after", "Published after"),
+ ("published_before", "Published before"),
+ ("is_read", "Read state"),
+ ("is_favorite", "Favorite state"),
+ ("is_hidden", "Hidden state"),
+ ("is_read_later", "Read-later state")
+ ]
+
+ private let actions = [
+ ("favorite", "Add favorite"),
+ ("unfavorite", "Remove favorite"),
+ ("hide", "Hide"),
+ ("unhide", "Unhide"),
+ ("mark_read", "Mark read"),
+ ("mark_unread", "Mark unread"),
+ ("read_later", "Add to read later"),
+ ("remove_read_later", "Remove from read later")
+ ]
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Rule") {
+ TextField("Name", text: $rule.name)
+ Toggle("Enabled", isOn: $rule.enabled)
+ }
+
+ Section("Conditions") {
+ if rule.conditions.isEmpty {
+ Text("No conditions means that every article matches.")
+ .foregroundStyle(.secondary)
+ }
+
+ ForEach($rule.conditions) { $condition in
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ if condition.id != rule.conditions.first?.id {
+ Picker("Logic", selection: Binding(
+ get: { condition.logic ?? "and" },
+ set: { condition.logic = $0 }
+ )) {
+ Text("AND").tag("and")
+ Text("OR").tag("or")
+ }
+ .labelsHidden()
+ .frame(width: 76)
+ }
+
+ Toggle("Not", isOn: $condition.negate)
+ .toggleStyle(.checkbox)
+
+ Picker("Field", selection: $condition.field) {
+ ForEach(fields, id: \.0) { value, title in
+ Text(title).tag(value)
+ }
+ }
+
+ Button(role: .destructive) {
+ rule.conditions.removeAll { $0.id == condition.id }
+ } label: {
+ Image(systemName: "trash")
+ }
+ .buttonStyle(.borderless)
+ }
+
+ conditionValueEditor($condition)
+ }
+ .padding(.vertical, 5)
+ }
+
+ Button {
+ var condition = RuleCondition.empty(id: nextConditionID)
+ condition.logic = rule.conditions.isEmpty ? nil : "and"
+ rule.conditions.append(condition)
+ } label: {
+ Label("Add Condition", systemImage: "plus")
+ }
+ }
+
+ Section("Actions") {
+ ForEach(actions, id: \.0) { value, title in
+ Toggle(title, isOn: Binding(
+ get: { rule.actions.contains(value) },
+ set: { enabled in
+ if enabled {
+ if !rule.actions.contains(value) { rule.actions.append(value) }
+ } else {
+ rule.actions.removeAll { $0 == value }
+ }
+ }
+ ))
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle("Edit Rule")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ normalizeConditions()
+ onSave(rule)
+ dismiss()
+ }
+ .disabled(rule.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rule.actions.isEmpty)
+ }
+ }
+ }
+ .frame(minWidth: 680, minHeight: 580)
+ }
+
+ @ViewBuilder
+ private func conditionValueEditor(_ condition: Binding) -> some View {
+ switch condition.wrappedValue.field {
+ case "article_title":
+ HStack {
+ Picker("Operator", selection: condition.operator) {
+ Text("Contains").tag("contains")
+ Text("Equals").tag("exact")
+ Text("Regular expression").tag("regex")
+ }
+ .frame(width: 180)
+ TextField("Value", text: condition.value)
+ }
+ case "feed_name", "feed_category":
+ TextField("Comma-separated values", text: Binding(
+ get: { condition.wrappedValue.values.joined(separator: ", ") },
+ set: { condition.wrappedValue.values = splitValues($0) }
+ ))
+ case "is_freshrss_feed", "is_image_mode_feed", "is_read", "is_favorite", "is_hidden", "is_read_later":
+ Picker("Value", selection: condition.value) {
+ Text("Yes").tag("true")
+ Text("No").tag("false")
+ }
+ default:
+ TextField(condition.wrappedValue.field.hasPrefix("published_") ? "YYYY-MM-DD" : "Value", text: condition.value)
+ }
+ }
+
+ private var nextConditionID: Int {
+ max((rule.conditions.map(\.id).max() ?? 0) + 1, Int(Date().timeIntervalSince1970 * 1_000))
+ }
+
+ private func splitValues(_ text: String) -> [String] {
+ text.split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+
+ private func normalizeConditions() {
+ for index in rule.conditions.indices {
+ rule.conditions[index].logic = index == 0 ? nil : (rule.conditions[index].logic ?? "and")
+ if rule.conditions[index].field == "article_title", rule.conditions[index].operator.isEmpty {
+ rule.conditions[index].operator = "contains"
+ }
+ }
+ }
+}
diff --git a/frontend-swift/Sources/Views/ServerSettingsView.swift b/frontend-swift/Sources/Views/ServerSettingsView.swift
new file mode 100644
index 00000000..171d5a62
--- /dev/null
+++ b/frontend-swift/Sources/Views/ServerSettingsView.swift
@@ -0,0 +1,39 @@
+import SwiftUI
+
+struct ServerSettingsView: View {
+ @ObservedObject var viewModel: AppViewModel
+ @State private var didSave = false
+
+ var body: some View {
+ Form {
+ Section("Backend server") {
+ TextField("API address", text: $viewModel.serverURLText)
+ .textFieldStyle(.roundedBorder)
+
+ Text("The default server address is http://127.0.0.1:1234/api. You can also set MRRSS_API_BASE_URL before launching the app.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack {
+ Circle()
+ .fill(viewModel.connectionState.color)
+ .frame(width: 8, height: 8)
+ Text(viewModel.connectionState.title)
+ .foregroundStyle(.secondary)
+ Spacer()
+ if didSave {
+ Text("Saved")
+ .foregroundStyle(.secondary)
+ }
+ Button("Save and reconnect") {
+ didSave = viewModel.saveServerAddress()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .formStyle(.grouped)
+ .padding(8)
+ }
+}
diff --git a/frontend-swift/Sources/Views/SettingsRootView.swift b/frontend-swift/Sources/Views/SettingsRootView.swift
new file mode 100644
index 00000000..59ef4a08
--- /dev/null
+++ b/frontend-swift/Sources/Views/SettingsRootView.swift
@@ -0,0 +1,212 @@
+import SwiftUI
+
+struct SettingsRootView: View {
+ @ObservedObject var viewModel: AppViewModel
+ @State private var selectedPane: SettingsPane? = .general
+
+ var body: some View {
+ NavigationSplitView {
+ List(SettingsPane.allCases, selection: $selectedPane) { pane in
+ Label(pane.title, systemImage: pane.icon)
+ .tag(pane)
+ }
+ .navigationTitle("Settings")
+ .navigationSplitViewColumnWidth(min: 165, ideal: 185, max: 220)
+ } detail: {
+ detailView
+ .navigationTitle(selectedPane?.title ?? "Settings")
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ Task { await viewModel.saveSettings() }
+ }
+ .disabled(viewModel.isSavingSettings || selectedPane == .connection || selectedPane == .rules)
+ }
+ }
+ }
+ .frame(minWidth: 760, idealWidth: 860, minHeight: 540, idealHeight: 620)
+ .task {
+ if viewModel.settings.isEmpty {
+ await viewModel.loadSettings()
+ }
+ }
+ .overlay(alignment: .bottom) {
+ if let message = viewModel.statusMessage {
+ Text(message)
+ .font(.callout)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 9)
+ .background(.regularMaterial, in: Capsule())
+ .padding(.bottom, 12)
+ .task(id: message) {
+ try? await Task.sleep(for: .seconds(3))
+ viewModel.clearStatusMessage()
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var detailView: some View {
+ switch selectedPane ?? .general {
+ case .connection:
+ ServerSettingsView(viewModel: viewModel)
+ case .rules:
+ RulesSettingsView(viewModel: viewModel)
+ case .advanced:
+ AllSettingsView(viewModel: viewModel)
+ case let pane:
+ DynamicSettingsForm(viewModel: viewModel, definitions: SettingsCatalog.definitions(for: pane))
+ }
+ }
+}
+
+private struct DynamicSettingsForm: View {
+ @ObservedObject var viewModel: AppViewModel
+ let definitions: [SettingDefinition]
+
+ private var sections: [String] {
+ definitions.reduce(into: []) { result, definition in
+ if !result.contains(definition.section) { result.append(definition.section) }
+ }
+ }
+
+ var body: some View {
+ Form {
+ ForEach(sections, id: \.self) { section in
+ Section(section) {
+ ForEach(definitions.filter { $0.section == section }) { definition in
+ settingControl(definition)
+ }
+ }
+ }
+
+ if definitions.contains(where: { $0.pane == .summaryAI }) {
+ Section("AI usage") {
+ if let usage = viewModel.aiUsage {
+ LabeledContent("Tokens", value: "\(usage.usage) / \(usage.limit)")
+ ProgressView(value: Double(usage.usage), total: Double(max(usage.limit, 1)))
+ Button("Reset usage counter") {
+ Task { await viewModel.resetAIUsage() }
+ }
+ } else {
+ Text("Usage information is unavailable.")
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Section("Generated content") {
+ Button("Clear cached translations and summaries", role: .destructive) {
+ Task { await viewModel.clearGeneratedContent() }
+ }
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .overlay {
+ if viewModel.isLoadingSettings {
+ ProgressView("Loading settings")
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func settingControl(_ definition: SettingDefinition) -> some View {
+ switch definition.control {
+ case .toggle:
+ Toggle(definition.title, isOn: boolBinding(definition.key))
+ case .text:
+ LabeledContent(definition.title) {
+ TextField(definition.title, text: textBinding(definition.key))
+ .multilineTextAlignment(.trailing)
+ .frame(minWidth: 240)
+ }
+ case .secure:
+ LabeledContent(definition.title) {
+ SecureField(definition.title, text: textBinding(definition.key))
+ .multilineTextAlignment(.trailing)
+ .frame(minWidth: 240)
+ }
+ case .picker(let options):
+ Picker(definition.title, selection: textBinding(definition.key)) {
+ ForEach(options, id: \.0) { value, label in
+ Text(label).tag(value)
+ }
+ }
+ }
+
+ if let detail = definition.detail {
+ Text(detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private func textBinding(_ key: String) -> Binding {
+ Binding(
+ get: { viewModel.setting(key) },
+ set: { viewModel.updateSetting(key, value: $0) }
+ )
+ }
+
+ private func boolBinding(_ key: String) -> Binding {
+ Binding(
+ get: { viewModel.boolSetting(key) },
+ set: { viewModel.updateBoolSetting(key, value: $0) }
+ )
+ }
+}
+
+private struct AllSettingsView: View {
+ @ObservedObject var viewModel: AppViewModel
+ @State private var searchText = ""
+
+ private var keys: [String] {
+ viewModel.settings.keys
+ .filter { searchText.isEmpty || $0.localizedCaseInsensitiveContains(searchText) }
+ .sorted()
+ }
+
+ var body: some View {
+ Form {
+ Section {
+ Text("This pane exposes every backend setting, including internal state. Use the categorized panes for normal configuration.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Section("Backend settings") {
+ ForEach(keys, id: \.self) { key in
+ if SettingsCatalog.boolKeys.contains(key) {
+ Toggle(key, isOn: Binding(
+ get: { viewModel.boolSetting(key) },
+ set: { viewModel.updateBoolSetting(key, value: $0) }
+ ))
+ } else if SettingsCatalog.secureKeys.contains(key) {
+ LabeledContent(key) {
+ SecureField(key, text: valueBinding(key))
+ .multilineTextAlignment(.trailing)
+ .frame(minWidth: 300)
+ }
+ } else {
+ LabeledContent(key) {
+ TextField(key, text: valueBinding(key), axis: .vertical)
+ .multilineTextAlignment(.trailing)
+ .lineLimit(1...5)
+ .frame(minWidth: 300)
+ }
+ }
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .searchable(text: $searchText, prompt: "Filter setting keys")
+ }
+
+ private func valueBinding(_ key: String) -> Binding {
+ Binding(
+ get: { viewModel.setting(key) },
+ set: { viewModel.updateSetting(key, value: $0) }
+ )
+ }
+}
diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift
new file mode 100644
index 00000000..7af6d1e6
--- /dev/null
+++ b/frontend-swift/Sources/Views/SidebarView.swift
@@ -0,0 +1,159 @@
+import SwiftUI
+
+struct SidebarView: View {
+ @ObservedObject var viewModel: AppViewModel
+ @State private var isShowingAddFeed = false
+ @State private var feedPendingDeletion: Feed?
+
+ var body: some View {
+ VStack(spacing: 0) {
+ List {
+ Section("Library") {
+ ForEach(ArticleFilter.allCases) { filter in
+ sidebarButton(for: .filter(filter)) {
+ Label(filter.title, systemImage: filter.icon)
+ }
+ .badge(badge(for: filter))
+ }
+ }
+
+ Section("Feeds") {
+ if viewModel.isLoadingFeeds && viewModel.feeds.isEmpty {
+ HStack(spacing: 8) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Loading feeds")
+ .foregroundStyle(.secondary)
+ }
+ } else if viewModel.feeds.isEmpty {
+ Text("No feeds available")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(viewModel.feeds) { feed in
+ sidebarButton(for: .feed(feed.id)) {
+ FeedLabel(feed: feed)
+ }
+ .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0)
+ .contextMenu {
+ Button("Delete Feed", systemImage: "trash", role: .destructive) {
+ feedPendingDeletion = feed
+ }
+ }
+ }
+ }
+ }
+ }
+ .listStyle(.sidebar)
+
+ Divider()
+
+ HStack(spacing: 8) {
+ Circle()
+ .fill(viewModel.connectionState.color)
+ .frame(width: 7, height: 7)
+ Text(viewModel.connectionState.title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Spacer()
+ if viewModel.isLoadingFeeds || viewModel.isRefreshingSources {
+ ProgressView()
+ .controlSize(.mini)
+ }
+ }
+ .padding(.horizontal, 12)
+ .frame(height: 36)
+ .background(.ultraThinMaterial)
+ }
+ .navigationTitle("MrRSS")
+ .toolbar {
+ ToolbarItemGroup(placement: .primaryAction) {
+ Button("Add feed", systemImage: "plus") {
+ isShowingAddFeed = true
+ }
+ .help("Add a feed")
+
+ Button("Refresh feeds", systemImage: "arrow.clockwise") {
+ viewModel.refreshFromSources()
+ }
+ .disabled(viewModel.isRefreshingSources)
+ .help("Refresh feeds and articles")
+ }
+ }
+ .sheet(isPresented: $isShowingAddFeed) {
+ AddFeedView(viewModel: viewModel)
+ }
+ .confirmationDialog(
+ "Delete \(feedPendingDeletion?.title ?? "this feed")?",
+ isPresented: Binding(
+ get: { feedPendingDeletion != nil },
+ set: { if !$0 { feedPendingDeletion = nil } }
+ )
+ ) {
+ Button("Delete Feed", role: .destructive) {
+ guard let feed = feedPendingDeletion else { return }
+ feedPendingDeletion = nil
+ Task { await viewModel.deleteFeed(feed) }
+ }
+ Button("Cancel", role: .cancel) {
+ feedPendingDeletion = nil
+ }
+ } message: {
+ Text("The subscription and its stored articles will be removed.")
+ }
+ }
+
+ private func badge(for filter: ArticleFilter) -> Int {
+ switch filter {
+ case .all, .unread:
+ viewModel.unreadCounts.total
+ case .favorites, .readLater:
+ 0
+ }
+ }
+
+ private func sidebarButton(
+ for item: SidebarItem,
+ @ViewBuilder label: () -> Label
+ ) -> some View {
+ Button {
+ viewModel.selection = item
+ } label: {
+ label()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .listRowBackground(
+ viewModel.selection == item
+ ? Color.accentColor.opacity(0.16)
+ : Color.clear
+ )
+ }
+}
+
+private struct FeedLabel: View {
+ let feed: Feed
+
+ var body: some View {
+ Label {
+ Text(feed.title)
+ .lineLimit(1)
+ } icon: {
+ if let iconURL = feed.iconURL,
+ let url = URL(string: iconURL) {
+ AsyncImage(url: url) { image in
+ image
+ .resizable()
+ .scaledToFill()
+ } placeholder: {
+ Image(systemName: "dot.radiowaves.left.and.right")
+ }
+ .frame(width: 16, height: 16)
+ .clipShape(RoundedRectangle(cornerRadius: 3))
+ } else {
+ Image(systemName: "dot.radiowaves.left.and.right")
+ }
+ }
+ }
+}
diff --git a/frontend-swift/Sources/Views/WebView.swift b/frontend-swift/Sources/Views/WebView.swift
new file mode 100644
index 00000000..81a47c49
--- /dev/null
+++ b/frontend-swift/Sources/Views/WebView.swift
@@ -0,0 +1,222 @@
+import AppKit
+import SwiftUI
+import WebKit
+
+struct WebView: NSViewRepresentable {
+ let html: String
+ let baseURL: URL?
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator()
+ }
+
+ func makeNSView(context: Context) -> ClippedWebViewContainer {
+ let configuration = WKWebViewConfiguration()
+ let webpagePreferences = WKWebpagePreferences()
+ webpagePreferences.allowsContentJavaScript = false
+ configuration.defaultWebpagePreferences = webpagePreferences
+ configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
+ configuration.websiteDataStore = .nonPersistent()
+ configuration.mediaTypesRequiringUserActionForPlayback = .all
+
+ let webView = WKWebView(frame: .zero, configuration: configuration)
+ webView.navigationDelegate = context.coordinator
+ webView.underPageBackgroundColor = .textBackgroundColor
+ return ClippedWebViewContainer(webView: webView)
+ }
+
+ func updateNSView(_ container: ClippedWebViewContainer, context: Context) {
+ let webView = container.webView
+ let document = HTMLDocument.build(from: html)
+ guard document != context.coordinator.loadedDocument || baseURL != context.coordinator.loadedBaseURL else {
+ return
+ }
+
+ context.coordinator.loadedDocument = document
+ context.coordinator.loadedBaseURL = baseURL
+ webView.loadHTMLString(document, baseURL: baseURL)
+ }
+
+ /// The container never reports a size of its own. Web content can be far
+ /// taller than the window, and any size derived from the loaded document
+ /// would be propagated up to `NavigationSplitView`, which then lays the
+ /// whole window out around that height.
+ func sizeThatFits(
+ _ proposal: ProposedViewSize,
+ nsView: ClippedWebViewContainer,
+ context: Context
+ ) -> CGSize? {
+ CGSize(
+ width: WebView.resolvedLength(proposal.width),
+ height: WebView.resolvedLength(proposal.height)
+ )
+ }
+
+ /// Maps an unspecified or infinite proposal to zero so the surrounding
+ /// `frame(maxWidth:maxHeight:)` decides how much space the view receives.
+ private static func resolvedLength(_ value: CGFloat?) -> CGFloat {
+ guard let value, value.isFinite, value > 0 else { return 0 }
+ return value
+ }
+
+ final class Coordinator: NSObject, WKNavigationDelegate {
+ var loadedDocument = ""
+ var loadedBaseURL: URL?
+
+ func webView(
+ _ webView: WKWebView,
+ decidePolicyFor navigationAction: WKNavigationAction,
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
+ ) {
+ guard let url = navigationAction.request.url else {
+ decisionHandler(.allow)
+ return
+ }
+
+ if navigationAction.navigationType == .linkActivated {
+ if ["http", "https"].contains(url.scheme?.lowercased() ?? "") {
+ NSWorkspace.shared.open(url)
+ }
+ decisionHandler(.cancel)
+ return
+ }
+
+ if url.scheme == "about" || navigationAction.targetFrame == nil {
+ decisionHandler(.allow)
+ } else if navigationAction.targetFrame?.isMainFrame == true,
+ url == loadedBaseURL {
+ decisionHandler(.allow)
+ } else {
+ decisionHandler(.cancel)
+ }
+ }
+ }
+}
+
+final class ClippedWebViewContainer: NSView {
+ let webView: WKWebView
+
+ init(webView: WKWebView) {
+ self.webView = webView
+ super.init(frame: .zero)
+
+ wantsLayer = true
+ layer?.masksToBounds = true
+ layer?.backgroundColor = NSColor.textBackgroundColor.cgColor
+
+ setContentHuggingPriority(.defaultLow, for: .horizontal)
+ setContentHuggingPriority(.defaultLow, for: .vertical)
+ setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+
+ webView.frame = bounds
+ webView.autoresizingMask = [.width, .height]
+ webView.wantsLayer = true
+ webView.layer?.masksToBounds = true
+ addSubview(webView)
+ }
+
+ override var intrinsicContentSize: NSSize {
+ NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric)
+ }
+
+ override func layout() {
+ super.layout()
+ webView.frame = bounds
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+enum HTMLDocument {
+ static func build(from input: String) -> String {
+ let sanitized = sanitize(input)
+ return """
+
+
+
+
+
+
+
+
+ \(sanitized)
+
+ """
+ }
+
+ static func escape(_ input: String) -> String {
+ input
+ .replacingOccurrences(of: "&", with: "&")
+ .replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">")
+ .replacingOccurrences(of: "\"", with: """)
+ .replacingOccurrences(of: "'", with: "'")
+ }
+
+ private static func sanitize(_ input: String) -> String {
+ var output = input
+ let patterns = [
+ #"(?is)<(script|iframe|object|embed|form|base)\b[^>]*>.*?\1\s*>"#,
+ #"(?is)<(script|iframe|object|embed|form|base|meta)\b[^>]*?/?>"#,
+ #"(?i)\s+on[a-z]+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)"#,
+ #"(?i)(href|src)\s*=\s*([\"'])\s*javascript:.*?\2"#
+ ]
+
+ for pattern in patterns {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
+ let range = NSRange(output.startIndex..., in: output)
+ output = regex.stringByReplacingMatches(in: output, range: range, withTemplate: "")
+ }
+ return output
+ }
+}
diff --git a/frontend-swift/Tests/APIServiceTests.swift b/frontend-swift/Tests/APIServiceTests.swift
new file mode 100644
index 00000000..7eba5bc1
--- /dev/null
+++ b/frontend-swift/Tests/APIServiceTests.swift
@@ -0,0 +1,178 @@
+import XCTest
+@testable import MrRSS
+
+final class APIServiceTests: XCTestCase {
+ override func tearDown() {
+ MockURLProtocol.handler = nil
+ super.tearDown()
+ }
+
+ func testFetchArticlesSendsAllFilterAndPagination() async throws {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [MockURLProtocol.self]
+ let session = URLSession(configuration: configuration)
+ let service = APIService(baseURL: URL(string: "http://127.0.0.1:1234/api")!, session: session)
+
+ MockURLProtocol.handler = { request in
+ let components = try XCTUnwrap(URLComponents(url: request.url!, resolvingAgainstBaseURL: false))
+ let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") })
+ XCTAssertEqual(components.path, "/api/articles")
+ XCTAssertEqual(query["filter"], "all")
+ XCTAssertEqual(query["page"], "2")
+ XCTAssertEqual(query["limit"], "25")
+ return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data("[]".utf8))
+ }
+
+ let articles = try await service.fetchArticles(
+ feedID: nil,
+ category: nil,
+ filter: "all",
+ page: 2,
+ limit: 25
+ )
+
+ XCTAssertTrue(articles.isEmpty)
+ }
+
+ func testServerErrorIncludesResponseMessage() async {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [MockURLProtocol.self]
+ let session = URLSession(configuration: configuration)
+ let service = APIService(baseURL: URL(string: "http://127.0.0.1:1234/api")!, session: session)
+
+ MockURLProtocol.handler = { request in
+ return (
+ HTTPURLResponse(url: request.url!, statusCode: 503, httpVersion: nil, headerFields: nil)!,
+ Data("database unavailable".utf8)
+ )
+ }
+
+ do {
+ _ = try await service.fetchFeeds()
+ XCTFail("Expected the request to fail")
+ } catch let error as APIError {
+ XCTAssertEqual(error, .server(statusCode: 503, message: "database unavailable"))
+ } catch {
+ XCTFail("Unexpected error: \(error)")
+ }
+ }
+
+ func testServerAddressNormalizationAddsSchemeAndAPIPath() {
+ let url = ServerConfiguration.normalizedURL(from: "127.0.0.1:1234")
+ XCTAssertEqual(url?.absoluteString, "http://127.0.0.1:1234/api")
+ }
+
+ func testUpdateSettingsSendsCompleteJSONMap() async throws {
+ let service = makeService()
+ MockURLProtocol.handler = { request in
+ XCTAssertEqual(request.httpMethod, "POST")
+ XCTAssertEqual(request.url?.path, "/api/settings")
+ let body = try Self.requestBody(from: request)
+ let values = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: String])
+ XCTAssertEqual(values["theme"], "dark")
+ XCTAssertEqual(values["translation_enabled"], "true")
+ return Self.response(for: request, data: Data())
+ }
+
+ try await service.updateSettings(["theme": "dark", "translation_enabled": "true"])
+ }
+
+ func testTranslationSummaryAndRuleResponsesDecode() async throws {
+ let service = makeService()
+ MockURLProtocol.handler = { request in
+ switch request.url?.path {
+ case "/api/articles/translate":
+ return Self.response(
+ for: request,
+ data: Data(#"{"translated_title":"译文","limit_reached":false}"#.utf8)
+ )
+ case "/api/articles/summarize":
+ return Self.response(
+ for: request,
+ data: Data(#"{"summary":"摘要","is_too_short":false}"#.utf8)
+ )
+ case "/api/rules/apply":
+ return Self.response(
+ for: request,
+ data: Data(#"{"success":true,"affected":4}"#.utf8)
+ )
+ default:
+ XCTFail("Unexpected endpoint: \(request.url?.path ?? "nil")")
+ return Self.response(for: request, statusCode: 404, data: Data())
+ }
+ }
+
+ let translation = try await service.translateTitle(articleID: 1, title: "Text", targetLanguage: "zh")
+ let summary = try await service.summarize(articleID: 1, length: "medium", content: "Body")
+ let ruleResult = try await service.applyRule(
+ AutomationRule(id: 1, name: "Test", enabled: true, conditions: [], actions: ["favorite"])
+ )
+
+ XCTAssertEqual(translation.translatedTitle, "译文")
+ XCTAssertEqual(summary.summary, "摘要")
+ XCTAssertNil(summary.sentenceCount)
+ XCTAssertEqual(ruleResult.affected, 4)
+ }
+
+ private func makeService() -> APIService {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [MockURLProtocol.self]
+ return APIService(
+ baseURL: URL(string: "http://127.0.0.1:1234/api")!,
+ session: URLSession(configuration: configuration)
+ )
+ }
+
+ private static func response(
+ for request: URLRequest,
+ statusCode: Int = 200,
+ data: Data
+ ) -> (HTTPURLResponse, Data) {
+ (
+ HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!,
+ data
+ )
+ }
+
+ private static func requestBody(from request: URLRequest) throws -> Data {
+ if let body = request.httpBody { return body }
+ let stream = try XCTUnwrap(request.httpBodyStream)
+ stream.open()
+ defer { stream.close() }
+
+ var data = Data()
+ var buffer = [UInt8](repeating: 0, count: 1_024)
+ while stream.hasBytesAvailable {
+ let count = stream.read(&buffer, maxLength: buffer.count)
+ if count < 0 { throw stream.streamError ?? APIError.invalidResponse }
+ if count == 0 { break }
+ data.append(buffer, count: count)
+ }
+ return data
+ }
+}
+
+private final class MockURLProtocol: URLProtocol {
+ static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ guard let handler = Self.handler else {
+ XCTFail("MockURLProtocol handler was not configured")
+ return
+ }
+
+ do {
+ let (response, data) = try handler(request)
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/frontend-swift/Tests/AppViewModelTests.swift b/frontend-swift/Tests/AppViewModelTests.swift
new file mode 100644
index 00000000..7e2467e6
--- /dev/null
+++ b/frontend-swift/Tests/AppViewModelTests.swift
@@ -0,0 +1,178 @@
+import XCTest
+@testable import MrRSS
+
+@MainActor
+final class AppViewModelTests: XCTestCase {
+ func testChangingSelectionDiscardsPreviousResponse() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = AppViewModel(api: client, autoLoad: false)
+
+ viewModel.reloadArticles()
+ try await Task.sleep(for: .milliseconds(20))
+ viewModel.selection = .feed(2)
+ try await Task.sleep(for: .milliseconds(300))
+
+ XCTAssertEqual(viewModel.articles.map(\.id), [2])
+ }
+
+ func testReadActionCanMarkArticleUnread() async throws {
+ let client = DelayedAPIClient()
+ client.defaultArticles = [Self.article(id: 1, feedID: 1, isRead: true)]
+ let viewModel = AppViewModel(api: client, autoLoad: false)
+
+ viewModel.reloadArticles()
+ try await Task.sleep(for: .milliseconds(220))
+ let article = try XCTUnwrap(viewModel.articles.first)
+ viewModel.setArticleRead(article, read: false)
+
+ XCTAssertEqual(viewModel.articles.first?.isRead, false)
+ try await Task.sleep(for: .milliseconds(20))
+ XCTAssertEqual(client.lastReadMutation, .init(id: 1, read: false))
+ }
+
+ func testReloadKeepsCurrentArticlesUntilReplacementArrives() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = AppViewModel(api: client, autoLoad: false)
+
+ viewModel.reloadArticles()
+ try await Task.sleep(for: .milliseconds(220))
+ XCTAssertEqual(viewModel.articles.map(\.id), [1])
+
+ client.defaultArticles = [Self.article(id: 3, feedID: 1)]
+ viewModel.reloadArticles()
+
+ XCTAssertEqual(viewModel.articles.map(\.id), [1])
+ try await Task.sleep(for: .milliseconds(220))
+ XCTAssertEqual(viewModel.articles.map(\.id), [3])
+ }
+
+ func testSelectingAndMarkingArticleReadKeepsItInTheList() async throws {
+ let client = DelayedAPIClient()
+ client.defaultArticles = [Self.article(id: 1, feedID: 1)]
+ let viewModel = AppViewModel(api: client, autoLoad: false)
+
+ viewModel.reloadArticles()
+ try await Task.sleep(for: .milliseconds(220))
+ let article = try XCTUnwrap(viewModel.articles.first)
+
+ viewModel.selectArticle(article)
+ viewModel.setArticleRead(article, read: true)
+
+ XCTAssertEqual(viewModel.selectedArticleID, 1)
+ XCTAssertEqual(viewModel.articles.map(\.id), [1])
+ XCTAssertEqual(viewModel.articles.first?.isRead, true)
+ }
+
+ private static func article(id: Int, feedID: Int, isRead: Bool = false) -> Article {
+ Article(
+ id: id,
+ feedID: feedID,
+ feedTitle: "Feed \(feedID)",
+ title: "Article \(id)",
+ url: "https://example.com/\(id)",
+ publishedAt: "2026-08-16T08:00:00Z",
+ isRead: isRead
+ )
+ }
+}
+
+final class DelayedAPIClient: APIClient {
+ struct ReadMutation: Equatable {
+ let id: Int
+ let read: Bool
+ }
+
+ let baseURL = URL(string: "http://127.0.0.1:1234/api")!
+ var defaultArticles = [
+ Article(
+ id: 1,
+ feedID: 1,
+ feedTitle: "Feed 1",
+ title: "Old article",
+ url: "https://example.com/1",
+ publishedAt: "2026-08-16T08:00:00Z"
+ )
+ ]
+ private(set) var lastReadMutation: ReadMutation?
+ var articleContent = ArticleContent(content: "", feedURL: nil)
+
+ func checkConnection() async throws {}
+ func fetchFeeds() async throws -> [Feed] { [] }
+ func fetchUnreadCounts() async throws -> UnreadCounts { .empty }
+ func addFeed(url: String, title: String, category: String) async throws {}
+ func deleteFeed(id: Int) async throws {}
+ func refreshAllFeeds() async throws {}
+ func fetchRefreshProgress() async throws -> RefreshProgress {
+ RefreshProgress(isRunning: false)
+ }
+
+ func fetchArticles(
+ feedID: Int?,
+ category: String?,
+ filter: String,
+ page: Int,
+ limit: Int
+ ) async throws -> [Article] {
+ if feedID == 2 {
+ try await Task.sleep(for: .milliseconds(10))
+ return [
+ Article(
+ id: 2,
+ feedID: 2,
+ feedTitle: "Feed 2",
+ title: "Current article",
+ url: "https://example.com/2",
+ publishedAt: "2026-08-16T08:00:00Z"
+ )
+ ]
+ }
+
+ try? await Task.sleep(for: .milliseconds(180))
+ return defaultArticles
+ }
+
+ func setArticleRead(id: Int, read: Bool) async throws {
+ lastReadMutation = ReadMutation(id: id, read: read)
+ }
+
+ func toggleFavorite(id: Int) async throws {}
+
+ func fetchArticleContent(id: Int) async throws -> ArticleContent {
+ articleContent
+ }
+
+ func fetchSettings() async throws -> [String: String] { [:] }
+ func updateSettings(_ settings: [String: String]) async throws {}
+ func translateTitle(
+ articleID: Int,
+ title: String,
+ targetLanguage: String
+ ) async throws -> TitleTranslationResponse {
+ TitleTranslationResponse(translatedTitle: title, limitReached: false)
+ }
+ func translateText(_ text: String, targetLanguage: String) async throws -> TextTranslationResponse {
+ TextTranslationResponse(translatedText: text, html: text)
+ }
+ func summarize(articleID: Int, length: String, content: String?) async throws -> SummaryResult {
+ SummaryResult(
+ summary: "Summary",
+ html: nil,
+ sentenceCount: 1,
+ isTooShort: false,
+ limitReached: false,
+ usedFallback: false,
+ thinking: nil,
+ error: nil,
+ cached: false
+ )
+ }
+ func clearTranslations() async throws {}
+ func clearSummaries() async throws {}
+ func applyRule(_ rule: AutomationRule) async throws -> RuleApplicationResult {
+ RuleApplicationResult(success: true, affected: 0)
+ }
+ func fetchAIUsage() async throws -> AIUsage {
+ AIUsage(usage: 0, limit: 20_000, limitReached: false)
+ }
+ func resetAIUsage() async throws {}
+}
diff --git a/frontend-swift/Tests/HTMLDocumentTests.swift b/frontend-swift/Tests/HTMLDocumentTests.swift
new file mode 100644
index 00000000..343f1449
--- /dev/null
+++ b/frontend-swift/Tests/HTMLDocumentTests.swift
@@ -0,0 +1,24 @@
+import XCTest
+@testable import MrRSS
+
+final class HTMLDocumentTests: XCTestCase {
+ func testDocumentRemovesExecutableMarkup() {
+ let input = #"Safe
Bad link"#
+
+ let document = HTMLDocument.build(from: input)
+
+ XCTAssertTrue(document.contains("Safe
"))
+ XCTAssertFalse(document.contains(""))
+ XCTAssertFalse(document.contains("onclick="))
+ XCTAssertFalse(document.contains("javascript:"))
+ XCTAssertTrue(document.contains("script-src 'none'"))
+ XCTAssertTrue(document.contains("form-action 'none'"))
+ }
+
+ func testEscapeProtectsPlainTextSummary() {
+ XCTAssertEqual(
+ HTMLDocument.escape(#" & "world""#),
+ "<hello> & "world""
+ )
+ }
+}
diff --git a/frontend-swift/Tests/ModelsTests.swift b/frontend-swift/Tests/ModelsTests.swift
new file mode 100644
index 00000000..c3c8dfd2
--- /dev/null
+++ b/frontend-swift/Tests/ModelsTests.swift
@@ -0,0 +1,41 @@
+import XCTest
+@testable import MrRSS
+
+final class ModelsTests: XCTestCase {
+ func testFeedDecodesBackendResponse() throws {
+ let data = Data(#"""
+ {
+ "id": 7,
+ "title": "Example",
+ "url": "https://example.com/feed.xml",
+ "category": "Technology",
+ "image_url": "",
+ "last_updated": "2026-08-16T08:00:00Z",
+ "description": "Additional backend fields are ignored"
+ }
+ """#.utf8)
+
+ let feed = try JSONDecoder().decode(Feed.self, from: data)
+
+ XCTAssertEqual(feed.id, 7)
+ XCTAssertEqual(feed.lastUpdated, "2026-08-16T08:00:00Z")
+ XCTAssertNil(feed.iconURL)
+ }
+
+ func testFeedUsesSafeDefaultsForOlderResponses() throws {
+ let data = Data(#"{"id":1,"url":"https://example.com/rss","title":"Example"}"#.utf8)
+
+ let feed = try JSONDecoder().decode(Feed.self, from: data)
+
+ XCTAssertEqual(feed.category, "")
+ XCTAssertEqual(feed.lastUpdated, "")
+ }
+
+ func testUnreadCountsDecodeIntegerFeedIdentifiers() throws {
+ let data = Data(#"{"total":5,"feed_counts":{"1":2,"42":3}}"#.utf8)
+
+ let counts = try JSONDecoder().decode(UnreadCounts.self, from: data)
+
+ XCTAssertEqual(counts, UnreadCounts(total: 5, feedCounts: [1: 2, 42: 3]))
+ }
+}
diff --git a/frontend-swift/Tests/WebViewLayoutTests.swift b/frontend-swift/Tests/WebViewLayoutTests.swift
new file mode 100644
index 00000000..deb898a2
--- /dev/null
+++ b/frontend-swift/Tests/WebViewLayoutTests.swift
@@ -0,0 +1,145 @@
+import SwiftUI
+import WebKit
+import XCTest
+@testable import MrRSS
+
+@MainActor
+final class WebViewLayoutTests: XCTestCase {
+ func testContainerKeepsWebViewInsideItsBounds() {
+ let webView = WKWebView(frame: .zero)
+ let container = ClippedWebViewContainer(webView: webView)
+ container.frame = NSRect(x: 0, y: 0, width: 640, height: 480)
+
+ container.layoutSubtreeIfNeeded()
+
+ XCTAssertEqual(webView.frame, container.bounds)
+ XCTAssertEqual(container.intrinsicContentSize.width, NSView.noIntrinsicMetric)
+ XCTAssertEqual(container.intrinsicContentSize.height, NSView.noIntrinsicMetric)
+ XCTAssertTrue(container.layer?.masksToBounds == true)
+ XCTAssertTrue(webView.layer?.masksToBounds == true)
+ }
+
+ func testArticleContentDoesNotExpandSplitViewBeyondWindow() async throws {
+ let client = DelayedAPIClient()
+ client.articleContent = ArticleContent(
+ content: String(repeating: "Long article content.
", count: 2_000),
+ feedURL: "https://example.com"
+ )
+ client.defaultArticles = [
+ Article(
+ id: 1,
+ feedID: 1,
+ feedTitle: "Feed 1",
+ title: String(repeating: "Long article title ", count: 20),
+ url: "https://example.com/1",
+ publishedAt: "2026-08-16T08:00:00Z",
+ summary: String(repeating: "Long article summary. ", count: 400)
+ )
+ ]
+ let viewModel = AppViewModel(api: client, autoLoad: false)
+
+ viewModel.reloadArticles()
+ try await Task.sleep(for: .milliseconds(220))
+ let article = try XCTUnwrap(viewModel.articles.first)
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 1_280, height: 780),
+ styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
+ backing: .buffered,
+ defer: false
+ )
+ let hostingView = NSHostingView(rootView: ContentView(viewModel: viewModel))
+ window.contentView = hostingView
+ window.makeKeyAndOrderFront(nil)
+ hostingView.layoutSubtreeIfNeeded()
+
+ viewModel.selectArticle(article)
+ try await Task.sleep(for: .milliseconds(600))
+ window.layoutIfNeeded()
+ hostingView.layoutSubtreeIfNeeded()
+ try await Task.sleep(for: .milliseconds(400))
+
+ let splitView = try XCTUnwrap(firstDescendant(of: NSSplitView.self, in: hostingView))
+ XCTAssertEqual(splitView.frame.height, hostingView.bounds.height, accuracy: 1)
+ XCTAssertEqual(splitView.frame.width, hostingView.bounds.width, accuracy: 1)
+
+ // The split view is hosted by an intermediate AppKit view. When a column
+ // reports an ideal height taller than the window, that view keeps its
+ // oversized height and is centred, which scrolls the sidebar and the
+ // article list out of the window.
+ let splitHost = try XCTUnwrap(splitView.superview?.superview)
+ XCTAssertEqual(splitHost.frame.origin.y, 0, accuracy: 1)
+ XCTAssertEqual(splitHost.frame.height, hostingView.bounds.height, accuracy: 1)
+
+ let visibleBounds = hostingView.bounds
+ for column in splitView.subviews where column.frame.height > 100 {
+ let columnFrame = column.convert(column.bounds, to: hostingView)
+ XCTAssertEqual(
+ columnFrame.origin.y, 0, accuracy: 1,
+ "A split view column starts outside the window: \(columnFrame)"
+ )
+ XCTAssertEqual(
+ columnFrame.height, visibleBounds.height, accuracy: 1,
+ "A split view column is taller than the window: \(columnFrame)"
+ )
+ }
+ }
+
+ func testDetailPaneReportsASizeIndependentOfItsContent() async throws {
+ let client = DelayedAPIClient()
+ client.articleContent = ArticleContent(
+ content: String(repeating: "Long article content.
", count: 2_000),
+ feedURL: "https://example.com"
+ )
+ let article = Article(
+ id: 1,
+ feedID: 1,
+ feedTitle: "Feed 1",
+ title: String(repeating: "Long article title ", count: 20),
+ url: "https://example.com/1",
+ publishedAt: "2026-08-16T08:00:00Z",
+ summary: String(repeating: "Long article summary. ", count: 400)
+ )
+ let viewModel = AppViewModel(api: client, autoLoad: false)
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 300, height: 200),
+ styleMask: [.titled, .closable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ let hostingView = NSHostingView(
+ rootView: ArticleDetailView(article: article, viewModel: viewModel)
+ )
+ window.contentView = hostingView
+ window.makeKeyAndOrderFront(nil)
+ hostingView.layoutSubtreeIfNeeded()
+ try await Task.sleep(for: .milliseconds(600))
+ hostingView.layoutSubtreeIfNeeded()
+
+ // A pane whose reported size follows the article text makes
+ // NavigationSplitView lay the window out around that size.
+ XCTAssertLessThanOrEqual(
+ hostingView.fittingSize.width,
+ hostingView.bounds.width,
+ "The detail pane asks for more width than it is given: \(hostingView.fittingSize)"
+ )
+ XCTAssertLessThanOrEqual(
+ hostingView.fittingSize.height,
+ hostingView.bounds.height,
+ "The detail pane asks for more height than it is given: \(hostingView.fittingSize)"
+ )
+ }
+
+ private func firstDescendant(of type: View.Type, in root: NSView) -> View? {
+ if let match = root as? View {
+ return match
+ }
+ for subview in root.subviews {
+ if let match = firstDescendant(of: type, in: subview) {
+ return match
+ }
+ }
+ return nil
+ }
+}
diff --git a/frontend-swift/build-app.sh b/frontend-swift/build-app.sh
new file mode 100755
index 00000000..9566f785
--- /dev/null
+++ b/frontend-swift/build-app.sh
@@ -0,0 +1,90 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+SCRIPT_DIR="${0:A:h}"
+PROJECT_DIR="${SCRIPT_DIR:h}"
+VERSION="${1:-dev}"
+DIST_DIR="${SCRIPT_DIR}/dist"
+APP_DIR="${DIST_DIR}/MrRSS-SwiftUI.app"
+CONTENTS_DIR="${APP_DIR}/Contents"
+MACOS_DIR="${CONTENTS_DIR}/MacOS"
+RESOURCES_DIR="${CONTENTS_DIR}/Resources"
+SWIFT_BUILD_DIR="${SCRIPT_DIR}/.build/release-universal"
+BACKEND_BUILD_DIR="${SCRIPT_DIR}/.build/backend-universal"
+CLANG_CACHE_DIR="${SCRIPT_DIR}/.build/clang-module-cache"
+DMG_PATH="${DIST_DIR}/MrRSS-${VERSION}-macos-swiftui-universal.dmg"
+MRRSS_ARCH_STRING="${MRRSS_BUILD_ARCHS:-arm64 x86_64}"
+MRRSS_ARCHES=("${(@s: :)MRRSS_ARCH_STRING}")
+SWIFT_ARCH_ARGUMENTS=()
+MRRSS_BUNDLE_VERSION="${VERSION%%-*}"
+
+for MRRSS_ARCH in "${MRRSS_ARCHES[@]}"; do
+ SWIFT_ARCH_ARGUMENTS+=(--arch "${MRRSS_ARCH}")
+done
+
+if [[ ! -d "${PROJECT_DIR}/frontend/dist" ]]; then
+ echo "frontend/dist is required because the Go backend embeds those files." >&2
+ echo "Run npm ci and npm run build in frontend first." >&2
+ exit 1
+fi
+
+mkdir -p "${DIST_DIR}" "${SWIFT_BUILD_DIR}" "${BACKEND_BUILD_DIR}" "${CLANG_CACHE_DIR}"
+rm -rf "${APP_DIR}" "${DMG_PATH}"
+mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
+
+echo "Building the universal SwiftUI executable..."
+cd "${SCRIPT_DIR}"
+export CLANG_MODULE_CACHE_PATH="${CLANG_CACHE_DIR}"
+swift build \
+ --disable-sandbox \
+ -c release \
+ "${SWIFT_ARCH_ARGUMENTS[@]}" \
+ --scratch-path "${SWIFT_BUILD_DIR}"
+cp "${SWIFT_BUILD_DIR}/apple/Products/Release/MrRSS" "${MACOS_DIR}/MrRSS-SwiftUI"
+
+echo "Building the Go backend for ${MRRSS_ARCH_STRING}..."
+cd "${PROJECT_DIR}"
+MRRSS_BACKEND_BINARIES=()
+for MRRSS_ARCH in "${MRRSS_ARCHES[@]}"; do
+ if [[ "${MRRSS_ARCH}" == "x86_64" ]]; then
+ MRRSS_GO_ARCH="amd64"
+ else
+ MRRSS_GO_ARCH="${MRRSS_ARCH}"
+ fi
+ MRRSS_BACKEND_PATH="${BACKEND_BUILD_DIR}/mrrss-server-${MRRSS_ARCH}"
+ CGO_ENABLED=0 GOOS=darwin GOARCH="${MRRSS_GO_ARCH}" go build \
+ -tags server \
+ -trimpath \
+ -ldflags="-s -w" \
+ -o "${MRRSS_BACKEND_PATH}" .
+ MRRSS_BACKEND_BINARIES+=("${MRRSS_BACKEND_PATH}")
+done
+
+if (( ${#MRRSS_BACKEND_BINARIES[@]} == 1 )); then
+ cp "${MRRSS_BACKEND_BINARIES[1]}" "${RESOURCES_DIR}/mrrss-server"
+else
+ lipo -create "${MRRSS_BACKEND_BINARIES[@]}" -output "${RESOURCES_DIR}/mrrss-server"
+fi
+chmod +x "${MACOS_DIR}/MrRSS-SwiftUI" "${RESOURCES_DIR}/mrrss-server"
+
+cp "${PROJECT_DIR}/build/darwin/icons.icns" "${RESOURCES_DIR}/AppIcon.icns"
+
+sed \
+ -e "s/@VERSION@/${VERSION}/g" \
+ -e "s/@BUILD_VERSION@/${MRRSS_BUNDLE_VERSION}/g" \
+ "${SCRIPT_DIR}/packaging/Info.plist" > "${CONTENTS_DIR}/Info.plist"
+
+codesign --force --sign - "${RESOURCES_DIR}/mrrss-server"
+codesign --force --deep --sign - "${APP_DIR}"
+
+echo "Creating ${DMG_PATH:t}..."
+hdiutil create \
+ -volname "MrRSS SwiftUI" \
+ -srcfolder "${APP_DIR}" \
+ -ov \
+ -format UDZO \
+ "${DMG_PATH}"
+
+echo "Created ${APP_DIR}"
+echo "Created ${DMG_PATH}"
diff --git a/frontend-swift/packaging/Info.plist b/frontend-swift/packaging/Info.plist
new file mode 100644
index 00000000..f83e05e0
--- /dev/null
+++ b/frontend-swift/packaging/Info.plist
@@ -0,0 +1,39 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ MrRSS-SwiftUI
+ CFBundleIconFile
+ AppIcon
+ CFBundleIdentifier
+ com.devxdojo.mrrss.swiftui
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ MrRSS
+ CFBundleDisplayName
+ MrRSS
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ @VERSION@
+ CFBundleVersion
+ @BUILD_VERSION@
+ LSApplicationCategoryType
+ public.app-category.news
+ LSMinimumSystemVersion
+ 14.0
+ NSHighResolutionCapable
+
+ NSPrincipalClass
+ NSApplication
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+
+
diff --git a/frontend-swift/run.sh b/frontend-swift/run.sh
new file mode 100755
index 00000000..03418d7c
--- /dev/null
+++ b/frontend-swift/run.sh
@@ -0,0 +1,49 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+SCRIPT_DIR="${0:A:h}"
+PROJECT_DIR="${SCRIPT_DIR:h}"
+BACKEND_DIR="${SCRIPT_DIR}/.build/backend"
+BACKEND_BIN="${BACKEND_DIR}/mrrss-server"
+SERVER_URL="http://127.0.0.1:1234/api/version"
+BACKEND_PID=""
+
+cleanup() {
+ if [[ -n "${BACKEND_PID}" ]]; then
+ kill "${BACKEND_PID}" 2>/dev/null || true
+ wait "${BACKEND_PID}" 2>/dev/null || true
+ fi
+}
+
+trap cleanup EXIT INT TERM
+
+if ! curl --silent --fail --max-time 1 "${SERVER_URL}" >/dev/null 2>&1; then
+ mkdir -p "${BACKEND_DIR}"
+ echo "Building the MrRSS backend..."
+ (cd "${PROJECT_DIR}" && go build -tags server -o "${BACKEND_BIN}" .)
+
+ echo "Starting the MrRSS backend on 127.0.0.1:1234..."
+ "${BACKEND_BIN}" -host 127.0.0.1 -port 1234 &
+ BACKEND_PID=$!
+
+ for _ in {1..100}; do
+ if curl --silent --fail --max-time 1 "${SERVER_URL}" >/dev/null 2>&1; then
+ break
+ fi
+ if ! kill -0 "${BACKEND_PID}" 2>/dev/null; then
+ echo "The MrRSS backend exited before it became ready." >&2
+ exit 1
+ fi
+ sleep 0.1
+ done
+
+ if ! curl --silent --fail --max-time 1 "${SERVER_URL}" >/dev/null 2>&1; then
+ echo "The MrRSS backend did not become ready within 10 seconds." >&2
+ exit 1
+ fi
+fi
+
+echo "Starting the MrRSS SwiftUI frontend..."
+cd "${SCRIPT_DIR}"
+swift run MrRSS
From eede0b812b7f436c1163e6c8c04e3af3206d8153 Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Mon, 24 Aug 2026 20:05:58 +0800
Subject: [PATCH 08/59] fix(translation): make the free Google provider more
reliable
---
internal/translation/dynamic.go | 4 +-
internal/translation/google.go | 100 +++++++++++++++++-
.../translation/translation_providers_test.go | 36 +++++++
3 files changed, 137 insertions(+), 3 deletions(-)
diff --git a/internal/translation/dynamic.go b/internal/translation/dynamic.go
index 0651f743..c667f998 100644
--- a/internal/translation/dynamic.go
+++ b/internal/translation/dynamic.go
@@ -121,7 +121,7 @@ func (t *DynamicTranslator) getTranslatorWithProvider() (Translator, string, err
var translator Translator
switch provider {
case "google":
- translator = NewGoogleFreeTranslator()
+ translator = NewGoogleFreeTranslatorWithDB(t.settings)
case "deepl":
// For deeplx self-hosted, endpoint is required but API key is optional
if endpoint == "" && apiKey == "" {
@@ -151,7 +151,7 @@ func (t *DynamicTranslator) getTranslatorWithProvider() (Translator, string, err
}
translator = aiTranslator
default:
- translator = NewGoogleFreeTranslator()
+ translator = NewGoogleFreeTranslatorWithDB(t.settings)
}
// Cache the translator
diff --git a/internal/translation/google.go b/internal/translation/google.go
index 0ec15ce6..956df6f6 100644
--- a/internal/translation/google.go
+++ b/internal/translation/google.go
@@ -5,7 +5,9 @@ import (
"fmt"
"net/http"
"net/url"
+ "strings"
"time"
+ "unicode"
)
type GoogleFreeTranslator struct {
@@ -39,6 +41,31 @@ func (t *GoogleFreeTranslator) Translate(text, targetLang string) (string, error
if text == "" {
return "", nil
}
+ if textAlreadyMatchesTarget(text, targetLang) {
+ return text, nil
+ }
+
+ translated, err := t.translateWithGoogle(text, targetLang)
+ if err != nil {
+ return "", fmt.Errorf("google translation service is unavailable: %s", sanitizedTranslationError(err))
+ }
+ return translated, nil
+}
+
+func (t *GoogleFreeTranslator) translateWithGoogle(text, targetLang string) (string, error) {
+ chunks := splitTranslationText(text, 1_800)
+ translatedChunks := make([]string, 0, len(chunks))
+ for _, chunk := range chunks {
+ translated, err := t.translateGoogleChunk(chunk, targetLang)
+ if err != nil {
+ return "", err
+ }
+ translatedChunks = append(translatedChunks, translated)
+ }
+ return strings.Join(translatedChunks, ""), nil
+}
+
+func (t *GoogleFreeTranslator) translateGoogleChunk(text, targetLang string) (string, error) {
// Get the configured endpoint, default to translate.googleapis.com
endpoint := "translate.googleapis.com"
@@ -74,7 +101,12 @@ func (t *GoogleFreeTranslator) Translate(text, targetLang string) (string, error
q.Set("q", text)
u.RawQuery = q.Encode()
- resp, err := t.client.Get(u.String())
+ req, err := http.NewRequest(http.MethodGet, u.String(), nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("User-Agent", "Mozilla/5.0")
+ resp, err := t.client.Do(req)
if err != nil {
return "", err
}
@@ -109,3 +141,69 @@ func (t *GoogleFreeTranslator) Translate(text, targetLang string) (string, error
return "", fmt.Errorf("invalid response format")
}
+
+func textAlreadyMatchesTarget(text, targetLang string) bool {
+ var letters, cjk, latin int
+ for _, r := range text {
+ if unicode.IsLetter(r) {
+ letters++
+ if unicode.In(r, unicode.Han) {
+ cjk++
+ } else if unicode.In(r, unicode.Latin) {
+ latin++
+ }
+ }
+ }
+ if letters == 0 {
+ return true
+ }
+ switch strings.ToLower(targetLang) {
+ case "zh", "zh-cn", "zh-tw", "zh-hans", "zh-hant":
+ return cjk*100/letters >= 70
+ case "en", "en-us", "en-gb":
+ return latin*100/letters >= 95
+ default:
+ return false
+ }
+}
+
+func splitTranslationText(text string, maxBytes int) []string {
+ if len([]byte(text)) <= maxBytes {
+ return []string{text}
+ }
+
+ chunks := make([]string, 0, len(text)/maxBytes+1)
+ var builder strings.Builder
+ currentBytes := 0
+ for _, r := range text {
+ runeBytes := len(string(r))
+ if currentBytes > 0 && currentBytes+runeBytes > maxBytes {
+ chunks = append(chunks, builder.String())
+ builder.Reset()
+ currentBytes = 0
+ }
+ builder.WriteRune(r)
+ currentBytes += runeBytes
+ }
+ if builder.Len() > 0 {
+ chunks = append(chunks, builder.String())
+ }
+ return chunks
+}
+
+func sanitizedTranslationError(err error) string {
+ if err == nil {
+ return "unknown error"
+ }
+ message := err.Error()
+ if strings.Contains(message, "context deadline exceeded") || strings.Contains(message, "Client.Timeout") {
+ return "request timed out; configure the proxy or select another translation provider"
+ }
+ if strings.Contains(message, "no such host") {
+ return "service address could not be resolved; configure the proxy or select another translation provider"
+ }
+ if len(message) > 160 {
+ return message[:160]
+ }
+ return message
+}
diff --git a/internal/translation/translation_providers_test.go b/internal/translation/translation_providers_test.go
index 5a0b5805..5a4da617 100644
--- a/internal/translation/translation_providers_test.go
+++ b/internal/translation/translation_providers_test.go
@@ -1,6 +1,7 @@
package translation
import (
+ "context"
"io"
"net/http"
"strings"
@@ -109,3 +110,38 @@ func TestAITranslate_AutoDetectOllama(t *testing.T) {
t.Fatalf("expected 2 API calls (OpenAI then Ollama), got %d", callCount)
}
}
+
+func TestGoogleTranslateSkipsRequestWhenTextMatchesTargetLanguage(t *testing.T) {
+ translator := NewGoogleFreeTranslator()
+ requestCount := 0
+ translator.client = &http.Client{Transport: rtFunc(func(req *http.Request) (*http.Response, error) {
+ requestCount++
+ return nil, context.DeadlineExceeded
+ })}
+
+ translated, err := translator.Translate("这里是中文内容", "zh")
+ if err != nil {
+ t.Fatalf("expected same-language translation to succeed: %v", err)
+ }
+ if translated != "这里是中文内容" {
+ t.Fatalf("expected unchanged text, got %q", translated)
+ }
+ if requestCount != 0 {
+ t.Fatalf("expected no network requests, got %d", requestCount)
+ }
+}
+
+func TestGoogleTranslateReturnsSanitizedTimeout(t *testing.T) {
+ translator := NewGoogleFreeTranslator()
+ translator.client = &http.Client{Transport: rtFunc(func(req *http.Request) (*http.Response, error) {
+ return nil, context.DeadlineExceeded
+ })}
+
+ _, err := translator.Translate("Hello", "zh")
+ if err == nil {
+ t.Fatal("expected translation to fail")
+ }
+ if strings.Contains(err.Error(), "q=Hello") || !strings.Contains(err.Error(), "configure the proxy") {
+ t.Fatalf("expected a concise configuration hint without source text, got %q", err.Error())
+ }
+}
From b519a1be5680b5c8bebc96e01b534e7e662af427 Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Mon, 24 Aug 2026 20:36:55 +0800
Subject: [PATCH 09/59] fix(macos): show the app in the Dock and enable the
Edit shortcuts
---
frontend-swift/Sources/MrRSSApp.swift | 2 +-
...BackendManager.swift => AppDelegate.swift} | 37 ++++++++++++++++++-
frontend-swift/Tests/AppDelegateTests.swift | 19 ++++++++++
3 files changed, 56 insertions(+), 2 deletions(-)
rename frontend-swift/Sources/Services/{BackendManager.swift => AppDelegate.swift} (52%)
create mode 100644 frontend-swift/Tests/AppDelegateTests.swift
diff --git a/frontend-swift/Sources/MrRSSApp.swift b/frontend-swift/Sources/MrRSSApp.swift
index de61b60a..19ed8345 100644
--- a/frontend-swift/Sources/MrRSSApp.swift
+++ b/frontend-swift/Sources/MrRSSApp.swift
@@ -2,7 +2,7 @@ import SwiftUI
@main
struct MrRSSApp: App {
- @NSApplicationDelegateAdaptor(BackendManager.self) private var backendManager
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var viewModel = AppViewModel(autoLoad: false)
var body: some Scene {
diff --git a/frontend-swift/Sources/Services/BackendManager.swift b/frontend-swift/Sources/Services/AppDelegate.swift
similarity index 52%
rename from frontend-swift/Sources/Services/BackendManager.swift
rename to frontend-swift/Sources/Services/AppDelegate.swift
index 11d6d552..eb61dff8 100644
--- a/frontend-swift/Sources/Services/BackendManager.swift
+++ b/frontend-swift/Sources/Services/AppDelegate.swift
@@ -1,10 +1,11 @@
import AppKit
import Foundation
-final class BackendManager: NSObject, NSApplicationDelegate {
+final class AppDelegate: NSObject, NSApplicationDelegate {
private var process: Process?
func applicationDidFinishLaunching(_ notification: Notification) {
+ presentAsForegroundApplication()
startBundledBackendIfNeeded()
}
@@ -12,6 +13,40 @@ final class BackendManager: NSObject, NSApplicationDelegate {
stopBundledBackend()
}
+ /// A SwiftPM executable is not an application bundle, so AppKit launches it
+ /// with the `.prohibited` activation policy. Such a process has no Dock icon
+ /// and never owns the menu bar, which also leaves the standard Edit
+ /// shortcuts such as Command-V and Command-A without any effect inside text
+ /// fields. The packaged application already launches as a regular
+ /// application and is left untouched.
+ private func presentAsForegroundApplication() {
+ guard NSApp.activationPolicy() != .regular else { return }
+
+ NSApp.setActivationPolicy(.regular)
+ if let iconURL = AppDelegate.repositoryIconURL(startingAt: Bundle.main.bundleURL),
+ let icon = NSImage(contentsOf: iconURL) {
+ NSApp.applicationIconImage = icon
+ }
+ NSApp.activate()
+ }
+
+ /// Looks for the repository icon by walking up from the executable, so an
+ /// unbundled run shows the real icon instead of a generic placeholder.
+ static func repositoryIconURL(startingAt directory: URL) -> URL? {
+ var current = directory
+ for _ in 0..<8 {
+ let candidate = current.appendingPathComponent("build/darwin/icons.icns")
+ if FileManager.default.fileExists(atPath: candidate.path) {
+ return candidate
+ }
+
+ let parent = current.deletingLastPathComponent()
+ guard parent.path != current.path else { return nil }
+ current = parent
+ }
+ return nil
+ }
+
private func startBundledBackendIfNeeded() {
let baseURL = ServerConfiguration.savedBaseURL
guard ["127.0.0.1", "localhost"].contains(baseURL.host?.lowercased() ?? ""),
diff --git a/frontend-swift/Tests/AppDelegateTests.swift b/frontend-swift/Tests/AppDelegateTests.swift
new file mode 100644
index 00000000..eb3c8248
--- /dev/null
+++ b/frontend-swift/Tests/AppDelegateTests.swift
@@ -0,0 +1,19 @@
+import AppKit
+import XCTest
+@testable import MrRSS
+
+final class AppDelegateTests: XCTestCase {
+ func testRepositoryIconIsFoundFromTheBuildDirectory() throws {
+ let url = try XCTUnwrap(
+ AppDelegate.repositoryIconURL(startingAt: Bundle(for: type(of: self)).bundleURL),
+ "The application icon should be reachable from the built executable."
+ )
+
+ XCTAssertEqual(url.lastPathComponent, "icons.icns")
+ XCTAssertNotNil(NSImage(contentsOf: url))
+ }
+
+ func testRepositoryIconLookupStopsAtTheFilesystemRoot() {
+ XCTAssertNil(AppDelegate.repositoryIconURL(startingAt: URL(fileURLWithPath: "/")))
+ }
+}
From 983fd64e44e4015aa7c2ae0c963c337f7ae6a2f9 Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Mon, 24 Aug 2026 20:40:16 +0800
Subject: [PATCH 10/59] fix(macos): give sidebar rows a visible selection
---
.../Sources/Views/SidebarView.swift | 43 ++++--------
frontend-swift/Tests/AppViewModelTests.swift | 3 +-
.../Tests/SidebarSelectionTests.swift | 66 +++++++++++++++++++
3 files changed, 79 insertions(+), 33 deletions(-)
create mode 100644 frontend-swift/Tests/SidebarSelectionTests.swift
diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift
index 7af6d1e6..6b29e081 100644
--- a/frontend-swift/Sources/Views/SidebarView.swift
+++ b/frontend-swift/Sources/Views/SidebarView.swift
@@ -7,13 +7,12 @@ struct SidebarView: View {
var body: some View {
VStack(spacing: 0) {
- List {
+ List(selection: $viewModel.selection) {
Section("Library") {
ForEach(ArticleFilter.allCases) { filter in
- sidebarButton(for: .filter(filter)) {
- Label(filter.title, systemImage: filter.icon)
- }
- .badge(badge(for: filter))
+ Label(filter.title, systemImage: filter.icon)
+ .badge(badge(for: filter))
+ .tag(SidebarItem.filter(filter))
}
}
@@ -31,15 +30,14 @@ struct SidebarView: View {
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.feeds) { feed in
- sidebarButton(for: .feed(feed.id)) {
- FeedLabel(feed: feed)
- }
- .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0)
- .contextMenu {
- Button("Delete Feed", systemImage: "trash", role: .destructive) {
- feedPendingDeletion = feed
+ FeedLabel(feed: feed)
+ .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0)
+ .tag(SidebarItem.feed(feed.id))
+ .contextMenu {
+ Button("Delete Feed", systemImage: "trash", role: .destructive) {
+ feedPendingDeletion = feed
+ }
}
- }
}
}
}
@@ -111,25 +109,6 @@ struct SidebarView: View {
0
}
}
-
- private func sidebarButton(
- for item: SidebarItem,
- @ViewBuilder label: () -> Label
- ) -> some View {
- Button {
- viewModel.selection = item
- } label: {
- label()
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .listRowBackground(
- viewModel.selection == item
- ? Color.accentColor.opacity(0.16)
- : Color.clear
- )
- }
}
private struct FeedLabel: View {
diff --git a/frontend-swift/Tests/AppViewModelTests.swift b/frontend-swift/Tests/AppViewModelTests.swift
index 7e2467e6..25455048 100644
--- a/frontend-swift/Tests/AppViewModelTests.swift
+++ b/frontend-swift/Tests/AppViewModelTests.swift
@@ -95,9 +95,10 @@ final class DelayedAPIClient: APIClient {
]
private(set) var lastReadMutation: ReadMutation?
var articleContent = ArticleContent(content: "", feedURL: nil)
+ var defaultFeeds: [Feed] = []
func checkConnection() async throws {}
- func fetchFeeds() async throws -> [Feed] { [] }
+ func fetchFeeds() async throws -> [Feed] { defaultFeeds }
func fetchUnreadCounts() async throws -> UnreadCounts { .empty }
func addFeed(url: String, title: String, category: String) async throws {}
func deleteFeed(id: Int) async throws {}
diff --git a/frontend-swift/Tests/SidebarSelectionTests.swift b/frontend-swift/Tests/SidebarSelectionTests.swift
new file mode 100644
index 00000000..4662e427
--- /dev/null
+++ b/frontend-swift/Tests/SidebarSelectionTests.swift
@@ -0,0 +1,66 @@
+import AppKit
+import SwiftUI
+import XCTest
+@testable import MrRSS
+
+@MainActor
+final class SidebarSelectionTests: XCTestCase {
+ func testSidebarMarksTheCurrentItemAsSelected() async throws {
+ let client = DelayedAPIClient()
+ client.defaultFeeds = [
+ Feed(id: 1, url: "https://example.com/1", title: "阮一峰的网络日志", category: ""),
+ Feed(id: 2, url: "https://example.com/2", title: "V2EX", category: "")
+ ]
+ let viewModel = AppViewModel(api: client, autoLoad: false)
+ viewModel.refreshFeeds()
+ try await Task.sleep(for: .milliseconds(250))
+ XCTAssertEqual(viewModel.feeds.count, 2)
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 260, height: 600),
+ styleMask: [.titled, .closable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ let hostingView = NSHostingView(rootView: SidebarView(viewModel: viewModel))
+ window.contentView = hostingView
+ window.makeKeyAndOrderFront(nil)
+ hostingView.layoutSubtreeIfNeeded()
+ try await Task.sleep(for: .milliseconds(500))
+
+ let tableView = try XCTUnwrap(
+ firstDescendant(of: NSTableView.self, in: hostingView),
+ "The sidebar should be backed by a table view."
+ )
+
+ // The default selection is the All Articles filter.
+ let defaultSelection = tableView.selectedRowIndexes
+ XCTAssertFalse(
+ defaultSelection.isEmpty,
+ "Clicking a sidebar row must leave the row visibly selected."
+ )
+
+ viewModel.selection = .feed(2)
+ try await Task.sleep(for: .milliseconds(400))
+ hostingView.layoutSubtreeIfNeeded()
+
+ XCTAssertFalse(tableView.selectedRowIndexes.isEmpty)
+ XCTAssertNotEqual(
+ tableView.selectedRowIndexes,
+ defaultSelection,
+ "Selecting another sidebar item must move the selection."
+ )
+ }
+
+ private func firstDescendant(of type: View.Type, in root: NSView) -> View? {
+ if let match = root as? View {
+ return match
+ }
+ for subview in root.subviews {
+ if let match = firstDescendant(of: type, in: subview) {
+ return match
+ }
+ }
+ return nil
+ }
+}
From d47d2fa95b87e5f6bc5292e5fc4ca0e34da69bba Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Mon, 24 Aug 2026 20:54:55 +0800
Subject: [PATCH 11/59] fix(macos): lay out the Add Feed sheet without
scrolling or crowding
---
.../Sources/Views/AddFeedView.swift | 25 +++++++---
frontend-swift/Tests/AddFeedLayoutTests.swift | 50 +++++++++++++++++++
2 files changed, 69 insertions(+), 6 deletions(-)
create mode 100644 frontend-swift/Tests/AddFeedLayoutTests.swift
diff --git a/frontend-swift/Sources/Views/AddFeedView.swift b/frontend-swift/Sources/Views/AddFeedView.swift
index 943c6860..f049d032 100644
--- a/frontend-swift/Sources/Views/AddFeedView.swift
+++ b/frontend-swift/Sources/Views/AddFeedView.swift
@@ -18,12 +18,14 @@ struct AddFeedView: View {
.foregroundStyle(.secondary)
}
- Form {
- TextField("Feed URL", text: $url, prompt: Text("https://example.com/feed.xml"))
- TextField("Title (optional)", text: $title)
- TextField("Category (optional)", text: $category)
+ // The label sits above the field rather than beside it, so a long
+ // address keeps the full width of the sheet instead of scrolling
+ // out of a narrow field next to its label.
+ VStack(alignment: .leading, spacing: 14) {
+ field("Feed URL", text: $url, prompt: "https://example.com/feed.xml")
+ field("Title (optional)", text: $title, prompt: "Feed title")
+ field("Category (optional)", text: $category, prompt: "Category name")
}
- .formStyle(.grouped)
HStack {
Spacer()
@@ -38,7 +40,7 @@ struct AddFeedView: View {
}
}
.padding(24)
- .frame(width: 500, height: 300)
+ .frame(width: 520)
.overlay {
if isSubmitting {
ZStack {
@@ -51,6 +53,17 @@ struct AddFeedView: View {
}
}
+ private func field(_ label: String, text: Binding, prompt: String) -> some View {
+ VStack(alignment: .leading, spacing: 5) {
+ Text(label)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ TextField(label, text: text, prompt: Text(prompt))
+ .textFieldStyle(.roundedBorder)
+ .labelsHidden()
+ }
+ }
+
private func submit() {
isSubmitting = true
Task {
diff --git a/frontend-swift/Tests/AddFeedLayoutTests.swift b/frontend-swift/Tests/AddFeedLayoutTests.swift
new file mode 100644
index 00000000..7b5c49fa
--- /dev/null
+++ b/frontend-swift/Tests/AddFeedLayoutTests.swift
@@ -0,0 +1,50 @@
+import AppKit
+import SwiftUI
+import XCTest
+@testable import MrRSS
+
+@MainActor
+final class AddFeedLayoutTests: XCTestCase {
+ func testSheetShowsEveryFieldAtFullWidthWithoutScrolling() async throws {
+ let viewModel = AppViewModel(api: DelayedAPIClient(), autoLoad: false)
+ let hostingView = NSHostingView(rootView: AddFeedView(viewModel: viewModel))
+ let contentSize = hostingView.fittingSize
+ hostingView.frame = NSRect(origin: .zero, size: contentSize)
+
+ let window = NSWindow(
+ contentRect: hostingView.frame,
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ window.contentView = hostingView
+ window.makeKeyAndOrderFront(nil)
+ hostingView.layoutSubtreeIfNeeded()
+ try await Task.sleep(for: .milliseconds(300))
+
+ XCTAssertTrue(
+ descendants(of: NSScrollView.self, in: hostingView).isEmpty,
+ "A three field sheet should size itself to its content instead of scrolling."
+ )
+
+ let fields = descendants(of: NSTextField.self, in: hostingView).filter(\.isEditable)
+ XCTAssertEqual(fields.count, 3)
+
+ // Each field spans the sheet minus its padding, so a long address never
+ // runs into a label placed beside it.
+ for field in fields {
+ XCTAssertEqual(field.frame.width, contentSize.width - 48, accuracy: 1)
+ }
+ }
+
+ private func descendants(of type: View.Type, in root: NSView) -> [View] {
+ var found: [View] = []
+ if let match = root as? View {
+ found.append(match)
+ }
+ for subview in root.subviews {
+ found.append(contentsOf: descendants(of: type, in: subview))
+ }
+ return found
+ }
+}
From c0f606832df5cc7c4e0ba8797a00743fcef91f19 Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Mon, 24 Aug 2026 21:17:20 +0800
Subject: [PATCH 12/59] feat(macos): group subscriptions into folders and
colour their icons
---
frontend-swift/README.md | 4 +-
frontend-swift/Sources/Models/Models.swift | 2 +-
.../Sources/Services/APIService.swift | 10 +-
.../Sources/ViewModels/AppViewModel.swift | 145 ++++++++-
.../Sources/Views/ArticleListView.swift | 2 +
.../Sources/Views/FeedAccentColor.swift | 42 +++
.../Sources/Views/FolderNameView.swift | 64 ++++
.../Sources/Views/SidebarView.swift | 307 ++++++++++++++----
frontend-swift/Tests/AppViewModelTests.swift | 12 +-
.../Tests/FeedAccentColorTests.swift | 48 +++
frontend-swift/Tests/FolderTests.swift | 145 +++++++++
.../handlers/feed/category_handlers_test.go | 68 ++++
internal/handlers/feed/feed_handlers.go | 33 ++
internal/routes/feed_routes.go | 3 +
14 files changed, 805 insertions(+), 80 deletions(-)
create mode 100644 frontend-swift/Sources/Views/FeedAccentColor.swift
create mode 100644 frontend-swift/Sources/Views/FolderNameView.swift
create mode 100644 frontend-swift/Tests/FeedAccentColorTests.swift
create mode 100644 frontend-swift/Tests/FolderTests.swift
create mode 100644 internal/handlers/feed/category_handlers_test.go
diff --git a/frontend-swift/README.md b/frontend-swift/README.md
index f5ed806f..bd080662 100644
--- a/frontend-swift/README.md
+++ b/frontend-swift/README.md
@@ -58,4 +58,6 @@ cd ../frontend-swift
The release application contains both the SwiftUI frontend and reusable Go backend, so it can launch without a separately installed server.
-The application provides native three-column navigation, feed subscription management, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content.
+The application provides native three-column navigation, feed subscription management, folders for grouping subscriptions, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content.
+
+Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it.
diff --git a/frontend-swift/Sources/Models/Models.swift b/frontend-swift/Sources/Models/Models.swift
index 5e677ba8..5ebd3efb 100644
--- a/frontend-swift/Sources/Models/Models.swift
+++ b/frontend-swift/Sources/Models/Models.swift
@@ -4,7 +4,7 @@ struct Feed: Identifiable, Codable, Hashable {
let id: Int
let url: String
let title: String
- let category: String
+ var category: String
let lastUpdated: String
let iconURL: String?
diff --git a/frontend-swift/Sources/Services/APIService.swift b/frontend-swift/Sources/Services/APIService.swift
index 010f770e..cf067ffa 100644
--- a/frontend-swift/Sources/Services/APIService.swift
+++ b/frontend-swift/Sources/Services/APIService.swift
@@ -73,6 +73,7 @@ protocol APIClient: AnyObject {
func fetchUnreadCounts() async throws -> UnreadCounts
func addFeed(url: String, title: String, category: String) async throws
func deleteFeed(id: Int) async throws
+ func updateFeedCategory(id: Int, category: String) async throws
func refreshAllFeeds() async throws
func fetchRefreshProgress() async throws -> RefreshProgress
func fetchArticles(
@@ -146,6 +147,13 @@ final class APIService: APIClient {
)
}
+ func updateFeedCategory(id: Int, category: String) async throws {
+ try await post(
+ "feeds/category",
+ jsonBody: ["id": id, "category": category]
+ )
+ }
+
func refreshAllFeeds() async throws {
try await post("refresh")
}
@@ -309,7 +317,7 @@ final class APIService: APIClient {
private func post(
_ endpoint: String,
queryItems: [URLQueryItem] = [],
- jsonBody: [String: String]? = nil
+ jsonBody: [String: Any]? = nil
) async throws {
let url = try makeURL(endpoint: endpoint, queryItems: queryItems)
var request = URLRequest(url: url)
diff --git a/frontend-swift/Sources/ViewModels/AppViewModel.swift b/frontend-swift/Sources/ViewModels/AppViewModel.swift
index 656feb44..e2e48094 100644
--- a/frontend-swift/Sources/ViewModels/AppViewModel.swift
+++ b/frontend-swift/Sources/ViewModels/AppViewModel.swift
@@ -30,6 +30,7 @@ enum ArticleFilter: String, CaseIterable, Identifiable {
enum SidebarItem: Hashable {
case filter(ArticleFilter)
+ case folder(String)
case feed(Int)
}
@@ -58,6 +59,7 @@ enum ConnectionState: Equatable {
@MainActor
final class AppViewModel: ObservableObject {
@Published private(set) var feeds: [Feed] = []
+ @Published private(set) var folders: [String] = []
@Published private(set) var articles: [Article] = []
@Published private(set) var unreadCounts = UnreadCounts.empty
@Published private(set) var isLoadingArticles = false
@@ -86,16 +88,24 @@ final class AppViewModel: ObservableObject {
private var hasMore = true
private let limit: Int
private var api: APIClient
+ private let defaults: UserDefaults
private var feedTask: Task?
private var sourceRefreshTask: Task?
private var feedRequestID = UUID()
private var articleTask: Task?
private var articleRequestID = UUID()
- init(api: APIClient = APIService.shared, limit: Int = 50, autoLoad: Bool = true) {
+ init(
+ api: APIClient = APIService.shared,
+ limit: Int = 50,
+ autoLoad: Bool = true,
+ defaults: UserDefaults = .standard
+ ) {
self.api = api
self.limit = limit
+ self.defaults = defaults
serverURLText = api.baseURL.absoluteString
+ refreshFolders()
if autoLoad {
refreshAll()
@@ -143,6 +153,7 @@ final class AppViewModel: ObservableObject {
try Task.checkCancellation()
guard requestID == feedRequestID else { return }
feeds = loadedFeeds
+ refreshFolders()
unreadCounts = loadedCounts
connectionState = .connected
isLoadingFeeds = false
@@ -222,6 +233,126 @@ final class AppViewModel: ObservableObject {
}
}
+
+ // MARK: - Folders
+
+ /// A folder is the category stored on each feed. A folder that somebody
+ /// created but has not filled yet has nowhere to live on the server, so its
+ /// name is remembered on this Mac until a feed moves into it.
+ private static let pendingFoldersKey = "MrRSS.pendingFolders"
+
+ private var pendingFolders: Set {
+ get { Set(defaults.stringArray(forKey: Self.pendingFoldersKey) ?? []) }
+ set { defaults.set(newValue.sorted(), forKey: Self.pendingFoldersKey) }
+ }
+
+ func feeds(inFolder folder: String) -> [Feed] {
+ feeds.filter { $0.category == folder }
+ }
+
+ var unfiledFeeds: [Feed] {
+ feeds.filter { $0.category.isEmpty }
+ }
+
+ func unreadCount(forFolder folder: String) -> Int {
+ feeds(inFolder: folder).reduce(0) { $0 + (unreadCounts.feedCounts[$1.id] ?? 0) }
+ }
+
+ @discardableResult
+ func createFolder(named name: String) -> Bool {
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ errorMessage = "Enter a folder name."
+ return false
+ }
+ guard !folders.contains(trimmed) else {
+ errorMessage = "A folder named \(trimmed) already exists."
+ return false
+ }
+
+ pendingFolders.insert(trimmed)
+ refreshFolders()
+ return true
+ }
+
+ func moveFeed(_ feed: Feed, toFolder folder: String?) async {
+ let target = (folder ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ guard feed.category != target else { return }
+
+ do {
+ try await api.updateFeedCategory(id: feed.id, category: target)
+ } catch {
+ errorMessage = error.localizedDescription
+ return
+ }
+
+ if let index = feeds.firstIndex(where: { $0.id == feed.id }) {
+ feeds[index].category = target
+ }
+ refreshFolders()
+ }
+
+ func renameFolder(_ folder: String, to newName: String) async {
+ let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, trimmed != folder else { return }
+ guard !folders.contains(trimmed) else {
+ errorMessage = "A folder named \(trimmed) already exists."
+ return
+ }
+
+ for feed in feeds(inFolder: folder) {
+ do {
+ try await api.updateFeedCategory(id: feed.id, category: trimmed)
+ } catch {
+ errorMessage = error.localizedDescription
+ refreshFeeds()
+ return
+ }
+ }
+
+ for index in feeds.indices where feeds[index].category == folder {
+ feeds[index].category = trimmed
+ }
+ var stored = pendingFolders
+ stored.remove(folder)
+ stored.insert(trimmed)
+ pendingFolders = stored
+ if selection == .folder(folder) {
+ selection = .folder(trimmed)
+ }
+ refreshFolders()
+ }
+
+ /// Removes the folder itself. The feeds it held stay subscribed and move
+ /// back out of any folder.
+ func deleteFolder(_ folder: String) async {
+ for feed in feeds(inFolder: folder) {
+ do {
+ try await api.updateFeedCategory(id: feed.id, category: "")
+ } catch {
+ errorMessage = error.localizedDescription
+ refreshFeeds()
+ return
+ }
+ }
+
+ for index in feeds.indices where feeds[index].category == folder {
+ feeds[index].category = ""
+ }
+ pendingFolders.remove(folder)
+ if selection == .folder(folder) {
+ selection = .filter(.all)
+ }
+ refreshFolders()
+ }
+
+ private func refreshFolders() {
+ let assigned = Set(feeds.map(\.category).filter { !$0.isEmpty })
+ let stored = pendingFolders.subtracting(assigned)
+ pendingFolders = stored
+ folders = assigned.union(stored).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
+ }
+
func reloadArticles() {
articleTask?.cancel()
articleRequestID = UUID()
@@ -484,7 +615,7 @@ final class AppViewModel: ObservableObject {
do {
let loadedArticles = try await api.fetchArticles(
feedID: query.feedID,
- category: nil,
+ category: query.category,
filter: query.filter,
page: targetPage,
limit: limit
@@ -514,14 +645,16 @@ final class AppViewModel: ObservableObject {
}
}
- private var articleQuery: (feedID: Int?, filter: String) {
+ private var articleQuery: (feedID: Int?, category: String?, filter: String) {
switch selection {
case .filter(let filter):
- return (nil, filter.rawValue)
+ return (nil, nil, filter.rawValue)
+ case .folder(let name):
+ return (nil, name, "")
case .feed(let id):
- return (id, "")
+ return (id, nil, "")
case .none:
- return (nil, ArticleFilter.all.rawValue)
+ return (nil, nil, ArticleFilter.all.rawValue)
}
}
}
diff --git a/frontend-swift/Sources/Views/ArticleListView.swift b/frontend-swift/Sources/Views/ArticleListView.swift
index 7e3da44d..ce5b8e03 100644
--- a/frontend-swift/Sources/Views/ArticleListView.swift
+++ b/frontend-swift/Sources/Views/ArticleListView.swift
@@ -82,6 +82,8 @@ struct ArticleListView: View {
switch viewModel.selection {
case .filter(let filter):
filter.title
+ case .folder(let name):
+ name
case .feed(let id):
viewModel.feeds.first(where: { $0.id == id })?.title ?? "Feed"
case .none:
diff --git a/frontend-swift/Sources/Views/FeedAccentColor.swift b/frontend-swift/Sources/Views/FeedAccentColor.swift
new file mode 100644
index 00000000..c4018132
--- /dev/null
+++ b/frontend-swift/Sources/Views/FeedAccentColor.swift
@@ -0,0 +1,42 @@
+import SwiftUI
+
+/// A stable colour for each subscription, so the sidebar reads as a list of
+/// distinguishable sources rather than a column of identical glyphs. The hues
+/// are spread around the wheel instead of being limited to the primaries.
+enum FeedAccentColor {
+ static let palette: [Color] = [
+ Color(red: 0.90, green: 0.26, blue: 0.28), // red
+ Color(red: 0.95, green: 0.48, blue: 0.16), // orange
+ Color(red: 0.93, green: 0.71, blue: 0.13), // amber
+ Color(red: 0.71, green: 0.75, blue: 0.16), // olive
+ Color(red: 0.30, green: 0.71, blue: 0.31), // green
+ Color(red: 0.16, green: 0.71, blue: 0.58), // teal
+ Color(red: 0.15, green: 0.68, blue: 0.80), // cyan
+ Color(red: 0.20, green: 0.51, blue: 0.89), // blue
+ Color(red: 0.33, green: 0.36, blue: 0.82), // indigo
+ Color(red: 0.55, green: 0.34, blue: 0.80), // violet
+ Color(red: 0.79, green: 0.31, blue: 0.68), // magenta
+ Color(red: 0.88, green: 0.36, blue: 0.48), // rose
+ Color(red: 0.58, green: 0.44, blue: 0.32), // brown
+ Color(red: 0.42, green: 0.53, blue: 0.60) // slate
+ ]
+
+ static func color(for feed: Feed) -> Color {
+ palette[index(for: identity(of: feed))]
+ }
+
+ static func index(for identity: String) -> Int {
+ // FNV-1a keeps the colour stable across launches, which Swift's own
+ // hashing does not guarantee because it is seeded per process.
+ var hash: UInt64 = 0xcbf2_9ce4_8422_2325
+ for byte in identity.utf8 {
+ hash ^= UInt64(byte)
+ hash = hash &* 0x1000_0000_01b3
+ }
+ return Int(hash % UInt64(palette.count))
+ }
+
+ private static func identity(of feed: Feed) -> String {
+ feed.url.isEmpty ? feed.title : feed.url
+ }
+}
diff --git a/frontend-swift/Sources/Views/FolderNameView.swift b/frontend-swift/Sources/Views/FolderNameView.swift
new file mode 100644
index 00000000..f9c89051
--- /dev/null
+++ b/frontend-swift/Sources/Views/FolderNameView.swift
@@ -0,0 +1,64 @@
+import SwiftUI
+
+struct FolderNameView: View {
+ let prompt: FolderPrompt
+ @ObservedObject var viewModel: AppViewModel
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var name = ""
+ @State private var isSubmitting = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 20) {
+ VStack(alignment: .leading, spacing: 5) {
+ Text(prompt.title)
+ .font(.title2.bold())
+ Text(prompt.message)
+ .foregroundStyle(.secondary)
+ }
+
+ VStack(alignment: .leading, spacing: 5) {
+ Text("Name")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ TextField("Name", text: $name, prompt: Text("Technology"))
+ .textFieldStyle(.roundedBorder)
+ .labelsHidden()
+ }
+
+ HStack {
+ Spacer()
+ Button("Cancel", role: .cancel) {
+ dismiss()
+ }
+ Button(prompt.confirmTitle) {
+ submit()
+ }
+ .keyboardShortcut(.defaultAction)
+ .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting)
+ }
+ }
+ .padding(24)
+ .frame(width: 420)
+ .onAppear { name = prompt.initialName }
+ }
+
+ private func submit() {
+ isSubmitting = true
+ Task {
+ switch prompt {
+ case .create:
+ if viewModel.createFolder(named: name) { dismiss() }
+ case .createWithFeed(let feed):
+ if viewModel.createFolder(named: name) {
+ await viewModel.moveFeed(feed, toFolder: name)
+ dismiss()
+ }
+ case .rename(let folder):
+ await viewModel.renameFolder(folder, to: name)
+ dismiss()
+ }
+ isSubmitting = false
+ }
+ }
+}
diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift
index 6b29e081..a05f0bfc 100644
--- a/frontend-swift/Sources/Views/SidebarView.swift
+++ b/frontend-swift/Sources/Views/SidebarView.swift
@@ -4,83 +4,24 @@ struct SidebarView: View {
@ObservedObject var viewModel: AppViewModel
@State private var isShowingAddFeed = false
@State private var feedPendingDeletion: Feed?
+ @State private var folderPendingDeletion: String?
+ @State private var expandedFolders: Set = []
+ @State private var folderPrompt: FolderPrompt?
var body: some View {
VStack(spacing: 0) {
- List(selection: $viewModel.selection) {
- Section("Library") {
- ForEach(ArticleFilter.allCases) { filter in
- Label(filter.title, systemImage: filter.icon)
- .badge(badge(for: filter))
- .tag(SidebarItem.filter(filter))
- }
- }
-
- Section("Feeds") {
- if viewModel.isLoadingFeeds && viewModel.feeds.isEmpty {
- HStack(spacing: 8) {
- ProgressView()
- .controlSize(.small)
- Text("Loading feeds")
- .foregroundStyle(.secondary)
- }
- } else if viewModel.feeds.isEmpty {
- Text("No feeds available")
- .font(.callout)
- .foregroundStyle(.secondary)
- } else {
- ForEach(viewModel.feeds) { feed in
- FeedLabel(feed: feed)
- .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0)
- .tag(SidebarItem.feed(feed.id))
- .contextMenu {
- Button("Delete Feed", systemImage: "trash", role: .destructive) {
- feedPendingDeletion = feed
- }
- }
- }
- }
- }
- }
- .listStyle(.sidebar)
-
+ feedList
Divider()
-
- HStack(spacing: 8) {
- Circle()
- .fill(viewModel.connectionState.color)
- .frame(width: 7, height: 7)
- Text(viewModel.connectionState.title)
- .font(.caption)
- .foregroundStyle(.secondary)
- Spacer()
- if viewModel.isLoadingFeeds || viewModel.isRefreshingSources {
- ProgressView()
- .controlSize(.mini)
- }
- }
- .padding(.horizontal, 12)
- .frame(height: 36)
- .background(.ultraThinMaterial)
+ statusBar
}
.navigationTitle("MrRSS")
- .toolbar {
- ToolbarItemGroup(placement: .primaryAction) {
- Button("Add feed", systemImage: "plus") {
- isShowingAddFeed = true
- }
- .help("Add a feed")
-
- Button("Refresh feeds", systemImage: "arrow.clockwise") {
- viewModel.refreshFromSources()
- }
- .disabled(viewModel.isRefreshingSources)
- .help("Refresh feeds and articles")
- }
- }
+ .toolbar { toolbarContent }
.sheet(isPresented: $isShowingAddFeed) {
AddFeedView(viewModel: viewModel)
}
+ .sheet(item: $folderPrompt) { prompt in
+ FolderNameView(prompt: prompt, viewModel: viewModel)
+ }
.confirmationDialog(
"Delete \(feedPendingDeletion?.title ?? "this feed")?",
isPresented: Binding(
@@ -99,6 +40,181 @@ struct SidebarView: View {
} message: {
Text("The subscription and its stored articles will be removed.")
}
+ .confirmationDialog(
+ "Delete the folder \(folderPendingDeletion ?? "")?",
+ isPresented: Binding(
+ get: { folderPendingDeletion != nil },
+ set: { if !$0 { folderPendingDeletion = nil } }
+ )
+ ) {
+ Button("Delete Folder", role: .destructive) {
+ guard let folder = folderPendingDeletion else { return }
+ folderPendingDeletion = nil
+ Task { await viewModel.deleteFolder(folder) }
+ }
+ Button("Cancel", role: .cancel) {
+ folderPendingDeletion = nil
+ }
+ } message: {
+ Text("The feeds it holds stay subscribed and move back out of any folder.")
+ }
+ }
+
+ private var feedList: some View {
+ List(selection: $viewModel.selection) {
+ Section("Library") {
+ ForEach(ArticleFilter.allCases) { filter in
+ Label(filter.title, systemImage: filter.icon)
+ .badge(badge(for: filter))
+ .tag(SidebarItem.filter(filter))
+ }
+ }
+
+ Section("Feeds") {
+ feedSection
+ }
+ }
+ .listStyle(.sidebar)
+ }
+
+ @ViewBuilder
+ private var feedSection: some View {
+ if viewModel.isLoadingFeeds && viewModel.feeds.isEmpty {
+ HStack(spacing: 8) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Loading feeds")
+ .foregroundStyle(.secondary)
+ }
+ } else if viewModel.feeds.isEmpty && viewModel.folders.isEmpty {
+ Text("No feeds available")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(viewModel.folders, id: \.self) { folder in
+ folderRow(folder)
+ }
+
+ ForEach(viewModel.unfiledFeeds) { feed in
+ feedRow(feed)
+ }
+ }
+ }
+
+ private var statusBar: some View {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(viewModel.connectionState.color)
+ .frame(width: 7, height: 7)
+ Text(viewModel.connectionState.title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Spacer()
+ if viewModel.isLoadingFeeds || viewModel.isRefreshingSources {
+ ProgressView()
+ .controlSize(.mini)
+ }
+ }
+ .padding(.horizontal, 12)
+ .frame(height: 36)
+ .background(.ultraThinMaterial)
+ }
+
+ @ToolbarContentBuilder
+ private var toolbarContent: some ToolbarContent {
+ ToolbarItemGroup(placement: .primaryAction) {
+ Menu {
+ Button("Add Feed…", systemImage: "plus") {
+ isShowingAddFeed = true
+ }
+ Button("New Folder…", systemImage: "folder.badge.plus") {
+ folderPrompt = .create
+ }
+ } label: {
+ Label("Add", systemImage: "plus")
+ }
+ .help("Add a feed or a folder")
+
+ Button("Refresh feeds", systemImage: "arrow.clockwise") {
+ viewModel.refreshFromSources()
+ }
+ .disabled(viewModel.isRefreshingSources)
+ .help("Refresh feeds and articles")
+ }
+ }
+
+ private func folderRow(_ folder: String) -> some View {
+ DisclosureGroup(
+ isExpanded: Binding(
+ get: { expandedFolders.contains(folder) },
+ set: { isExpanded in
+ if isExpanded {
+ expandedFolders.insert(folder)
+ } else {
+ expandedFolders.remove(folder)
+ }
+ }
+ )
+ ) {
+ ForEach(viewModel.feeds(inFolder: folder)) { feed in
+ feedRow(feed)
+ }
+ } label: {
+ Label {
+ Text(folder)
+ .lineLimit(1)
+ } icon: {
+ Image(systemName: expandedFolders.contains(folder) ? "folder.fill" : "folder")
+ .foregroundStyle(.tint)
+ }
+ .badge(viewModel.unreadCount(forFolder: folder))
+ .tag(SidebarItem.folder(folder))
+ .contextMenu {
+ Button("Rename Folder…", systemImage: "pencil") {
+ folderPrompt = .rename(folder)
+ }
+ Button("Delete Folder", systemImage: "trash", role: .destructive) {
+ folderPendingDeletion = folder
+ }
+ }
+ }
+ }
+
+ private func feedRow(_ feed: Feed) -> some View {
+ FeedLabel(feed: feed)
+ .badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0)
+ .tag(SidebarItem.feed(feed.id))
+ .contextMenu {
+ Menu("Move to Folder") {
+ ForEach(viewModel.folders, id: \.self) { folder in
+ Button(folder) {
+ Task { await viewModel.moveFeed(feed, toFolder: folder) }
+ }
+ .disabled(feed.category == folder)
+ }
+
+ if !viewModel.folders.isEmpty {
+ Divider()
+ }
+
+ Button("New Folder…") {
+ folderPrompt = .createWithFeed(feed)
+ }
+
+ if !feed.category.isEmpty {
+ Divider()
+ Button("Remove from Folder") {
+ Task { await viewModel.moveFeed(feed, toFolder: nil) }
+ }
+ }
+ }
+
+ Divider()
+
+ Button("Delete Feed", systemImage: "trash", role: .destructive) {
+ feedPendingDeletion = feed
+ }
+ }
}
private func badge(for filter: ArticleFilter) -> Int {
@@ -111,6 +227,52 @@ struct SidebarView: View {
}
}
+enum FolderPrompt: Identifiable {
+ case create
+ case createWithFeed(Feed)
+ case rename(String)
+
+ var id: String {
+ switch self {
+ case .create: "create"
+ case .createWithFeed(let feed): "create-\(feed.id)"
+ case .rename(let folder): "rename-\(folder)"
+ }
+ }
+
+ var title: String {
+ switch self {
+ case .create, .createWithFeed: "New Folder"
+ case .rename: "Rename Folder"
+ }
+ }
+
+ var message: String {
+ switch self {
+ case .create:
+ "Folders group your subscriptions in the sidebar."
+ case .createWithFeed(let feed):
+ "\(feed.title) will move into the new folder."
+ case .rename(let folder):
+ "Every feed in \(folder) moves to the new name."
+ }
+ }
+
+ var initialName: String {
+ switch self {
+ case .create, .createWithFeed: ""
+ case .rename(let folder): folder
+ }
+ }
+
+ var confirmTitle: String {
+ switch self {
+ case .create, .createWithFeed: "Create"
+ case .rename: "Rename"
+ }
+ }
+}
+
private struct FeedLabel: View {
let feed: Feed
@@ -126,13 +288,18 @@ private struct FeedLabel: View {
.resizable()
.scaledToFill()
} placeholder: {
- Image(systemName: "dot.radiowaves.left.and.right")
+ fallbackIcon
}
.frame(width: 16, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 3))
} else {
- Image(systemName: "dot.radiowaves.left.and.right")
+ fallbackIcon
}
}
}
+
+ private var fallbackIcon: some View {
+ Image(systemName: "dot.radiowaves.left.and.right")
+ .foregroundStyle(FeedAccentColor.color(for: feed))
+ }
}
diff --git a/frontend-swift/Tests/AppViewModelTests.swift b/frontend-swift/Tests/AppViewModelTests.swift
index 25455048..b5e461a2 100644
--- a/frontend-swift/Tests/AppViewModelTests.swift
+++ b/frontend-swift/Tests/AppViewModelTests.swift
@@ -96,12 +96,21 @@ final class DelayedAPIClient: APIClient {
private(set) var lastReadMutation: ReadMutation?
var articleContent = ArticleContent(content: "", feedURL: nil)
var defaultFeeds: [Feed] = []
+ private(set) var categoryMutations: [(id: Int, category: String)] = []
+ var categoryUpdateError: Error?
+ private(set) var lastArticleQuery: (feedID: Int?, category: String?, filter: String)?
func checkConnection() async throws {}
func fetchFeeds() async throws -> [Feed] { defaultFeeds }
- func fetchUnreadCounts() async throws -> UnreadCounts { .empty }
+ var unreadCounts = UnreadCounts.empty
+ func fetchUnreadCounts() async throws -> UnreadCounts { unreadCounts }
func addFeed(url: String, title: String, category: String) async throws {}
func deleteFeed(id: Int) async throws {}
+
+ func updateFeedCategory(id: Int, category: String) async throws {
+ if let categoryUpdateError { throw categoryUpdateError }
+ categoryMutations.append((id, category))
+ }
func refreshAllFeeds() async throws {}
func fetchRefreshProgress() async throws -> RefreshProgress {
RefreshProgress(isRunning: false)
@@ -114,6 +123,7 @@ final class DelayedAPIClient: APIClient {
page: Int,
limit: Int
) async throws -> [Article] {
+ lastArticleQuery = (feedID, category, filter)
if feedID == 2 {
try await Task.sleep(for: .milliseconds(10))
return [
diff --git a/frontend-swift/Tests/FeedAccentColorTests.swift b/frontend-swift/Tests/FeedAccentColorTests.swift
new file mode 100644
index 00000000..8dd1d227
--- /dev/null
+++ b/frontend-swift/Tests/FeedAccentColorTests.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+import XCTest
+@testable import MrRSS
+
+final class FeedAccentColorTests: XCTestCase {
+ func testThePaletteOffersMoreThanTheThreePrimaries() {
+ XCTAssertGreaterThanOrEqual(FeedAccentColor.palette.count, 12)
+ XCTAssertEqual(
+ Set(FeedAccentColor.palette.map { String(describing: $0) }).count,
+ FeedAccentColor.palette.count,
+ "Every entry in the palette should be a distinct colour."
+ )
+ }
+
+ func testAFeedKeepsTheSameColourAcrossLookups() {
+ let feed = Feed(id: 7, url: "https://example.com/feed", title: "Example", category: "")
+ let first = FeedAccentColor.color(for: feed)
+
+ // The colour is derived from the address, so renaming or renumbering a
+ // feed does not repaint it.
+ let renamed = Feed(id: 42, url: "https://example.com/feed", title: "Renamed", category: "Tech")
+
+ XCTAssertEqual(first, FeedAccentColor.color(for: feed))
+ XCTAssertEqual(first, FeedAccentColor.color(for: renamed))
+ }
+
+ func testTheHashIsStableAcrossProcessesAndStaysInRange() {
+ // A fixed expectation catches any switch to Swift's per-process hashing.
+ XCTAssertEqual(FeedAccentColor.index(for: "https://example.com/feed"), 6)
+
+ for identity in (0..<200).map({ "https://example.com/\($0)" }) {
+ let index = FeedAccentColor.index(for: identity)
+ XCTAssertTrue((0.. AppViewModel {
+ client.defaultFeeds = [
+ Feed(id: 1, url: "https://a.example.com/feed", title: "Feed A", category: ""),
+ Feed(id: 2, url: "https://b.example.com/feed", title: "Feed B", category: "Tech"),
+ Feed(id: 3, url: "https://c.example.com/feed", title: "Feed C", category: "Tech")
+ ]
+ let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults)
+ viewModel.refreshFeeds()
+ try await Task.sleep(for: .milliseconds(250))
+ return viewModel
+ }
+
+ func testFoldersComeFromTheCategoryOnEachFeed() async throws {
+ let viewModel = try await makeViewModel(DelayedAPIClient())
+
+ XCTAssertEqual(viewModel.folders, ["Tech"])
+ XCTAssertEqual(viewModel.feeds(inFolder: "Tech").map(\.id), [2, 3])
+ XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1])
+ }
+
+ func testAnEmptyFolderSurvivesUntilAFeedMovesIntoIt() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = try await makeViewModel(client)
+
+ XCTAssertTrue(viewModel.createFolder(named: " Reading "))
+ XCTAssertEqual(viewModel.folders, ["Reading", "Tech"])
+
+ // A folder with no feeds has nothing to store on the server, so it is
+ // remembered locally and reappears on the next launch.
+ let reopened = AppViewModel(api: client, autoLoad: false, defaults: defaults)
+ XCTAssertEqual(reopened.folders, ["Reading"])
+ }
+
+ func testCreateFolderRejectsBlankAndDuplicateNames() async throws {
+ let viewModel = try await makeViewModel(DelayedAPIClient())
+
+ XCTAssertFalse(viewModel.createFolder(named: " "))
+ XCTAssertNotNil(viewModel.errorMessage)
+
+ viewModel.clearError()
+ XCTAssertFalse(viewModel.createFolder(named: "Tech"))
+ XCTAssertNotNil(viewModel.errorMessage)
+ XCTAssertEqual(viewModel.folders, ["Tech"])
+ }
+
+ func testMovingAFeedUpdatesTheServerAndTheSidebar() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = try await makeViewModel(client)
+
+ await viewModel.moveFeed(viewModel.feeds[0], toFolder: "Tech")
+
+ XCTAssertEqual(client.categoryMutations.map(\.id), [1])
+ XCTAssertEqual(client.categoryMutations.map(\.category), ["Tech"])
+ XCTAssertEqual(viewModel.feeds(inFolder: "Tech").map(\.id), [1, 2, 3])
+ XCTAssertTrue(viewModel.unfiledFeeds.isEmpty)
+ }
+
+ func testMovingAFeedOutOfAFolderClearsItsCategory() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = try await makeViewModel(client)
+ let feed = try XCTUnwrap(viewModel.feeds.first(where: { $0.id == 2 }))
+
+ await viewModel.moveFeed(feed, toFolder: nil)
+
+ XCTAssertEqual(client.categoryMutations.map(\.category), [""])
+ XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1, 2])
+ }
+
+ func testAFailedMoveLeavesTheSidebarUnchanged() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = try await makeViewModel(client)
+ client.categoryUpdateError = URLError(.notConnectedToInternet)
+
+ await viewModel.moveFeed(viewModel.feeds[0], toFolder: "Tech")
+
+ XCTAssertNotNil(viewModel.errorMessage)
+ XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1])
+ }
+
+ func testRenamingAFolderMovesEveryFeedAndFollowsTheSelection() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = try await makeViewModel(client)
+ viewModel.selection = .folder("Tech")
+
+ await viewModel.renameFolder("Tech", to: "Technology")
+
+ XCTAssertEqual(client.categoryMutations.map(\.id), [2, 3])
+ XCTAssertEqual(Set(client.categoryMutations.map(\.category)), ["Technology"])
+ XCTAssertEqual(viewModel.folders, ["Technology"])
+ XCTAssertEqual(viewModel.selection, .folder("Technology"))
+ }
+
+ func testDeletingAFolderKeepsItsFeedsSubscribed() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = try await makeViewModel(client)
+ viewModel.selection = .folder("Tech")
+
+ await viewModel.deleteFolder("Tech")
+
+ XCTAssertEqual(Set(client.categoryMutations.map(\.category)), [""])
+ XCTAssertTrue(viewModel.folders.isEmpty)
+ XCTAssertEqual(viewModel.feeds.count, 3)
+ XCTAssertEqual(viewModel.selection, .filter(.all))
+ }
+
+ func testFolderBadgeSumsTheUnreadCountsOfItsFeeds() async throws {
+ let client = DelayedAPIClient()
+ client.unreadCounts = UnreadCounts(total: 30, feedCounts: [1: 5, 2: 7, 3: 18])
+ let viewModel = try await makeViewModel(client)
+
+ XCTAssertEqual(viewModel.unreadCount(forFolder: "Tech"), 25)
+ }
+
+ func testSelectingAFolderAsksTheServerForThatCategory() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = try await makeViewModel(client)
+
+ viewModel.selection = .folder("Tech")
+ try await Task.sleep(for: .milliseconds(300))
+
+ XCTAssertEqual(client.lastArticleQuery?.category, "Tech")
+ XCTAssertNil(client.lastArticleQuery?.feedID)
+ }
+}
diff --git a/internal/handlers/feed/category_handlers_test.go b/internal/handlers/feed/category_handlers_test.go
new file mode 100644
index 00000000..27ddc496
--- /dev/null
+++ b/internal/handlers/feed/category_handlers_test.go
@@ -0,0 +1,68 @@
+package feed_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http/httptest"
+ "testing"
+
+ fh "MrRSS/internal/handlers/feed"
+ "MrRSS/internal/models"
+)
+
+// reuse setupHandler from feed_handlers_test.go
+
+func TestHandleUpdateFeedCategory(t *testing.T) {
+ h := setupHandler(t)
+
+ id, err := h.DB.AddFeed(&models.Feed{
+ Title: "example",
+ URL: "http://example.com/feed",
+ Category: "old",
+ ProxyURL: "http://127.0.0.1:8080",
+ })
+ if err != nil {
+ t.Fatalf("AddFeed error: %v", err)
+ }
+
+ body, _ := json.Marshal(map[string]any{"id": id, "category": " Tech "})
+ req := httptest.NewRequest("POST", "/api/feeds/category", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ fh.HandleUpdateFeedCategory(h, w, req)
+ if w.Result().StatusCode != 200 {
+ t.Fatalf("expected 200 OK, got %d", w.Result().StatusCode)
+ }
+
+ feed, err := h.DB.GetFeedByID(id)
+ if err != nil {
+ t.Fatalf("GetFeedByID error: %v", err)
+ }
+ if feed.Category != "Tech" {
+ t.Fatalf("expected the category to be trimmed to Tech, got %q", feed.Category)
+ }
+ // Moving a feed between categories must not disturb its other settings.
+ if feed.ProxyURL != "http://127.0.0.1:8080" {
+ t.Fatalf("expected the proxy setting to survive, got %q", feed.ProxyURL)
+ }
+}
+
+func TestHandleUpdateFeedCategoryRejectsBadRequests(t *testing.T) {
+ h := setupHandler(t)
+
+ req := httptest.NewRequest("POST", "/api/feeds/category", bytes.NewReader([]byte("notjson")))
+ w := httptest.NewRecorder()
+ fh.HandleUpdateFeedCategory(h, w, req)
+ if w.Result().StatusCode != 400 {
+ t.Fatalf("expected 400 for an invalid payload, got %d", w.Result().StatusCode)
+ }
+
+ body, _ := json.Marshal(map[string]any{"category": "Tech"})
+ missingID := httptest.NewRequest("POST", "/api/feeds/category", bytes.NewReader(body))
+ w2 := httptest.NewRecorder()
+ fh.HandleUpdateFeedCategory(h, w2, missingID)
+ if w2.Result().StatusCode != 400 {
+ t.Fatalf("expected 400 when the feed identifier is missing, got %d", w2.Result().StatusCode)
+ }
+}
diff --git a/internal/handlers/feed/feed_handlers.go b/internal/handlers/feed/feed_handlers.go
index a2dc848b..8f8238c4 100644
--- a/internal/handlers/feed/feed_handlers.go
+++ b/internal/handlers/feed/feed_handlers.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"strconv"
+ "strings"
"time"
"MrRSS/internal/handlers/core"
@@ -198,6 +199,38 @@ func HandleDeleteFeed(h *core.Handler, w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
+// HandleUpdateFeedCategory moves a feed into a category.
+// @Summary Set a feed's category
+// @Description Update only the category of a feed, leaving its other settings untouched
+// @Tags feeds
+// @Accept json
+// @Produce json
+// @Param request body object true "Feed identifier and category name"
+// @Success 200 {string} string "Category updated successfully"
+// @Failure 400 {object} map[string]string "Bad request"
+// @Failure 500 {object} map[string]string "Internal server error"
+// @Router /feeds/category [post]
+func HandleUpdateFeedCategory(h *core.Handler, w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ ID int64 `json:"id"`
+ Category string `json:"category"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ response.Error(w, err, http.StatusBadRequest)
+ return
+ }
+ if req.ID <= 0 {
+ response.Error(w, nil, http.StatusBadRequest)
+ return
+ }
+
+ if err := h.DB.UpdateFeedCategory(req.ID, strings.TrimSpace(req.Category)); err != nil {
+ response.Error(w, err, http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
// HandleUpdateFeed updates a feed's properties.
// @Summary Update a feed
// @Description Update properties of an existing feed subscription
diff --git a/internal/routes/feed_routes.go b/internal/routes/feed_routes.go
index 17c4cd23..9e78e6a1 100644
--- a/internal/routes/feed_routes.go
+++ b/internal/routes/feed_routes.go
@@ -18,6 +18,9 @@ func registerFeedRoutes(mux *http.ServeMux, h *core.Handler) {
mux.HandleFunc("/api/feeds/add", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleAddFeed(h, w, r) })
mux.HandleFunc("/api/feeds/delete", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleDeleteFeed(h, w, r) })
mux.HandleFunc("/api/feeds/update", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleUpdateFeed(h, w, r) })
+ mux.HandleFunc("/api/feeds/category", func(w http.ResponseWriter, r *http.Request) {
+ feedhandlers.HandleUpdateFeedCategory(h, w, r)
+ })
mux.HandleFunc("/api/feeds/refresh", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleRefreshFeed(h, w, r) })
mux.HandleFunc("/api/feeds/reorder", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleReorderFeed(h, w, r) })
mux.HandleFunc("/api/feeds/test-imap", func(w http.ResponseWriter, r *http.Request) { feedhandlers.HandleTestIMAPConnection(h, w, r) })
From c87d8fbbb24fddc8740fdde94a2d441c4edd101a Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Mon, 24 Aug 2026 21:39:21 +0800
Subject: [PATCH 13/59] feat(macos): create a folder from the empty area of the
sidebar
---
.../Sources/Views/SidebarBackgroundMenu.swift | 103 +++++++++++++++
.../Sources/Views/SidebarView.swift | 5 +
.../Tests/SidebarBackgroundMenuTests.swift | 118 ++++++++++++++++++
3 files changed, 226 insertions(+)
create mode 100644 frontend-swift/Sources/Views/SidebarBackgroundMenu.swift
create mode 100644 frontend-swift/Tests/SidebarBackgroundMenuTests.swift
diff --git a/frontend-swift/Sources/Views/SidebarBackgroundMenu.swift b/frontend-swift/Sources/Views/SidebarBackgroundMenu.swift
new file mode 100644
index 00000000..56e1f47d
--- /dev/null
+++ b/frontend-swift/Sources/Views/SidebarBackgroundMenu.swift
@@ -0,0 +1,103 @@
+import AppKit
+import SwiftUI
+
+/// Puts a menu on the sidebar's table view so a right click on the empty area
+/// below the last row offers an action. SwiftUI's `contextMenu` reaches only
+/// the rows themselves; the space beneath them belongs to the table view, which
+/// has no SwiftUI representation.
+struct SidebarBackgroundMenu: NSViewRepresentable {
+ let title: String
+ let action: () -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(title: title, action: action)
+ }
+
+ func makeNSView(context: Context) -> NSView {
+ let view = MenuInstaller()
+ view.coordinator = context.coordinator
+ return view
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ context.coordinator.action = action
+ (nsView as? MenuInstaller)?.installMenu()
+ }
+
+ func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSView, context: Context) -> CGSize? {
+ .zero
+ }
+
+ final class Coordinator: NSObject {
+ let menu: NSMenu
+ var action: () -> Void
+
+ init(title: String, action: @escaping () -> Void) {
+ self.action = action
+ menu = NSMenu()
+ super.init()
+
+ let item = NSMenuItem(title: title, action: #selector(runAction), keyEquivalent: "")
+ item.target = self
+ menu.addItem(item)
+ }
+
+ @objc private func runAction() {
+ action()
+ }
+ }
+
+ final class MenuInstaller: NSView {
+ weak var coordinator: Coordinator?
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ installMenu()
+ }
+
+ /// The table view is built while the list lays out, so the first attempt
+ /// can run before it exists.
+ func installMenu(remainingAttempts: Int = 5) {
+ guard let coordinator else { return }
+
+ if let scrollView = nearestTableView()?.enclosingScrollView {
+ // The table view itself discards an assigned menu, and AppKit
+ // then looks for one on each ancestor in turn, so the clip view
+ // is the first place that can answer for the empty area.
+ scrollView.contentView.menu = coordinator.menu
+ scrollView.menu = coordinator.menu
+ return
+ }
+
+ guard remainingAttempts > 0, window != nil else { return }
+ DispatchQueue.main.async { [weak self] in
+ self?.installMenu(remainingAttempts: remainingAttempts - 1)
+ }
+ }
+
+ /// Searches outwards one ancestor at a time so the closest table view
+ /// wins, which is the sidebar's rather than another column's.
+ private func nearestTableView() -> NSTableView? {
+ var ancestor = superview
+ while let current = ancestor {
+ if let tableView = MenuInstaller.firstTableView(in: current) {
+ return tableView
+ }
+ ancestor = current.superview
+ }
+ return nil
+ }
+
+ private static func firstTableView(in view: NSView) -> NSTableView? {
+ if let tableView = view as? NSTableView {
+ return tableView
+ }
+ for subview in view.subviews {
+ if let tableView = firstTableView(in: subview) {
+ return tableView
+ }
+ }
+ return nil
+ }
+ }
+}
diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift
index a05f0bfc..f8ae895c 100644
--- a/frontend-swift/Sources/Views/SidebarView.swift
+++ b/frontend-swift/Sources/Views/SidebarView.swift
@@ -75,6 +75,11 @@ struct SidebarView: View {
}
}
.listStyle(.sidebar)
+ .background {
+ SidebarBackgroundMenu(title: "New Folder…") {
+ folderPrompt = .create
+ }
+ }
}
@ViewBuilder
diff --git a/frontend-swift/Tests/SidebarBackgroundMenuTests.swift b/frontend-swift/Tests/SidebarBackgroundMenuTests.swift
new file mode 100644
index 00000000..c83bc16f
--- /dev/null
+++ b/frontend-swift/Tests/SidebarBackgroundMenuTests.swift
@@ -0,0 +1,118 @@
+import AppKit
+import SwiftUI
+import XCTest
+@testable import MrRSS
+
+@MainActor
+final class SidebarBackgroundMenuTests: XCTestCase {
+ private var defaults: UserDefaults!
+ private var suiteName: String!
+
+ override func setUp() {
+ super.setUp()
+ suiteName = "MrRSSBackgroundMenuTests-\(UUID().uuidString)"
+ defaults = UserDefaults(suiteName: suiteName)
+ }
+
+ override func tearDown() {
+ defaults.removePersistentDomain(forName: suiteName)
+ defaults = nil
+ suiteName = nil
+ super.tearDown()
+ }
+
+ func testRightClickBelowTheRowsOffersToCreateAFolder() async throws {
+ let (window, hostingView) = try await makeSidebar()
+ let tableView = try XCTUnwrap(firstDescendant(of: NSTableView.self, in: hostingView))
+
+ let lastRow = tableView.rect(ofRow: max(0, tableView.numberOfRows - 1))
+ let emptyPoint = NSPoint(x: 100, y: lastRow.maxY + 60)
+ XCTAssertEqual(tableView.row(at: emptyPoint), -1, "The probe should land below the last row.")
+
+ let titles = try XCTUnwrap(menuTitles(at: emptyPoint, in: tableView, window: window))
+ XCTAssertTrue(
+ titles.contains("New Folder…"),
+ "The empty area of the sidebar should offer to create a folder, got \(titles)."
+ )
+ }
+
+ func testRightClickOnAFeedStillOffersTheFeedActions() async throws {
+ let (window, hostingView) = try await makeSidebar()
+ let tableView = try XCTUnwrap(firstDescendant(of: NSTableView.self, in: hostingView))
+
+ let feedRow = tableView.rect(ofRow: tableView.numberOfRows - 1)
+ let point = NSPoint(x: 100, y: feedRow.midY)
+ XCTAssertNotEqual(tableView.row(at: point), -1)
+
+ let titles = try XCTUnwrap(menuTitles(at: point, in: tableView, window: window))
+ XCTAssertTrue(
+ titles.contains("Delete Feed"),
+ "A row keeps its own menu instead of the background one, got \(titles)."
+ )
+ XCTAssertFalse(titles.contains("New Folder…"))
+ }
+
+ private func makeSidebar() async throws -> (NSWindow, NSHostingView) {
+ let client = DelayedAPIClient()
+ client.defaultFeeds = [
+ Feed(id: 1, url: "https://a.example.com/feed", title: "Feed A", category: ""),
+ Feed(id: 2, url: "https://b.example.com/feed", title: "Feed B", category: "")
+ ]
+ let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults)
+ viewModel.refreshFeeds()
+ try await Task.sleep(for: .milliseconds(250))
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 260, height: 600),
+ styleMask: [.titled, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ let hostingView = NSHostingView(rootView: SidebarView(viewModel: viewModel))
+ window.contentView = hostingView
+ window.makeKeyAndOrderFront(nil)
+ hostingView.layoutSubtreeIfNeeded()
+ try await Task.sleep(for: .milliseconds(500))
+ return (window, hostingView)
+ }
+
+ /// Mirrors how AppKit resolves a context menu: it hit tests the click and
+ /// then asks each ancestor in turn until one answers.
+ private func menuTitles(at point: NSPoint, in tableView: NSTableView, window: NSWindow) -> [String]? {
+ let windowPoint = tableView.convert(point, to: nil)
+ guard let event = NSEvent.mouseEvent(
+ with: .rightMouseDown,
+ location: windowPoint,
+ modifierFlags: [],
+ timestamp: 0,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1
+ ) else {
+ return nil
+ }
+
+ var candidate = window.contentView?.hitTest(windowPoint)
+ while let view = candidate {
+ if let menu = view.menu(for: event) {
+ return menu.items.map(\.title)
+ }
+ candidate = view.superview
+ }
+ return nil
+ }
+
+ private func firstDescendant(of type: View.Type, in root: NSView) -> View? {
+ if let match = root as? View {
+ return match
+ }
+ for subview in root.subviews {
+ if let match = firstDescendant(of: type, in: subview) {
+ return match
+ }
+ }
+ return nil
+ }
+}
From 5a65b0fd4c1e0e313c313674b6a569949c518794 Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Mon, 24 Aug 2026 21:46:34 +0800
Subject: [PATCH 14/59] feat(macos): drag a subscription onto a folder to file
it
---
frontend-swift/README.md | 2 +-
.../Sources/Models/FeedTransfer.swift | 17 +++
.../Sources/ViewModels/AppViewModel.swift | 9 ++
.../Sources/Views/SidebarView.swift | 24 ++++
.../Tests/FeedDragAndDropTests.swift | 117 ++++++++++++++++++
frontend-swift/packaging/Info.plist | 15 +++
6 files changed, 183 insertions(+), 1 deletion(-)
create mode 100644 frontend-swift/Sources/Models/FeedTransfer.swift
create mode 100644 frontend-swift/Tests/FeedDragAndDropTests.swift
diff --git a/frontend-swift/README.md b/frontend-swift/README.md
index bd080662..6e0f1594 100644
--- a/frontend-swift/README.md
+++ b/frontend-swift/README.md
@@ -60,4 +60,4 @@ The release application contains both the SwiftUI frontend and reusable Go backe
The application provides native three-column navigation, feed subscription management, folders for grouping subscriptions, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content.
-Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it.
+Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, or from the row's context menu. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it.
diff --git a/frontend-swift/Sources/Models/FeedTransfer.swift b/frontend-swift/Sources/Models/FeedTransfer.swift
new file mode 100644
index 00000000..ea596b27
--- /dev/null
+++ b/frontend-swift/Sources/Models/FeedTransfer.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+extension UTType {
+ /// Private to MrRSS, so a drag coming from another application is never
+ /// mistaken for one of our subscriptions.
+ static let mrrssFeed = UTType(exportedAs: "com.devxdojo.mrrss.feed", conformingTo: .data)
+}
+
+/// What a sidebar row carries while it is being dragged onto a folder.
+struct FeedTransfer: Codable, Transferable, Equatable {
+ let feedID: Int
+
+ static var transferRepresentation: some TransferRepresentation {
+ CodableRepresentation(contentType: .mrrssFeed)
+ }
+}
diff --git a/frontend-swift/Sources/ViewModels/AppViewModel.swift b/frontend-swift/Sources/ViewModels/AppViewModel.swift
index e2e48094..bef6f255 100644
--- a/frontend-swift/Sources/ViewModels/AppViewModel.swift
+++ b/frontend-swift/Sources/ViewModels/AppViewModel.swift
@@ -292,6 +292,15 @@ final class AppViewModel: ObservableObject {
refreshFolders()
}
+ /// Moves everything a drag carried. Identifiers that no longer match a
+ /// subscription are skipped rather than failing the whole drop.
+ func moveFeeds(ids: [Int], toFolder folder: String?) async {
+ for id in ids {
+ guard let feed = feeds.first(where: { $0.id == id }) else { continue }
+ await moveFeed(feed, toFolder: folder)
+ }
+ }
+
func renameFolder(_ folder: String, to newName: String) async {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != folder else { return }
diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift
index f8ae895c..851336c9 100644
--- a/frontend-swift/Sources/Views/SidebarView.swift
+++ b/frontend-swift/Sources/Views/SidebarView.swift
@@ -7,6 +7,7 @@ struct SidebarView: View {
@State private var folderPendingDeletion: String?
@State private var expandedFolders: Set = []
@State private var folderPrompt: FolderPrompt?
+ @State private var dropTargetFolder: String?
var body: some View {
VStack(spacing: 0) {
@@ -174,6 +175,24 @@ struct SidebarView: View {
}
.badge(viewModel.unreadCount(forFolder: folder))
.tag(SidebarItem.folder(folder))
+ .padding(.vertical, 2)
+ .padding(.horizontal, 4)
+ .background(
+ RoundedRectangle(cornerRadius: 5)
+ .fill(dropTargetFolder == folder ? Color.accentColor.opacity(0.25) : .clear)
+ )
+ .dropDestination(for: FeedTransfer.self) { transfers, _ in
+ guard !transfers.isEmpty else { return false }
+ Task { await viewModel.moveFeeds(ids: transfers.map(\.feedID), toFolder: folder) }
+ return true
+ } isTargeted: { isTargeted in
+ if isTargeted {
+ dropTargetFolder = folder
+ expandedFolders.insert(folder)
+ } else if dropTargetFolder == folder {
+ dropTargetFolder = nil
+ }
+ }
.contextMenu {
Button("Rename Folder…", systemImage: "pencil") {
folderPrompt = .rename(folder)
@@ -189,6 +208,11 @@ struct SidebarView: View {
FeedLabel(feed: feed)
.badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0)
.tag(SidebarItem.feed(feed.id))
+ .draggable(FeedTransfer(feedID: feed.id)) {
+ FeedLabel(feed: feed)
+ .padding(6)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6))
+ }
.contextMenu {
Menu("Move to Folder") {
ForEach(viewModel.folders, id: \.self) { folder in
diff --git a/frontend-swift/Tests/FeedDragAndDropTests.swift b/frontend-swift/Tests/FeedDragAndDropTests.swift
new file mode 100644
index 00000000..ac9881f5
--- /dev/null
+++ b/frontend-swift/Tests/FeedDragAndDropTests.swift
@@ -0,0 +1,117 @@
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import XCTest
+@testable import MrRSS
+
+@MainActor
+final class FeedDragAndDropTests: XCTestCase {
+ private var defaults: UserDefaults!
+ private var suiteName: String!
+
+ override func setUp() {
+ super.setUp()
+ suiteName = "MrRSSDragTests-\(UUID().uuidString)"
+ defaults = UserDefaults(suiteName: suiteName)
+ }
+
+ override func tearDown() {
+ defaults.removePersistentDomain(forName: suiteName)
+ defaults = nil
+ suiteName = nil
+ super.tearDown()
+ }
+
+ func testTheDraggedPayloadUsesTheApplicationsOwnType() {
+ let provider = NSItemProvider()
+ provider.register(FeedTransfer(feedID: 1))
+
+ XCTAssertEqual(UTType.mrrssFeed.identifier, "com.devxdojo.mrrss.feed")
+ XCTAssertEqual(
+ provider.registeredTypeIdentifiers,
+ [UTType.mrrssFeed.identifier],
+ "A subscription travels under an identifier of our own, not as generic text."
+ )
+ }
+
+ func testTheDraggedPayloadSurvivesTheRoundTrip() async throws {
+ let provider = NSItemProvider()
+ provider.register(FeedTransfer(feedID: 42))
+
+ XCTAssertTrue(provider.registeredTypeIdentifiers.contains(UTType.mrrssFeed.identifier))
+
+ let restored: FeedTransfer = try await withCheckedThrowingContinuation { continuation in
+ _ = provider.loadTransferable(type: FeedTransfer.self) { result in
+ continuation.resume(with: result)
+ }
+ }
+
+ XCTAssertEqual(restored, FeedTransfer(feedID: 42))
+ }
+
+ func testDroppingOnAFolderMovesEveryFeedInTheDrag() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = try await makeViewModel(client)
+
+ await viewModel.moveFeeds(ids: [1, 3], toFolder: "Tech")
+
+ XCTAssertEqual(client.categoryMutations.map(\.id), [1, 3])
+ XCTAssertEqual(Set(client.categoryMutations.map(\.category)), ["Tech"])
+ XCTAssertEqual(viewModel.feeds(inFolder: "Tech").map(\.id), [1, 2, 3])
+ }
+
+ func testDroppingAFeedThatNoLongerExistsIsIgnored() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = try await makeViewModel(client)
+
+ await viewModel.moveFeeds(ids: [99, 1], toFolder: "Tech")
+
+ XCTAssertEqual(client.categoryMutations.map(\.id), [1])
+ XCTAssertNil(viewModel.errorMessage)
+ }
+
+ func testTheSidebarAcceptsSubscriptionDrags() async throws {
+ let client = DelayedAPIClient()
+ let viewModel = try await makeViewModel(client)
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 260, height: 600),
+ styleMask: [.titled, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ let hostingView = NSHostingView(rootView: SidebarView(viewModel: viewModel))
+ window.contentView = hostingView
+ window.makeKeyAndOrderFront(nil)
+ hostingView.layoutSubtreeIfNeeded()
+ try await Task.sleep(for: .milliseconds(500))
+
+ // Without a drop destination the sidebar registers only SwiftUI's own
+ // row reordering type.
+ let registered = registeredDraggedTypes(in: hostingView)
+ XCTAssertTrue(
+ registered.contains(NSPasteboard.PasteboardType(UTType.data.identifier)),
+ "A folder row should register as a drop target, got \(registered)."
+ )
+ }
+
+ private func makeViewModel(_ client: DelayedAPIClient) async throws -> AppViewModel {
+ client.defaultFeeds = [
+ Feed(id: 1, url: "https://a.example.com/feed", title: "Feed A", category: ""),
+ Feed(id: 2, url: "https://b.example.com/feed", title: "Feed B", category: "Tech"),
+ Feed(id: 3, url: "https://c.example.com/feed", title: "Feed C", category: "")
+ ]
+ let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults)
+ viewModel.refreshFeeds()
+ try await Task.sleep(for: .milliseconds(250))
+ return viewModel
+ }
+
+ private func registeredDraggedTypes(in root: NSView) -> Set {
+ var types = Set(root.registeredDraggedTypes)
+ for subview in root.subviews {
+ types.formUnion(registeredDraggedTypes(in: subview))
+ }
+ return types
+ }
+}
diff --git a/frontend-swift/packaging/Info.plist b/frontend-swift/packaging/Info.plist
index f83e05e0..51a9123b 100644
--- a/frontend-swift/packaging/Info.plist
+++ b/frontend-swift/packaging/Info.plist
@@ -30,6 +30,21 @@
NSPrincipalClass
NSApplication
+ UTExportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ com.devxdojo.mrrss.feed
+ UTTypeDescription
+ MrRSS Subscription
+ UTTypeConformsTo
+
+ public.data
+
+ UTTypeTagSpecification
+
+
+
NSAppTransportSecurity
NSAllowsLocalNetworking
From 9df13da532045b8811cf2546c53b9d510d820dd7 Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Mon, 24 Aug 2026 21:57:20 +0800
Subject: [PATCH 15/59] fix(macos): make the drop half of folder filing work
---
frontend-swift/README.md | 2 +-
.../Sources/Models/FeedTransfer.swift | 55 +++++-
.../Sources/Views/SidebarView.swift | 55 ++++--
.../Tests/FeedDragAndDropTests.swift | 32 ++--
.../Tests/FeedDropTargetTests.swift | 166 ++++++++++++++++++
frontend-swift/packaging/Info.plist | 15 --
6 files changed, 275 insertions(+), 50 deletions(-)
create mode 100644 frontend-swift/Tests/FeedDropTargetTests.swift
diff --git a/frontend-swift/README.md b/frontend-swift/README.md
index 6e0f1594..06a70862 100644
--- a/frontend-swift/README.md
+++ b/frontend-swift/README.md
@@ -60,4 +60,4 @@ The release application contains both the SwiftUI frontend and reusable Go backe
The application provides native three-column navigation, feed subscription management, folders for grouping subscriptions, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content.
-Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, or from the row's context menu. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it.
+Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, and back out by dropping it on the Feeds heading. The row's context menu offers the same moves. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it.
diff --git a/frontend-swift/Sources/Models/FeedTransfer.swift b/frontend-swift/Sources/Models/FeedTransfer.swift
index ea596b27..dcab5683 100644
--- a/frontend-swift/Sources/Models/FeedTransfer.swift
+++ b/frontend-swift/Sources/Models/FeedTransfer.swift
@@ -2,16 +2,61 @@ import SwiftUI
import UniformTypeIdentifiers
extension UTType {
- /// Private to MrRSS, so a drag coming from another application is never
+ /// Specific to MrRSS, so a drag coming from another application is never
/// mistaken for one of our subscriptions.
- static let mrrssFeed = UTType(exportedAs: "com.devxdojo.mrrss.feed", conformingTo: .data)
+ ///
+ /// The type is derived from a tag rather than declared with
+ /// `UTType(exportedAs:)`. An exported declaration only carries its
+ /// conformances when the bundle's Info.plist declares it as well, and a
+ /// type that conforms to nothing matches no drop target at all.
+ static let mrrssFeed = UTType(
+ tag: "mrrssfeed",
+ tagClass: .filenameExtension,
+ conformingTo: .data
+ ) ?? .data
}
/// What a sidebar row carries while it is being dragged onto a folder.
-struct FeedTransfer: Codable, Transferable, Equatable {
+///
+/// The payload travels under `UTType.mrrssFeed` alone. A drag registered under
+/// a broad type such as `public.data` is claimed by the enclosing list, which
+/// registers itself for row reordering, and never reaches the folder underneath
+/// the pointer.
+struct FeedTransfer: Codable, Equatable {
let feedID: Int
- static var transferRepresentation: some TransferRepresentation {
- CodableRepresentation(contentType: .mrrssFeed)
+ var itemProvider: NSItemProvider {
+ let provider = NSItemProvider()
+ provider.registerDataRepresentation(
+ forTypeIdentifier: UTType.mrrssFeed.identifier,
+ visibility: .ownProcess
+ ) { completion in
+ completion(try? JSONEncoder().encode(self), nil)
+ return nil
+ }
+ return provider
+ }
+
+ static func feedIDs(from providers: [NSItemProvider]) async -> [Int] {
+ var ids: [Int] = []
+ for provider in providers {
+ guard let transfer = await load(from: provider) else { continue }
+ ids.append(transfer.feedID)
+ }
+ return ids
+ }
+
+ private static func load(from provider: NSItemProvider) async -> FeedTransfer? {
+ await withCheckedContinuation { continuation in
+ _ = provider.loadDataRepresentation(
+ forTypeIdentifier: UTType.mrrssFeed.identifier
+ ) { data, _ in
+ guard let data, let transfer = try? JSONDecoder().decode(FeedTransfer.self, from: data) else {
+ continuation.resume(returning: nil)
+ return
+ }
+ continuation.resume(returning: transfer)
+ }
+ }
}
}
diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift
index 851336c9..95a315be 100644
--- a/frontend-swift/Sources/Views/SidebarView.swift
+++ b/frontend-swift/Sources/Views/SidebarView.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import UniformTypeIdentifiers
struct SidebarView: View {
@ObservedObject var viewModel: AppViewModel
@@ -8,6 +9,7 @@ struct SidebarView: View {
@State private var expandedFolders: Set = []
@State private var folderPrompt: FolderPrompt?
@State private var dropTargetFolder: String?
+ @State private var isDropTargetingRoot = false
var body: some View {
VStack(spacing: 0) {
@@ -71,8 +73,20 @@ struct SidebarView: View {
}
}
- Section("Feeds") {
+ Section {
feedSection
+ } header: {
+ // Dropping on the section header takes a subscription back out
+ // of whatever folder it is in.
+ Text("Feeds")
+ .padding(.horizontal, 4)
+ .background(
+ RoundedRectangle(cornerRadius: 4)
+ .fill(isDropTargetingRoot ? Color.accentColor.opacity(0.25) : .clear)
+ )
+ .onDrop(of: [.mrrssFeed], isTargeted: $isDropTargetingRoot) { providers in
+ move(providers, toFolder: nil)
+ }
}
}
.listStyle(.sidebar)
@@ -181,17 +195,21 @@ struct SidebarView: View {
RoundedRectangle(cornerRadius: 5)
.fill(dropTargetFolder == folder ? Color.accentColor.opacity(0.25) : .clear)
)
- .dropDestination(for: FeedTransfer.self) { transfers, _ in
- guard !transfers.isEmpty else { return false }
- Task { await viewModel.moveFeeds(ids: transfers.map(\.feedID), toFolder: folder) }
- return true
- } isTargeted: { isTargeted in
- if isTargeted {
- dropTargetFolder = folder
- expandedFolders.insert(folder)
- } else if dropTargetFolder == folder {
- dropTargetFolder = nil
- }
+ .onDrop(
+ of: [.mrrssFeed],
+ isTargeted: Binding(
+ get: { dropTargetFolder == folder },
+ set: { isTargeted in
+ if isTargeted {
+ dropTargetFolder = folder
+ expandedFolders.insert(folder)
+ } else if dropTargetFolder == folder {
+ dropTargetFolder = nil
+ }
+ }
+ )
+ ) { providers in
+ move(providers, toFolder: folder)
}
.contextMenu {
Button("Rename Folder…", systemImage: "pencil") {
@@ -208,7 +226,9 @@ struct SidebarView: View {
FeedLabel(feed: feed)
.badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0)
.tag(SidebarItem.feed(feed.id))
- .draggable(FeedTransfer(feedID: feed.id)) {
+ .onDrag {
+ FeedTransfer(feedID: feed.id).itemProvider
+ } preview: {
FeedLabel(feed: feed)
.padding(6)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6))
@@ -246,6 +266,15 @@ struct SidebarView: View {
}
}
+ private func move(_ providers: [NSItemProvider], toFolder folder: String?) -> Bool {
+ guard !providers.isEmpty else { return false }
+ Task {
+ let ids = await FeedTransfer.feedIDs(from: providers)
+ await viewModel.moveFeeds(ids: ids, toFolder: folder)
+ }
+ return true
+ }
+
private func badge(for filter: ArticleFilter) -> Int {
switch filter {
case .all, .unread:
diff --git a/frontend-swift/Tests/FeedDragAndDropTests.swift b/frontend-swift/Tests/FeedDragAndDropTests.swift
index ac9881f5..c037b6bc 100644
--- a/frontend-swift/Tests/FeedDragAndDropTests.swift
+++ b/frontend-swift/Tests/FeedDragAndDropTests.swift
@@ -23,30 +23,30 @@ final class FeedDragAndDropTests: XCTestCase {
}
func testTheDraggedPayloadUsesTheApplicationsOwnType() {
- let provider = NSItemProvider()
- provider.register(FeedTransfer(feedID: 1))
-
- XCTAssertEqual(UTType.mrrssFeed.identifier, "com.devxdojo.mrrss.feed")
+ XCTAssertTrue(UTType.mrrssFeed.conforms(to: .data))
+ XCTAssertFalse(UTType.mrrssFeed.conforms(to: .plainText))
+ XCTAssertNotEqual(UTType.mrrssFeed, .data)
XCTAssertEqual(
- provider.registeredTypeIdentifiers,
+ FeedTransfer(feedID: 1).itemProvider.registeredTypeIdentifiers,
[UTType.mrrssFeed.identifier],
- "A subscription travels under an identifier of our own, not as generic text."
+ "A subscription must not travel under a broad type such as public.data, which the list claims for row reordering."
)
}
- func testTheDraggedPayloadSurvivesTheRoundTrip() async throws {
- let provider = NSItemProvider()
- provider.register(FeedTransfer(feedID: 42))
+ func testTheDraggedPayloadSurvivesTheRoundTrip() async {
+ let providers = [FeedTransfer(feedID: 42), FeedTransfer(feedID: 7)].map(\.itemProvider)
- XCTAssertTrue(provider.registeredTypeIdentifiers.contains(UTType.mrrssFeed.identifier))
+ let ids = await FeedTransfer.feedIDs(from: providers)
- let restored: FeedTransfer = try await withCheckedThrowingContinuation { continuation in
- _ = provider.loadTransferable(type: FeedTransfer.self) { result in
- continuation.resume(with: result)
- }
- }
+ XCTAssertEqual(ids, [42, 7])
+ }
+
+ func testAProviderOfAnotherKindContributesNothing() async {
+ let foreign = NSItemProvider(object: NSString(string: "https://example.com"))
+
+ let ids = await FeedTransfer.feedIDs(from: [foreign, FeedTransfer(feedID: 3).itemProvider])
- XCTAssertEqual(restored, FeedTransfer(feedID: 42))
+ XCTAssertEqual(ids, [3])
}
func testDroppingOnAFolderMovesEveryFeedInTheDrag() async throws {
diff --git a/frontend-swift/Tests/FeedDropTargetTests.swift b/frontend-swift/Tests/FeedDropTargetTests.swift
new file mode 100644
index 00000000..9c4b3a11
--- /dev/null
+++ b/frontend-swift/Tests/FeedDropTargetTests.swift
@@ -0,0 +1,166 @@
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import XCTest
+@testable import MrRSS
+
+/// Enough of a dragging session to drive a real drop through AppKit. The drop
+/// path cannot be reached from SwiftUI alone, and it is exactly where a
+/// mismatch between the dragged type and the target's registered types goes
+/// unnoticed: the drag still starts, and nothing accepts it.
+private final class StubDraggingInfo: NSObject, NSDraggingInfo {
+ var draggingDestinationWindow: NSWindow?
+ var draggingSourceOperationMask: NSDragOperation = .move
+ var draggingLocation: NSPoint = .zero
+ var draggedImageLocation: NSPoint = .zero
+ var draggedImage: NSImage?
+ var draggingPasteboard: NSPasteboard
+ var draggingSource: Any?
+ var draggingSequenceNumber: Int = 1
+ var draggingFormation: NSDraggingFormation = .default
+ var animatesToDestination: Bool = false
+ var numberOfValidItemsForDrop: Int = 1
+ var springLoadingHighlight: NSSpringLoadingHighlight = .none
+ var items: [NSDraggingItem] = []
+
+ init(pasteboard: NSPasteboard) {
+ draggingPasteboard = pasteboard
+ super.init()
+ }
+
+ func slideDraggedImage(to screenPoint: NSPoint) {}
+
+ override func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? {
+ nil
+ }
+
+ func enumerateDraggingItems(
+ options enumOpts: NSDraggingItemEnumerationOptions,
+ for view: NSView?,
+ classes classArray: [AnyClass],
+ searchOptions: [NSPasteboard.ReadingOptionKey: Any],
+ using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void
+ ) {
+ var stop: ObjCBool = false
+ for (index, item) in items.enumerated() {
+ block(item, index, &stop)
+ if stop.boolValue { return }
+ }
+ }
+
+ func resetSpringLoading() {}
+}
+
+@MainActor
+final class FeedDropTargetTests: XCTestCase {
+ private var defaults: UserDefaults!
+ private var suiteName: String!
+
+ override func setUp() {
+ super.setUp()
+ suiteName = "MrRSSDropTargetTests-\(UUID().uuidString)"
+ defaults = UserDefaults(suiteName: suiteName)
+ }
+
+ override func tearDown() {
+ defaults.removePersistentDomain(forName: suiteName)
+ defaults = nil
+ suiteName = nil
+ super.tearDown()
+ }
+
+ func testDroppingOnAFolderFilesTheSubscription() async throws {
+ let (client, hostingView) = try await makeSidebar()
+ let target = try XCTUnwrap(folderDestination(in: hostingView))
+
+ try await drop(FeedTransfer(feedID: 1), on: target)
+
+ XCTAssertEqual(client.categoryMutations.map(\.id), [1])
+ XCTAssertEqual(client.categoryMutations.map(\.category), ["Tech"])
+ }
+
+ func testDroppingOnTheSectionHeaderTakesTheSubscriptionOutOfItsFolder() async throws {
+ let (client, hostingView) = try await makeSidebar()
+ let target = try XCTUnwrap(headerDestination(in: hostingView))
+
+ try await drop(FeedTransfer(feedID: 2), on: target)
+
+ XCTAssertEqual(client.categoryMutations.map(\.id), [2])
+ XCTAssertEqual(client.categoryMutations.map(\.category), [""])
+ }
+
+ func testTheDraggedTypeMatchesWhatTheTargetsAccept() throws {
+ // A type declared with UTType(exportedAs:) carries no conformances
+ // unless the bundle declares it too, and then no target accepts it.
+ XCTAssertTrue(UTType.mrrssFeed.conforms(to: .data))
+ XCTAssertTrue(UTType.mrrssFeed.conforms(to: .item))
+ XCTAssertFalse(UTType.mrrssFeed.conforms(to: .plainText))
+ XCTAssertNotEqual(UTType.mrrssFeed, .data)
+ }
+
+ private func drop(_ transfer: FeedTransfer, on target: NSView) async throws {
+ let item = NSPasteboardItem()
+ item.setData(try JSONEncoder().encode(transfer), forType: .init(UTType.mrrssFeed.identifier))
+
+ let pasteboard = NSPasteboard(name: .init("MrRSSDropTargetTests-\(UUID().uuidString)"))
+ pasteboard.clearContents()
+ pasteboard.writeObjects([item])
+
+ let info = StubDraggingInfo(pasteboard: pasteboard)
+ info.draggingDestinationWindow = target.window
+ info.draggingLocation = target.convert(NSPoint(x: 4, y: 4), to: nil)
+ info.items = [NSDraggingItem(pasteboardWriter: item)]
+
+ XCTAssertNotEqual(
+ target.draggingEntered(info), [],
+ "The target refused the drag, so the pointer would show no drop cursor."
+ )
+ XCTAssertTrue(target.performDragOperation(info))
+ try await Task.sleep(for: .milliseconds(600))
+ }
+
+ private func makeSidebar() async throws -> (DelayedAPIClient, NSHostingView) {
+ let client = DelayedAPIClient()
+ client.defaultFeeds = [
+ Feed(id: 1, url: "https://a.example.com/feed", title: "Feed A", category: ""),
+ Feed(id: 2, url: "https://b.example.com/feed", title: "Feed B", category: "Tech")
+ ]
+ let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults)
+ viewModel.refreshFeeds()
+ try await Task.sleep(for: .milliseconds(250))
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 260, height: 600),
+ styleMask: [.titled, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ let hostingView = NSHostingView(rootView: SidebarView(viewModel: viewModel))
+ window.contentView = hostingView
+ window.makeKeyAndOrderFront(nil)
+ hostingView.layoutSubtreeIfNeeded()
+ try await Task.sleep(for: .milliseconds(600))
+ return (client, hostingView)
+ }
+
+ /// A folder covers most of the row; the section header covers its label
+ /// only, which is much narrower.
+ private func folderDestination(in view: NSView) -> NSView? {
+ dropDestinations(in: view).first { $0.bounds.width > 100 }
+ }
+
+ private func headerDestination(in view: NSView) -> NSView? {
+ dropDestinations(in: view).first { $0.bounds.width <= 100 }
+ }
+
+ private func dropDestinations(in view: NSView) -> [NSView] {
+ var found: [NSView] = []
+ if !view.registeredDraggedTypes.isEmpty, !(view is NSTableView) {
+ found.append(view)
+ }
+ for subview in view.subviews {
+ found.append(contentsOf: dropDestinations(in: subview))
+ }
+ return found
+ }
+}
diff --git a/frontend-swift/packaging/Info.plist b/frontend-swift/packaging/Info.plist
index 51a9123b..f83e05e0 100644
--- a/frontend-swift/packaging/Info.plist
+++ b/frontend-swift/packaging/Info.plist
@@ -30,21 +30,6 @@
NSPrincipalClass
NSApplication
- UTExportedTypeDeclarations
-
-
- UTTypeIdentifier
- com.devxdojo.mrrss.feed
- UTTypeDescription
- MrRSS Subscription
- UTTypeConformsTo
-
- public.data
-
- UTTypeTagSpecification
-
-
-
NSAppTransportSecurity
NSAllowsLocalNetworking
From 5cd426c4305b58e3841259bfcee9bddc2c4ba574 Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Mon, 24 Aug 2026 22:07:25 +0800
Subject: [PATCH 16/59] feat(macos): reorder subscriptions by dropping between
rows
---
frontend-swift/README.md | 2 +-
frontend-swift/Sources/Models/Models.swift | 7 +-
.../Sources/Services/APIService.swift | 8 +
.../Sources/ViewModels/AppViewModel.swift | 59 ++++++-
.../Sources/Views/FeedRowDropDelegate.swift | 45 ++++++
.../Sources/Views/SidebarView.swift | 51 +++++-
frontend-swift/Tests/AppViewModelTests.swift | 6 +
.../Tests/FeedDropTargetTests.swift | 72 +--------
frontend-swift/Tests/FeedReorderTests.swift | 151 ++++++++++++++++++
frontend-swift/Tests/StubDraggingInfo.swift | 89 +++++++++++
10 files changed, 418 insertions(+), 72 deletions(-)
create mode 100644 frontend-swift/Sources/Views/FeedRowDropDelegate.swift
create mode 100644 frontend-swift/Tests/FeedReorderTests.swift
create mode 100644 frontend-swift/Tests/StubDraggingInfo.swift
diff --git a/frontend-swift/README.md b/frontend-swift/README.md
index 06a70862..4a373fdc 100644
--- a/frontend-swift/README.md
+++ b/frontend-swift/README.md
@@ -60,4 +60,4 @@ The release application contains both the SwiftUI frontend and reusable Go backe
The application provides native three-column navigation, feed subscription management, folders for grouping subscriptions, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content.
-Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, and back out by dropping it on the Feeds heading. The row's context menu offers the same moves. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it.
+Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, and back out by dropping it on the Feeds heading. The row's context menu offers the same moves. Dropping a subscription on the upper or lower half of another row files it directly before or after that row, and the sidebar shows a line where it will land. The resulting order is the `position` the server records, so it is shared with the Vue frontend. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it.
diff --git a/frontend-swift/Sources/Models/Models.swift b/frontend-swift/Sources/Models/Models.swift
index 5ebd3efb..32e50601 100644
--- a/frontend-swift/Sources/Models/Models.swift
+++ b/frontend-swift/Sources/Models/Models.swift
@@ -5,11 +5,13 @@ struct Feed: Identifiable, Codable, Hashable {
let url: String
let title: String
var category: String
+ /// Rank inside its category, as the server keeps it.
+ var position: Int
let lastUpdated: String
let iconURL: String?
enum CodingKeys: String, CodingKey {
- case id, url, title, category
+ case id, url, title, category, position
case lastUpdated = "last_updated"
case iconURL = "image_url"
}
@@ -19,6 +21,7 @@ struct Feed: Identifiable, Codable, Hashable {
url: String,
title: String,
category: String,
+ position: Int = 0,
lastUpdated: String = "",
iconURL: String? = nil
) {
@@ -26,6 +29,7 @@ struct Feed: Identifiable, Codable, Hashable {
self.url = url
self.title = title
self.category = category
+ self.position = position
self.lastUpdated = lastUpdated
self.iconURL = iconURL?.nilIfEmpty
}
@@ -36,6 +40,7 @@ struct Feed: Identifiable, Codable, Hashable {
url = try container.decodeIfPresent(String.self, forKey: .url) ?? ""
title = try container.decodeIfPresent(String.self, forKey: .title) ?? url
category = try container.decodeIfPresent(String.self, forKey: .category) ?? ""
+ position = try container.decodeIfPresent(Int.self, forKey: .position) ?? 0
lastUpdated = try container.decodeIfPresent(String.self, forKey: .lastUpdated) ?? ""
iconURL = try container.decodeIfPresent(String.self, forKey: .iconURL)?.nilIfEmpty
}
diff --git a/frontend-swift/Sources/Services/APIService.swift b/frontend-swift/Sources/Services/APIService.swift
index cf067ffa..d4ed2c51 100644
--- a/frontend-swift/Sources/Services/APIService.swift
+++ b/frontend-swift/Sources/Services/APIService.swift
@@ -74,6 +74,7 @@ protocol APIClient: AnyObject {
func addFeed(url: String, title: String, category: String) async throws
func deleteFeed(id: Int) async throws
func updateFeedCategory(id: Int, category: String) async throws
+ func reorderFeed(id: Int, category: String, position: Int) async throws
func refreshAllFeeds() async throws
func fetchRefreshProgress() async throws -> RefreshProgress
func fetchArticles(
@@ -154,6 +155,13 @@ final class APIService: APIClient {
)
}
+ func reorderFeed(id: Int, category: String, position: Int) async throws {
+ try await post(
+ "feeds/reorder",
+ jsonBody: ["feed_id": id, "category": category, "position": position]
+ )
+ }
+
func refreshAllFeeds() async throws {
try await post("refresh")
}
diff --git a/frontend-swift/Sources/ViewModels/AppViewModel.swift b/frontend-swift/Sources/ViewModels/AppViewModel.swift
index bef6f255..7facb006 100644
--- a/frontend-swift/Sources/ViewModels/AppViewModel.swift
+++ b/frontend-swift/Sources/ViewModels/AppViewModel.swift
@@ -152,7 +152,7 @@ final class AppViewModel: ObservableObject {
let (loadedFeeds, loadedCounts) = try await (feedsRequest, countsRequest)
try Task.checkCancellation()
guard requestID == feedRequestID else { return }
- feeds = loadedFeeds
+ feeds = AppViewModel.ordered(loadedFeeds)
refreshFolders()
unreadCounts = loadedCounts
connectionState = .connected
@@ -301,6 +301,63 @@ final class AppViewModel: ObservableObject {
}
}
+ /// Files everything a drag carried directly above or below one of the rows
+ /// it was dropped on, which is how the sidebar's own order is changed.
+ func moveFeeds(ids: [Int], relativeTo referenceID: Int, placeAbove: Bool) async {
+ var anchorID = referenceID
+ var above = placeAbove
+
+ for id in ids where id != referenceID {
+ guard let anchor = feeds.first(where: { $0.id == anchorID }),
+ feeds.contains(where: { $0.id == id }) else { continue }
+
+ let siblings = feeds(inFolder: anchor.category).filter { $0.id != id }
+ let anchorIndex = siblings.firstIndex(where: { $0.id == anchorID }) ?? siblings.count
+ await reorderFeed(id: id, category: anchor.category, index: above ? anchorIndex : anchorIndex + 1)
+
+ // Anything after the first lands just below what came before it, so
+ // a multiple selection keeps the order it was dragged in.
+ anchorID = id
+ above = false
+ }
+ }
+
+ private func reorderFeed(id: Int, category: String, index: Int) async {
+ do {
+ try await api.reorderFeed(id: id, category: category, position: index)
+ } catch {
+ errorMessage = error.localizedDescription
+ refreshFeeds()
+ return
+ }
+
+ applyLocalOrder(feedID: id, category: category, index: index)
+ refreshFolders()
+ }
+
+ /// Mirrors the ranking the server just performed so the sidebar settles
+ /// immediately instead of waiting for the next load.
+ private func applyLocalOrder(feedID: Int, category: String, index: Int) {
+ guard var moving = feeds.first(where: { $0.id == feedID }) else { return }
+ moving.category = category
+
+ var siblings = feeds.filter { $0.category == category && $0.id != feedID }
+ siblings.insert(moving, at: min(max(0, index), siblings.count))
+
+ for (rank, sibling) in siblings.enumerated() {
+ guard let position = feeds.firstIndex(where: { $0.id == sibling.id }) else { continue }
+ feeds[position].category = category
+ feeds[position].position = rank
+ }
+ feeds = AppViewModel.ordered(feeds)
+ }
+
+ private static func ordered(_ feeds: [Feed]) -> [Feed] {
+ feeds.sorted { lhs, rhs in
+ lhs.position == rhs.position ? lhs.id < rhs.id : lhs.position < rhs.position
+ }
+ }
+
func renameFolder(_ folder: String, to newName: String) async {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != folder else { return }
diff --git a/frontend-swift/Sources/Views/FeedRowDropDelegate.swift b/frontend-swift/Sources/Views/FeedRowDropDelegate.swift
new file mode 100644
index 00000000..a48b6bf6
--- /dev/null
+++ b/frontend-swift/Sources/Views/FeedRowDropDelegate.swift
@@ -0,0 +1,45 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+/// Handles a subscription dragged over another row. `DropInfo` reports where
+/// the pointer sits inside the row, which is what decides whether the drop
+/// lands above or below it; `onDrop(of:isTargeted:)` cannot tell.
+struct FeedRowDropDelegate: DropDelegate {
+ let rowHeight: CGFloat
+ let onHover: (Bool) -> Void
+ let onExit: () -> Void
+ let onDrop: (Bool, [NSItemProvider]) -> Bool
+
+ func validateDrop(info: DropInfo) -> Bool {
+ info.hasItemsConforming(to: [.mrrssFeed])
+ }
+
+ func dropEntered(info: DropInfo) {
+ onHover(placesAbove(info))
+ }
+
+ func dropUpdated(info: DropInfo) -> DropProposal? {
+ onHover(placesAbove(info))
+ return DropProposal(operation: .move)
+ }
+
+ func dropExited(info: DropInfo) {
+ onExit()
+ }
+
+ func performDrop(info: DropInfo) -> Bool {
+ let above = placesAbove(info)
+ onExit()
+ return onDrop(above, info.itemProviders(for: [.mrrssFeed]))
+ }
+
+ private func placesAbove(_ info: DropInfo) -> Bool {
+ info.location.y < rowHeight / 2
+ }
+}
+
+/// Where the sidebar will place a dragged subscription.
+struct FeedInsertionPoint: Equatable {
+ let feedID: Int
+ let above: Bool
+}
diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift
index 95a315be..4041332c 100644
--- a/frontend-swift/Sources/Views/SidebarView.swift
+++ b/frontend-swift/Sources/Views/SidebarView.swift
@@ -10,6 +10,7 @@ struct SidebarView: View {
@State private var folderPrompt: FolderPrompt?
@State private var dropTargetFolder: String?
@State private var isDropTargetingRoot = false
+ @State private var insertionPoint: FeedInsertionPoint?
var body: some View {
VStack(spacing: 0) {
@@ -226,8 +227,36 @@ struct SidebarView: View {
FeedLabel(feed: feed)
.badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0)
.tag(SidebarItem.feed(feed.id))
+ .overlay(alignment: .top) {
+ insertionIndicator(showing: insertionPoint == FeedInsertionPoint(feedID: feed.id, above: true))
+ }
+ .overlay(alignment: .bottom) {
+ insertionIndicator(showing: insertionPoint == FeedInsertionPoint(feedID: feed.id, above: false))
+ }
+ .background {
+ GeometryReader { geometry in
+ Color.clear.onDrop(
+ of: [.mrrssFeed],
+ delegate: FeedRowDropDelegate(
+ rowHeight: geometry.size.height,
+ onHover: { above in
+ insertionPoint = FeedInsertionPoint(feedID: feed.id, above: above)
+ },
+ onExit: {
+ if insertionPoint?.feedID == feed.id {
+ insertionPoint = nil
+ }
+ },
+ onDrop: { above, providers in
+ insert(providers, relativeTo: feed, above: above)
+ }
+ )
+ )
+ }
+ }
.onDrag {
- FeedTransfer(feedID: feed.id).itemProvider
+ insertionPoint = nil
+ return FeedTransfer(feedID: feed.id).itemProvider
} preview: {
FeedLabel(feed: feed)
.padding(6)
@@ -266,6 +295,26 @@ struct SidebarView: View {
}
}
+ private func insertionIndicator(showing: Bool) -> some View {
+ Capsule()
+ .fill(Color.accentColor)
+ .frame(height: 2)
+ .padding(.horizontal, 2)
+ .opacity(showing ? 1 : 0)
+ .scaleEffect(x: showing ? 1 : 0.4, anchor: .leading)
+ .animation(.easeOut(duration: 0.12), value: showing)
+ .allowsHitTesting(false)
+ }
+
+ private func insert(_ providers: [NSItemProvider], relativeTo feed: Feed, above: Bool) -> Bool {
+ guard !providers.isEmpty else { return false }
+ Task {
+ let ids = await FeedTransfer.feedIDs(from: providers)
+ await viewModel.moveFeeds(ids: ids, relativeTo: feed.id, placeAbove: above)
+ }
+ return true
+ }
+
private func move(_ providers: [NSItemProvider], toFolder folder: String?) -> Bool {
guard !providers.isEmpty else { return false }
Task {
diff --git a/frontend-swift/Tests/AppViewModelTests.swift b/frontend-swift/Tests/AppViewModelTests.swift
index b5e461a2..87267c83 100644
--- a/frontend-swift/Tests/AppViewModelTests.swift
+++ b/frontend-swift/Tests/AppViewModelTests.swift
@@ -99,6 +99,7 @@ final class DelayedAPIClient: APIClient {
private(set) var categoryMutations: [(id: Int, category: String)] = []
var categoryUpdateError: Error?
private(set) var lastArticleQuery: (feedID: Int?, category: String?, filter: String)?
+ private(set) var reorderMutations: [(id: Int, category: String, position: Int)] = []
func checkConnection() async throws {}
func fetchFeeds() async throws -> [Feed] { defaultFeeds }
@@ -111,6 +112,11 @@ final class DelayedAPIClient: APIClient {
if let categoryUpdateError { throw categoryUpdateError }
categoryMutations.append((id, category))
}
+
+ func reorderFeed(id: Int, category: String, position: Int) async throws {
+ if let categoryUpdateError { throw categoryUpdateError }
+ reorderMutations.append((id, category, position))
+ }
func refreshAllFeeds() async throws {}
func fetchRefreshProgress() async throws -> RefreshProgress {
RefreshProgress(isRunning: false)
diff --git a/frontend-swift/Tests/FeedDropTargetTests.swift b/frontend-swift/Tests/FeedDropTargetTests.swift
index 9c4b3a11..1713e8e3 100644
--- a/frontend-swift/Tests/FeedDropTargetTests.swift
+++ b/frontend-swift/Tests/FeedDropTargetTests.swift
@@ -4,53 +4,6 @@ import UniformTypeIdentifiers
import XCTest
@testable import MrRSS
-/// Enough of a dragging session to drive a real drop through AppKit. The drop
-/// path cannot be reached from SwiftUI alone, and it is exactly where a
-/// mismatch between the dragged type and the target's registered types goes
-/// unnoticed: the drag still starts, and nothing accepts it.
-private final class StubDraggingInfo: NSObject, NSDraggingInfo {
- var draggingDestinationWindow: NSWindow?
- var draggingSourceOperationMask: NSDragOperation = .move
- var draggingLocation: NSPoint = .zero
- var draggedImageLocation: NSPoint = .zero
- var draggedImage: NSImage?
- var draggingPasteboard: NSPasteboard
- var draggingSource: Any?
- var draggingSequenceNumber: Int = 1
- var draggingFormation: NSDraggingFormation = .default
- var animatesToDestination: Bool = false
- var numberOfValidItemsForDrop: Int = 1
- var springLoadingHighlight: NSSpringLoadingHighlight = .none
- var items: [NSDraggingItem] = []
-
- init(pasteboard: NSPasteboard) {
- draggingPasteboard = pasteboard
- super.init()
- }
-
- func slideDraggedImage(to screenPoint: NSPoint) {}
-
- override func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? {
- nil
- }
-
- func enumerateDraggingItems(
- options enumOpts: NSDraggingItemEnumerationOptions,
- for view: NSView?,
- classes classArray: [AnyClass],
- searchOptions: [NSPasteboard.ReadingOptionKey: Any],
- using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void
- ) {
- var stop: ObjCBool = false
- for (index, item) in items.enumerated() {
- block(item, index, &stop)
- if stop.boolValue { return }
- }
- }
-
- func resetSpringLoading() {}
-}
-
@MainActor
final class FeedDropTargetTests: XCTestCase {
private var defaults: UserDefaults!
@@ -99,23 +52,13 @@ final class FeedDropTargetTests: XCTestCase {
}
private func drop(_ transfer: FeedTransfer, on target: NSView) async throws {
- let item = NSPasteboardItem()
- item.setData(try JSONEncoder().encode(transfer), forType: .init(UTType.mrrssFeed.identifier))
-
- let pasteboard = NSPasteboard(name: .init("MrRSSDropTargetTests-\(UUID().uuidString)"))
- pasteboard.clearContents()
- pasteboard.writeObjects([item])
-
- let info = StubDraggingInfo(pasteboard: pasteboard)
- info.draggingDestinationWindow = target.window
- info.draggingLocation = target.convert(NSPoint(x: 4, y: 4), to: nil)
- info.items = [NSDraggingItem(pasteboardWriter: item)]
+ let result = try SidebarDropProbe.drop(transfer, on: target, at: NSPoint(x: 4, y: 4))
XCTAssertNotEqual(
- target.draggingEntered(info), [],
+ result.entered, [],
"The target refused the drag, so the pointer would show no drop cursor."
)
- XCTAssertTrue(target.performDragOperation(info))
+ XCTAssertTrue(result.performed)
try await Task.sleep(for: .milliseconds(600))
}
@@ -154,13 +97,6 @@ final class FeedDropTargetTests: XCTestCase {
}
private func dropDestinations(in view: NSView) -> [NSView] {
- var found: [NSView] = []
- if !view.registeredDraggedTypes.isEmpty, !(view is NSTableView) {
- found.append(view)
- }
- for subview in view.subviews {
- found.append(contentsOf: dropDestinations(in: subview))
- }
- return found
+ SidebarDropProbe.destinations(in: view)
}
}
diff --git a/frontend-swift/Tests/FeedReorderTests.swift b/frontend-swift/Tests/FeedReorderTests.swift
new file mode 100644
index 00000000..f073cac9
--- /dev/null
+++ b/frontend-swift/Tests/FeedReorderTests.swift
@@ -0,0 +1,151 @@
+import AppKit
+import SwiftUI
+import UniformTypeIdentifiers
+import XCTest
+@testable import MrRSS
+
+@MainActor
+final class FeedReorderTests: XCTestCase {
+ private var defaults: UserDefaults!
+ private var suiteName: String!
+
+ override func setUp() {
+ super.setUp()
+ suiteName = "MrRSSReorderTests-\(UUID().uuidString)"
+ defaults = UserDefaults(suiteName: suiteName)
+ }
+
+ override func tearDown() {
+ defaults.removePersistentDomain(forName: suiteName)
+ defaults = nil
+ suiteName = nil
+ super.tearDown()
+ }
+
+ func testFeedsAreListedInTheOrderTheServerKeeps() async throws {
+ let client = DelayedAPIClient()
+ client.defaultFeeds = [
+ Feed(id: 1, url: "https://a.example.com/feed", title: "A", category: "", position: 2),
+ Feed(id: 2, url: "https://b.example.com/feed", title: "B", category: "", position: 0),
+ Feed(id: 3, url: "https://c.example.com/feed", title: "C", category: "", position: 1)
+ ]
+ let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults)
+ viewModel.refreshFeeds()
+ try await Task.sleep(for: .milliseconds(250))
+
+ XCTAssertEqual(viewModel.feeds.map(\.id), [2, 3, 1])
+ }
+
+ func testDroppingAboveARowRanksTheFeedBeforeIt() async throws {
+ let (client, viewModel) = try await makeViewModel()
+
+ await viewModel.moveFeeds(ids: [3], relativeTo: 1, placeAbove: true)
+
+ XCTAssertEqual(client.reorderMutations.map(\.id), [3])
+ XCTAssertEqual(client.reorderMutations.map(\.position), [0])
+ XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [3, 1, 2])
+ }
+
+ func testDroppingBelowARowRanksTheFeedAfterIt() async throws {
+ let (client, viewModel) = try await makeViewModel()
+
+ await viewModel.moveFeeds(ids: [3], relativeTo: 1, placeAbove: false)
+
+ XCTAssertEqual(client.reorderMutations.map(\.position), [1])
+ XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1, 3, 2])
+ }
+
+ func testDroppingOnARowInsideAFolderFilesTheFeedThere() async throws {
+ let (client, viewModel) = try await makeViewModel()
+
+ await viewModel.moveFeeds(ids: [1], relativeTo: 4, placeAbove: true)
+
+ XCTAssertEqual(client.reorderMutations.map(\.category), ["Tech"])
+ XCTAssertEqual(viewModel.feeds(inFolder: "Tech").map(\.id), [1, 4])
+ XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [2, 3])
+ }
+
+ func testDroppingARowOnItselfChangesNothing() async throws {
+ let (client, viewModel) = try await makeViewModel()
+
+ await viewModel.moveFeeds(ids: [1], relativeTo: 1, placeAbove: true)
+
+ XCTAssertTrue(client.reorderMutations.isEmpty)
+ XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1, 2, 3])
+ }
+
+ func testSeveralFeedsKeepTheOrderTheyWereDraggedIn() async throws {
+ let (client, viewModel) = try await makeViewModel()
+
+ await viewModel.moveFeeds(ids: [2, 3], relativeTo: 1, placeAbove: true)
+
+ XCTAssertEqual(client.reorderMutations.map(\.id), [2, 3])
+ XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [2, 3, 1])
+ }
+
+ func testAFailedReorderLeavesTheListAlone() async throws {
+ let (client, viewModel) = try await makeViewModel()
+ client.categoryUpdateError = URLError(.notConnectedToInternet)
+
+ await viewModel.moveFeeds(ids: [3], relativeTo: 1, placeAbove: true)
+
+ XCTAssertNotNil(viewModel.errorMessage)
+ XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [1, 2, 3])
+ }
+
+ func testTheRowUnderThePointerDecidesWhereTheFeedLands() async throws {
+ for dropOnUpperHalf in [true, false] {
+ let client = DelayedAPIClient()
+ client.defaultFeeds = [
+ Feed(id: 1, url: "https://a.example.com/feed", title: "A", category: "", position: 0),
+ Feed(id: 2, url: "https://b.example.com/feed", title: "B", category: "", position: 1)
+ ]
+ let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults)
+ viewModel.refreshFeeds()
+ try await Task.sleep(for: .milliseconds(250))
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 260, height: 600),
+ styleMask: [.titled, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ let hostingView = NSHostingView(rootView: SidebarView(viewModel: viewModel))
+ window.contentView = hostingView
+ window.makeKeyAndOrderFront(nil)
+ hostingView.layoutSubtreeIfNeeded()
+ try await Task.sleep(for: .milliseconds(600))
+
+ // The first wide destination is the topmost row, which is feed A.
+ let rows = SidebarDropProbe.destinations(in: hostingView).filter { $0.bounds.width > 100 }
+ let target = try XCTUnwrap(rows.first)
+
+ // The sidebar's views are flipped, so the smaller y is the top half.
+ let y = dropOnUpperHalf ? target.bounds.minY + 2 : target.bounds.maxY - 2
+ let result = try SidebarDropProbe.drop(FeedTransfer(feedID: 2), on: target, at: NSPoint(x: 20, y: y))
+ XCTAssertNotEqual(result.entered, [])
+ XCTAssertTrue(result.performed)
+ try await Task.sleep(for: .milliseconds(600))
+
+ XCTAssertEqual(
+ client.reorderMutations.map(\.position),
+ [dropOnUpperHalf ? 0 : 1],
+ "Dropping on the \(dropOnUpperHalf ? "upper" : "lower") half should rank the feed \(dropOnUpperHalf ? "before" : "after") the row."
+ )
+ }
+ }
+
+ private func makeViewModel() async throws -> (DelayedAPIClient, AppViewModel) {
+ let client = DelayedAPIClient()
+ client.defaultFeeds = [
+ Feed(id: 1, url: "https://a.example.com/feed", title: "A", category: "", position: 0),
+ Feed(id: 2, url: "https://b.example.com/feed", title: "B", category: "", position: 1),
+ Feed(id: 3, url: "https://c.example.com/feed", title: "C", category: "", position: 2),
+ Feed(id: 4, url: "https://d.example.com/feed", title: "D", category: "Tech", position: 0)
+ ]
+ let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults)
+ viewModel.refreshFeeds()
+ try await Task.sleep(for: .milliseconds(250))
+ return (client, viewModel)
+ }
+}
diff --git a/frontend-swift/Tests/StubDraggingInfo.swift b/frontend-swift/Tests/StubDraggingInfo.swift
new file mode 100644
index 00000000..ffe8f79d
--- /dev/null
+++ b/frontend-swift/Tests/StubDraggingInfo.swift
@@ -0,0 +1,89 @@
+import AppKit
+import UniformTypeIdentifiers
+@testable import MrRSS
+
+/// Enough of a dragging session to drive a real drop through AppKit. The drop
+/// path cannot be reached from SwiftUI alone, and it is exactly where a
+/// mismatch between the dragged type and a target's registered types goes
+/// unnoticed: the drag still starts, and nothing accepts it.
+final class StubDraggingInfo: NSObject, NSDraggingInfo {
+ var draggingDestinationWindow: NSWindow?
+ var draggingSourceOperationMask: NSDragOperation = .move
+ var draggingLocation: NSPoint = .zero
+ var draggedImageLocation: NSPoint = .zero
+ var draggedImage: NSImage?
+ var draggingPasteboard: NSPasteboard
+ var draggingSource: Any?
+ var draggingSequenceNumber: Int = 1
+ var draggingFormation: NSDraggingFormation = .default
+ var animatesToDestination: Bool = false
+ var numberOfValidItemsForDrop: Int = 1
+ var springLoadingHighlight: NSSpringLoadingHighlight = .none
+ var items: [NSDraggingItem] = []
+
+ init(pasteboard: NSPasteboard) {
+ draggingPasteboard = pasteboard
+ super.init()
+ }
+
+ func slideDraggedImage(to screenPoint: NSPoint) {}
+
+ override func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? {
+ nil
+ }
+
+ func enumerateDraggingItems(
+ options enumOpts: NSDraggingItemEnumerationOptions,
+ for view: NSView?,
+ classes classArray: [AnyClass],
+ searchOptions: [NSPasteboard.ReadingOptionKey: Any],
+ using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void
+ ) {
+ var stop: ObjCBool = false
+ for (index, item) in items.enumerated() {
+ block(item, index, &stop)
+ if stop.boolValue { return }
+ }
+ }
+
+ func resetSpringLoading() {}
+}
+
+enum SidebarDropProbe {
+ /// Drops `transfer` at a point given in the target's own coordinates. The
+ /// sidebar's views are flipped, so a larger y is further down the screen.
+ @MainActor
+ static func drop(
+ _ transfer: FeedTransfer,
+ on target: NSView,
+ at point: NSPoint
+ ) throws -> (entered: NSDragOperation, performed: Bool) {
+ let item = NSPasteboardItem()
+ item.setData(try JSONEncoder().encode(transfer), forType: .init(UTType.mrrssFeed.identifier))
+
+ let pasteboard = NSPasteboard(name: .init("MrRSSDropProbe-\(UUID().uuidString)"))
+ pasteboard.clearContents()
+ pasteboard.writeObjects([item])
+
+ let info = StubDraggingInfo(pasteboard: pasteboard)
+ info.draggingDestinationWindow = target.window
+ info.draggingLocation = target.convert(point, to: nil)
+ info.items = [NSDraggingItem(pasteboardWriter: item)]
+
+ let entered = target.draggingEntered(info)
+ _ = target.draggingUpdated(info)
+ return (entered, target.performDragOperation(info))
+ }
+
+ @MainActor
+ static func destinations(in root: NSView) -> [NSView] {
+ var found: [NSView] = []
+ if !root.registeredDraggedTypes.isEmpty, !(root is NSTableView) {
+ found.append(root)
+ }
+ for subview in root.subviews {
+ found.append(contentsOf: destinations(in: subview))
+ }
+ return found
+ }
+}
From 70a1cab7caf26399859121319d285b05695a2aa8 Mon Sep 17 00:00:00 2001
From: WCY-dt <834421194@qq.com>
Date: Tue, 25 Aug 2026 00:36:51 +0800
Subject: [PATCH 17/59] release: Upgrade version to v1.3.28
---
CHANGELOG.md | 2 ++
README.md | 2 +-
README_zh.md | 2 +-
build/config.yml | 4 ++--
build/linux/Taskfile.yml | 2 +-
build/linux/nfpm/nfpm.yaml | 2 +-
docs/SERVER_MODE/swagger.json | 2 +-
frontend/package-lock.json | 4 ++--
frontend/package.json | 2 +-
internal/version/version.go | 2 +-
main-core.go | 2 +-
skills/mrrss-assistant/references/api.md | 2 +-
website/package-lock.json | 4 ++--
website/package.json | 2 +-
14 files changed, 18 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index be755a3b..2f21f2a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@ All notable changes to MrRSS will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
## [1.3.27] - 2026-08-22
### Added
diff --git a/README.md b/README.md
index 794dc1c3..913afecb 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
English | 简体中文
-[](https://github.com/DevXDojo/MrRSS/releases)
+[](https://github.com/DevXDojo/MrRSS/releases)
[](LICENSE)
[](https://go.dev/)
[](https://wails.io/)
diff --git a/README_zh.md b/README_zh.md
index 60fb9046..d34e7e82 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -8,7 +8,7 @@
English | 简体中文
-[](https://github.com/DevXDojo/MrRSS/releases)
+[](https://github.com/DevXDojo/MrRSS/releases)
[](LICENSE)
[](https://go.dev/)
[](https://wails.io/)
diff --git a/build/config.yml b/build/config.yml
index 2b4ef518..3e19293f 100644
--- a/build/config.yml
+++ b/build/config.yml
@@ -11,7 +11,7 @@ info:
description: "A modern, standalone RSS reader" # The application description
copyright: "Copyright © 2026" # Copyright text
comments: "Built with Wails" # Comments
- version: "1.3.27" # The application version
+ version: "1.3.28" # The application version
# iOS build configuration (uncomment to customise iOS project generation)
# Note: Keys under `ios` OVERRIDE values under `info` when set.
@@ -21,7 +21,7 @@ info:
# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
# displayName: "MrRSS"
# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
-# version: "1.3.27"
+# version: "1.3.28"
# # The company/organisation name for templates and project settings
# company: "Ch3nyang"
# # Additional comments to embed in Info.plist metadata
diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml
index ce2a0950..6cd23c03 100644
--- a/build/linux/Taskfile.yml
+++ b/build/linux/Taskfile.yml
@@ -124,7 +124,7 @@ tasks:
cmds:
- cd {{.BIN_DIR}} && tar -czf {{.APP_NAME}}-{{.VERSION}}-linux-{{.ARCH}}.tar.gz {{.APP_NAME}}
vars:
- VERSION: '1.3.27'
+ VERSION: '1.3.28'
ARCH: '{{.ARCH | default "amd64"}}'
generate:dotdesktop:
diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml
index 0a39500b..c35c35ed 100644
--- a/build/linux/nfpm/nfpm.yaml
+++ b/build/linux/nfpm/nfpm.yaml
@@ -6,7 +6,7 @@
name: "MrRSS"
arch: ${GOARCH}
platform: "linux"
-version: "1.3.27"
+version: "1.3.28"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
diff --git a/docs/SERVER_MODE/swagger.json b/docs/SERVER_MODE/swagger.json
index d7bbfd6a..7a8de4e6 100644
--- a/docs/SERVER_MODE/swagger.json
+++ b/docs/SERVER_MODE/swagger.json
@@ -12,7 +12,7 @@
"name": "GPL-3.0",
"url": "https://www.gnu.org/licenses/gpl-3.0.en.html"
},
- "version": "1.3.27"
+ "version": "1.3.28"
},
"host": "localhost:1234",
"basePath": "/api",
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1015cbe9..47c84169 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mrrss-frontend",
- "version": "1.3.27",
+ "version": "1.3.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mrrss-frontend",
- "version": "1.3.27",
+ "version": "1.3.28",
"dependencies": {
"@eslint/js": "^10.0.1",
"@phosphor-icons/vue": "^2.2.1",
diff --git a/frontend/package.json b/frontend/package.json
index c41f0fd8..cc7afa09 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "mrrss-frontend",
"private": true,
- "version": "1.3.27",
+ "version": "1.3.28",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/internal/version/version.go b/internal/version/version.go
index c91838ae..797f5719 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -1,4 +1,4 @@
package version
// Version is the current application version
-const Version = "1.3.27"
+const Version = "1.3.28"
diff --git a/main-core.go b/main-core.go
index 04a9d9aa..3ce5e611 100644
--- a/main-core.go
+++ b/main-core.go
@@ -31,7 +31,7 @@ import (
)
// @title MrRSS API
-// @version 1.3.27
+// @version 1.3.28
// @description MrRSS is a modern, cross-platform desktop RSS reader with auto-translation, smart feed discovery, and AI-powered summarization.
// @contact.name API Support
diff --git a/skills/mrrss-assistant/references/api.md b/skills/mrrss-assistant/references/api.md
index 866d3eb3..6f1a1cf2 100644
--- a/skills/mrrss-assistant/references/api.md
+++ b/skills/mrrss-assistant/references/api.md
@@ -6,7 +6,7 @@ Generated from `docs/SERVER_MODE/swagger.json`. Regenerate with:
python skills/mrrss-assistant/scripts/generate_api_reference.py docs/SERVER_MODE/swagger.json skills/mrrss-assistant/references/api.md
```
-- API version: `1.3.27`
+- API version: `1.3.28`
- API root: `{base_url}/api`
- Endpoint paths below are relative to the API root unless they already start with `/api/`.
diff --git a/website/package-lock.json b/website/package-lock.json
index f8221aec..a745f994 100644
--- a/website/package-lock.json
+++ b/website/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mrrss-website",
- "version": "1.3.27",
+ "version": "1.3.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mrrss-website",
- "version": "1.3.27",
+ "version": "1.3.28",
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
"vue": "^3.5.41"
diff --git a/website/package.json b/website/package.json
index ba9f6283..cf0f91f5 100644
--- a/website/package.json
+++ b/website/package.json
@@ -1,6 +1,6 @@
{
"name": "mrrss-website",
- "version": "1.3.27",
+ "version": "1.3.28",
"description": "MrRSS - AI-Powered RSS Reader Landing Page",
"type": "module",
"scripts": {
From ee185d20b9a2426a1a59f8f727c8f03cccef01f7 Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Tue, 25 Aug 2026 07:01:35 +0800
Subject: [PATCH 18/59] feat(macos): open a gap under the drag instead of
drawing a bar
---
frontend-swift/README.md | 2 +-
.../Sources/ViewModels/AppViewModel.swift | 38 +++++
.../Sources/Views/FeedRowDropDelegate.swift | 6 -
.../Sources/Views/SidebarView.swift | 146 +++++++++++++-----
.../Tests/FeedDragPreviewTests.swift | 94 +++++++++++
5 files changed, 241 insertions(+), 45 deletions(-)
create mode 100644 frontend-swift/Tests/FeedDragPreviewTests.swift
diff --git a/frontend-swift/README.md b/frontend-swift/README.md
index 4a373fdc..e4fadefe 100644
--- a/frontend-swift/README.md
+++ b/frontend-swift/README.md
@@ -60,4 +60,4 @@ The release application contains both the SwiftUI frontend and reusable Go backe
The application provides native three-column navigation, feed subscription management, folders for grouping subscriptions, source refresh, article filtering, pagination, read/unread and favorite actions, translation, summaries, automation rules, complete backend settings management, configurable server connectivity, and restricted HTML rendering for untrusted feed content.
-Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, and back out by dropping it on the Feeds heading. The row's context menu offers the same moves. Dropping a subscription on the upper or lower half of another row files it directly before or after that row, and the sidebar shows a line where it will land. The resulting order is the `position` the server records, so it is shared with the Vue frontend. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it.
+Folders are the `category` recorded on each feed, so they are shared with the Vue frontend. A subscription moves into a folder by dragging its row onto the folder, and back out by dropping it on the Feeds heading. The row's context menu offers the same moves. Dropping a subscription on the upper or lower half of another row files it directly before or after that row, and the rows around it open up while the drag is in flight to show where it would land. The resulting order is the `position` the server records, so it is shared with the Vue frontend. A folder that has been created but holds no feeds yet has nowhere to live on the server, so its name is remembered on the Mac until a feed moves into it.
diff --git a/frontend-swift/Sources/ViewModels/AppViewModel.swift b/frontend-swift/Sources/ViewModels/AppViewModel.swift
index 7facb006..0c9732d1 100644
--- a/frontend-swift/Sources/ViewModels/AppViewModel.swift
+++ b/frontend-swift/Sources/ViewModels/AppViewModel.swift
@@ -28,6 +28,15 @@ enum ArticleFilter: String, CaseIterable, Identifiable {
}
}
+/// The live picture of a drag in progress: which subscription is travelling,
+/// and where the sidebar currently offers to put it. It drives the preview
+/// only; nothing is written until the drop happens.
+struct FeedDragSession: Equatable {
+ let feedID: Int
+ var folder: String
+ var index: Int
+}
+
enum SidebarItem: Hashable {
case filter(ArticleFilter)
case folder(String)
@@ -250,6 +259,23 @@ final class AppViewModel: ObservableObject {
feeds.filter { $0.category == folder }
}
+ /// The folder's rows as they should read while a drag is in flight: the
+ /// travelling subscription is lifted out of where it came from and shown
+ /// where it would land, so the rows around it open up for it.
+ func arrangedFeeds(inFolder folder: String, previewing session: FeedDragSession?) -> [Feed] {
+ var rows = feeds(inFolder: folder)
+ guard let session else { return rows }
+
+ rows.removeAll { $0.id == session.feedID }
+ guard session.folder == folder,
+ let travelling = feeds.first(where: { $0.id == session.feedID }) else {
+ return rows
+ }
+
+ rows.insert(travelling, at: min(max(0, session.index), rows.count))
+ return rows
+ }
+
var unfiledFeeds: [Feed] {
feeds.filter { $0.category.isEmpty }
}
@@ -322,6 +348,18 @@ final class AppViewModel: ObservableObject {
}
}
+ /// Commits what the drag preview was showing.
+ func placeFeed(id: Int, inFolder folder: String, at index: Int) async {
+ guard let feed = feeds.first(where: { $0.id == id }) else { return }
+
+ let siblings = feeds(inFolder: folder).filter { $0.id != id }
+ let clamped = min(max(0, index), siblings.count)
+ let currentIndex = feeds(inFolder: folder).firstIndex(where: { $0.id == id })
+ guard feed.category != folder || currentIndex != clamped else { return }
+
+ await reorderFeed(id: id, category: folder, index: clamped)
+ }
+
private func reorderFeed(id: Int, category: String, index: Int) async {
do {
try await api.reorderFeed(id: id, category: category, position: index)
diff --git a/frontend-swift/Sources/Views/FeedRowDropDelegate.swift b/frontend-swift/Sources/Views/FeedRowDropDelegate.swift
index a48b6bf6..2dff25bc 100644
--- a/frontend-swift/Sources/Views/FeedRowDropDelegate.swift
+++ b/frontend-swift/Sources/Views/FeedRowDropDelegate.swift
@@ -37,9 +37,3 @@ struct FeedRowDropDelegate: DropDelegate {
info.location.y < rowHeight / 2
}
}
-
-/// Where the sidebar will place a dragged subscription.
-struct FeedInsertionPoint: Equatable {
- let feedID: Int
- let above: Bool
-}
diff --git a/frontend-swift/Sources/Views/SidebarView.swift b/frontend-swift/Sources/Views/SidebarView.swift
index 4041332c..395d5a02 100644
--- a/frontend-swift/Sources/Views/SidebarView.swift
+++ b/frontend-swift/Sources/Views/SidebarView.swift
@@ -10,7 +10,8 @@ struct SidebarView: View {
@State private var folderPrompt: FolderPrompt?
@State private var dropTargetFolder: String?
@State private var isDropTargetingRoot = false
- @State private var insertionPoint: FeedInsertionPoint?
+ @State private var dragSession: FeedDragSession?
+ @State private var hoverGeneration = 0
var body: some View {
VStack(spacing: 0) {
@@ -81,11 +82,21 @@ struct SidebarView: View {
// of whatever folder it is in.
Text("Feeds")
.padding(.horizontal, 4)
- .background(
- RoundedRectangle(cornerRadius: 4)
- .fill(isDropTargetingRoot ? Color.accentColor.opacity(0.25) : .clear)
- )
- .onDrop(of: [.mrrssFeed], isTargeted: $isDropTargetingRoot) { providers in
+ .background(dropHighlight(active: isDropTargetingRoot))
+ .onDrop(
+ of: [.mrrssFeed],
+ isTargeted: Binding(
+ get: { isDropTargetingRoot },
+ set: { isTargeted in
+ isDropTargetingRoot = isTargeted
+ if isTargeted {
+ previewMove(intoFolder: "")
+ } else {
+ scheduleDragSessionClear()
+ }
+ }
+ )
+ ) { providers in
move(providers, toFolder: nil)
}
}
@@ -177,7 +188,7 @@ struct SidebarView: View {
}
)
) {
- ForEach(viewModel.feeds(inFolder: folder)) { feed in
+ ForEach(viewModel.arrangedFeeds(inFolder: folder, previewing: dragSession)) { feed in
feedRow(feed)
}
} label: {
@@ -192,10 +203,7 @@ struct SidebarView: View {
.tag(SidebarItem.folder(folder))
.padding(.vertical, 2)
.padding(.horizontal, 4)
- .background(
- RoundedRectangle(cornerRadius: 5)
- .fill(dropTargetFolder == folder ? Color.accentColor.opacity(0.25) : .clear)
- )
+ .background(dropHighlight(active: dropTargetFolder == folder))
.onDrop(
of: [.mrrssFeed],
isTargeted: Binding(
@@ -204,8 +212,10 @@ struct SidebarView: View {
if isTargeted {
dropTargetFolder = folder
expandedFolders.insert(folder)
+ previewMove(intoFolder: folder)
} else if dropTargetFolder == folder {
dropTargetFolder = nil
+ scheduleDragSessionClear()
}
}
)
@@ -227,35 +237,24 @@ struct SidebarView: View {
FeedLabel(feed: feed)
.badge(viewModel.unreadCounts.feedCounts[feed.id] ?? 0)
.tag(SidebarItem.feed(feed.id))
- .overlay(alignment: .top) {
- insertionIndicator(showing: insertionPoint == FeedInsertionPoint(feedID: feed.id, above: true))
- }
- .overlay(alignment: .bottom) {
- insertionIndicator(showing: insertionPoint == FeedInsertionPoint(feedID: feed.id, above: false))
- }
+ .opacity(dragSession?.feedID == feed.id ? 0.4 : 1)
.background {
GeometryReader { geometry in
Color.clear.onDrop(
of: [.mrrssFeed],
delegate: FeedRowDropDelegate(
rowHeight: geometry.size.height,
- onHover: { above in
- insertionPoint = FeedInsertionPoint(feedID: feed.id, above: above)
- },
- onExit: {
- if insertionPoint?.feedID == feed.id {
- insertionPoint = nil
- }
- },
+ onHover: { above in previewInsertion(near: feed, above: above) },
+ onExit: { scheduleDragSessionClear() },
onDrop: { above, providers in
- insert(providers, relativeTo: feed, above: above)
+ drop(providers, near: feed, above: above)
}
)
)
}
}
.onDrag {
- insertionPoint = nil
+ startDragSession(for: feed)
return FeedTransfer(feedID: feed.id).itemProvider
} preview: {
FeedLabel(feed: feed)
@@ -295,28 +294,99 @@ struct SidebarView: View {
}
}
- private func insertionIndicator(showing: Bool) -> some View {
- Capsule()
- .fill(Color.accentColor)
- .frame(height: 2)
- .padding(.horizontal, 2)
- .opacity(showing ? 1 : 0)
- .scaleEffect(x: showing ? 1 : 0.4, anchor: .leading)
- .animation(.easeOut(duration: 0.12), value: showing)
- .allowsHitTesting(false)
+ private func startDragSession(for feed: Feed) {
+ let siblings = viewModel.feeds(inFolder: feed.category)
+ dragSession = FeedDragSession(
+ feedID: feed.id,
+ folder: feed.category,
+ index: siblings.firstIndex(where: { $0.id == feed.id }) ?? siblings.count
+ )
+ }
+
+ /// Opens a gap where the subscription would land. Hovering the travelling
+ /// row itself changes nothing, which is what keeps the preview from
+ /// oscillating as the rows move out of the way.
+ private func previewInsertion(near feed: Feed, above: Bool) {
+ hoverGeneration += 1
+ guard let session = dragSession, session.feedID != feed.id else { return }
+
+ let siblings = viewModel.feeds(inFolder: feed.category).filter { $0.id != session.feedID }
+ let anchor = siblings.firstIndex(where: { $0.id == feed.id }) ?? siblings.count
+ let updated = FeedDragSession(
+ feedID: session.feedID,
+ folder: feed.category,
+ index: above ? anchor : anchor + 1
+ )
+
+ guard updated != session else { return }
+ withAnimation(.snappy(duration: 0.18)) {
+ dragSession = updated
+ }
+ }
+
+ /// A drag that leaves one row usually enters another straight away, so the
+ /// preview is only dropped once nothing has claimed it.
+ private func scheduleDragSessionClear() {
+ hoverGeneration += 1
+ let generation = hoverGeneration
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
+ guard generation == hoverGeneration else { return }
+ withAnimation(.snappy(duration: 0.18)) {
+ dragSession = nil
+ }
+ }
}
- private func insert(_ providers: [NSItemProvider], relativeTo feed: Feed, above: Bool) -> Bool {
+ private func drop(_ providers: [NSItemProvider], near feed: Feed, above: Bool) -> Bool {
guard !providers.isEmpty else { return false }
+
+ let session = dragSession
+ withAnimation(.snappy(duration: 0.18)) {
+ dragSession = nil
+ }
+
Task {
let ids = await FeedTransfer.feedIDs(from: providers)
- await viewModel.moveFeeds(ids: ids, relativeTo: feed.id, placeAbove: above)
+ // Commit what the preview was showing; a drag from elsewhere has no
+ // preview, so fall back to the row the pointer is on.
+ if let session, ids == [session.feedID] {
+ await viewModel.placeFeed(id: session.feedID, inFolder: session.folder, at: session.index)
+ } else {
+ await viewModel.moveFeeds(ids: ids, relativeTo: feed.id, placeAbove: above)
+ }
}
return true
}
+ /// The system's own drop tint, rather than a colour of our choosing.
+ private func dropHighlight(active: Bool) -> some View {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color(nsColor: .selectedContentBackgroundColor).opacity(active ? 0.28 : 0))
+ .animation(.easeOut(duration: 0.12), value: active)
+ }
+
+ /// Lifts the travelling subscription out of the list it came from while the
+ /// pointer rests on a folder, so the gap it leaves behind closes up.
+ private func previewMove(intoFolder folder: String) {
+ hoverGeneration += 1
+ guard let session = dragSession else { return }
+
+ let updated = FeedDragSession(
+ feedID: session.feedID,
+ folder: folder,
+ index: viewModel.feeds(inFolder: folder).filter { $0.id != session.feedID }.count
+ )
+ guard updated != session else { return }
+ withAnimation(.snappy(duration: 0.18)) {
+ dragSession = updated
+ }
+ }
+
private func move(_ providers: [NSItemProvider], toFolder folder: String?) -> Bool {
guard !providers.isEmpty else { return false }
+ withAnimation(.snappy(duration: 0.18)) {
+ dragSession = nil
+ }
Task {
let ids = await FeedTransfer.feedIDs(from: providers)
await viewModel.moveFeeds(ids: ids, toFolder: folder)
diff --git a/frontend-swift/Tests/FeedDragPreviewTests.swift b/frontend-swift/Tests/FeedDragPreviewTests.swift
new file mode 100644
index 00000000..227e4384
--- /dev/null
+++ b/frontend-swift/Tests/FeedDragPreviewTests.swift
@@ -0,0 +1,94 @@
+import XCTest
+@testable import MrRSS
+
+@MainActor
+final class FeedDragPreviewTests: XCTestCase {
+ private var defaults: UserDefaults!
+ private var suiteName: String!
+
+ override func setUp() {
+ super.setUp()
+ suiteName = "MrRSSDragPreviewTests-\(UUID().uuidString)"
+ defaults = UserDefaults(suiteName: suiteName)
+ }
+
+ override func tearDown() {
+ defaults.removePersistentDomain(forName: suiteName)
+ defaults = nil
+ suiteName = nil
+ super.tearDown()
+ }
+
+ func testWithoutADragTheRowsReadInTheirStoredOrder() async throws {
+ let (_, viewModel) = try await makeViewModel()
+
+ XCTAssertEqual(viewModel.arrangedFeeds(inFolder: "", previewing: nil).map(\.id), [1, 2, 3])
+ }
+
+ func testTheRowsOpenUpWhereTheSubscriptionWouldLand() async throws {
+ let (_, viewModel) = try await makeViewModel()
+
+ // C is on its way to the top, so A and B move down to make room.
+ let session = FeedDragSession(feedID: 3, folder: "", index: 0)
+
+ XCTAssertEqual(viewModel.arrangedFeeds(inFolder: "", previewing: session).map(\.id), [3, 1, 2])
+ }
+
+ func testTheGapLeftBehindClosesWhileTheSubscriptionIsOverAnotherFolder() async throws {
+ let (_, viewModel) = try await makeViewModel()
+
+ let session = FeedDragSession(feedID: 1, folder: "Tech", index: 0)
+
+ XCTAssertEqual(viewModel.arrangedFeeds(inFolder: "", previewing: session).map(\.id), [2, 3])
+ XCTAssertEqual(viewModel.arrangedFeeds(inFolder: "Tech", previewing: session).map(\.id), [1, 4])
+ }
+
+ func testAnIndexBeyondTheListLandsAtTheEnd() async throws {
+ let (_, viewModel) = try await makeViewModel()
+
+ let session = FeedDragSession(feedID: 1, folder: "", index: 99)
+
+ XCTAssertEqual(viewModel.arrangedFeeds(inFolder: "", previewing: session).map(\.id), [2, 3, 1])
+ }
+
+ func testCommittingThePreviewRanksTheSubscriptionThere() async throws {
+ let (client, viewModel) = try await makeViewModel()
+
+ await viewModel.placeFeed(id: 3, inFolder: "", at: 0)
+
+ XCTAssertEqual(client.reorderMutations.map(\.id), [3])
+ XCTAssertEqual(client.reorderMutations.map(\.position), [0])
+ XCTAssertEqual(viewModel.unfiledFeeds.map(\.id), [3, 1, 2])
+ }
+
+ func testCommittingAPreviewThatChangesNothingSkipsTheServer() async throws {
+ let (client, viewModel) = try await makeViewModel()
+
+ await viewModel.placeFeed(id: 2, inFolder: "", at: 1)
+
+ XCTAssertTrue(client.reorderMutations.isEmpty)
+ }
+
+ func testCommittingAPreviewIntoAnotherFolderFilesItThere() async throws {
+ let (client, viewModel) = try await makeViewModel()
+
+ await viewModel.placeFeed(id: 1, inFolder: "Tech", at: 0)
+
+ XCTAssertEqual(client.reorderMutations.map(\.category), ["Tech"])
+ XCTAssertEqual(viewModel.feeds(inFolder: "Tech").map(\.id), [1, 4])
+ }
+
+ private func makeViewModel() async throws -> (DelayedAPIClient, AppViewModel) {
+ let client = DelayedAPIClient()
+ client.defaultFeeds = [
+ Feed(id: 1, url: "https://a.example.com/feed", title: "A", category: "", position: 0),
+ Feed(id: 2, url: "https://b.example.com/feed", title: "B", category: "", position: 1),
+ Feed(id: 3, url: "https://c.example.com/feed", title: "C", category: "", position: 2),
+ Feed(id: 4, url: "https://d.example.com/feed", title: "D", category: "Tech", position: 0)
+ ]
+ let viewModel = AppViewModel(api: client, autoLoad: false, defaults: defaults)
+ viewModel.refreshFeeds()
+ try await Task.sleep(for: .milliseconds(250))
+ return (client, viewModel)
+ }
+}
From 9d0f3346d6c49709016611ea86f3e4178848b764 Mon Sep 17 00:00:00 2001
From: marcomarcogd <35049765+marcomarcogd@users.noreply.github.com>
Date: Tue, 25 Aug 2026 10:49:46 +0800
Subject: [PATCH 19/59] fix(ui): prevent long toast messages from overflowing
---
frontend/src/App.test.ts | 9 +++++++++
frontend/src/components/common/Toast.vue | 21 +++++++++++++--------
2 files changed, 22 insertions(+), 8 deletions(-)
diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts
index 2605314e..6ab72799 100644
--- a/frontend/src/App.test.ts
+++ b/frontend/src/App.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
+import { readFileSync } from 'node:fs';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { createPinia } from 'pinia';
@@ -15,6 +16,14 @@ const createStub = (name: string) => ({
});
describe('App', () => {
+ it('keeps long toast messages inside narrow viewports', () => {
+ const toast = readFileSync('src/components/common/Toast.vue', 'utf8');
+ expect(toast).toContain('calc(100vw-2rem)');
+ expect(toast).toContain('overflow-wrap: anywhere');
+ expect(toast).toContain('min-w-0 flex-1');
+ expect(toast).toContain('shrink-0');
+ });
+
it('renders and reacts to interface typography settings', async () => {
setSettingsFromRawData({});
const pinia = createPinia();
diff --git a/frontend/src/components/common/Toast.vue b/frontend/src/components/common/Toast.vue
index b0f32238..6b7d3716 100644
--- a/frontend/src/components/common/Toast.vue
+++ b/frontend/src/components/common/Toast.vue
@@ -38,13 +38,16 @@ function handleClose() {
-
-
-
-
-
-
{{ message }}
-
diff --git a/frontend/src/components/modals/settings/ai/AIProfileModal.vue b/frontend/src/components/modals/settings/ai/AIProfileModal.vue
index 6aab4d5a..c91e6196 100644
--- a/frontend/src/components/modals/settings/ai/AIProfileModal.vue
+++ b/frontend/src/components/modals/settings/ai/AIProfileModal.vue
@@ -17,6 +17,7 @@ import type { AIProfileFormData, AIProfileTestResult } from '@/types/aiProfile';
import type { Status } from '@/components/settings/base/StatusBox.vue';
import { defaultAIProfileFormData } from '@/types/aiProfile';
import { useAIProfiles } from '@/composables/ai/useAIProfiles';
+import { getAIErrorMessage } from '@/utils/aiError';
import { openInBrowser } from '@/utils/browser';
const { t, locale } = useI18n();
@@ -105,7 +106,7 @@ async function testConfiguration() {
if (result) {
testResult.value = result;
if (!result.config_valid || !result.connection_success) {
- testError.value = result.error_message || t('setting.ai.aiTestFailed');
+ testError.value = getAIErrorMessage(result);
}
} else {
testError.value = t('setting.ai.aiTestFailed');
@@ -155,7 +156,7 @@ async function saveProfile() {
}
} catch (e) {
console.error('Save failed:', e);
- saveError.value = e instanceof Error ? e.message : t('setting.ai.saveFailed');
+ saveError.value = getAIErrorMessage(e);
} finally {
isSaving.value = false;
}
diff --git a/frontend/src/composables/ai/useAIProfiles.ts b/frontend/src/composables/ai/useAIProfiles.ts
index 2f338d1a..b101e023 100644
--- a/frontend/src/composables/ai/useAIProfiles.ts
+++ b/frontend/src/composables/ai/useAIProfiles.ts
@@ -1,6 +1,7 @@
import { ref, computed } from 'vue';
import type { AIProfile, AIProfileTestResult, AIProfileFormData } from '@/types/aiProfile';
import { defaultAIProfileFormData } from '@/types/aiProfile';
+import { readAIError } from '@/utils/aiError';
// Shared state for AI profiles
const profiles = ref([]);
@@ -59,8 +60,7 @@ export function useAIProfiles() {
});
if (!response.ok) {
- const errorText = await response.text();
- throw new Error(errorText || `Failed to create profile: ${response.status}`);
+ throw new Error((await readAIError(response)).message);
}
const newProfile = await response.json();
@@ -82,8 +82,7 @@ export function useAIProfiles() {
});
if (!response.ok) {
- const errorText = await response.text();
- throw new Error(errorText || `Failed to update profile: ${response.status}`);
+ throw new Error((await readAIError(response)).message);
}
const updatedProfile = await response.json();
diff --git a/frontend/src/composables/article/useArticleSummary.ts b/frontend/src/composables/article/useArticleSummary.ts
index 15e68bf7..bb742d62 100644
--- a/frontend/src/composables/article/useArticleSummary.ts
+++ b/frontend/src/composables/article/useArticleSummary.ts
@@ -1,6 +1,7 @@
import { ref, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Article } from '@/types/models';
+import { getAIErrorMessage, readAIError } from '@/utils/aiError';
interface SummarySettings {
enabled: boolean;
@@ -116,18 +117,7 @@ export function useArticleSummary() {
return data;
} else {
- // Handle API errors properly
- let errorMessage = `${t('setting.content.summaryGenerationFailed')}: ${res.status} ${res.statusText}`;
-
- try {
- const errorData = await res.json();
- if (errorData.error) {
- errorMessage = errorData.error;
- }
- } catch (jsonError) {
- // If we can't parse JSON, use the status text
- console.error('Error parsing error response:', jsonError);
- }
+ const { message: errorMessage } = await readAIError(res);
console.error('Summary generation failed:', errorMessage);
@@ -148,7 +138,7 @@ export function useArticleSummary() {
return null;
}
- const errorMessage = `${t('setting.content.summaryGenerationFailed')}: ${e instanceof Error ? e.message : t('common.errors.unknownError')}`;
+ const errorMessage = getAIErrorMessage(e);
console.error('Error generating summary:', e);
// Cache the error to show in UI
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 78901587..eaf5288f 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -146,6 +146,26 @@ const en: TranslationMessages = {
searchFailed: 'AI search failed. Please check your AI settings.',
showingResults: 'Showing AI search results',
},
+ aiErrors: {
+ configuration_invalid:
+ 'The AI configuration is incomplete or invalid. Check the endpoint and model.',
+ usage_limit_reached:
+ 'The AI usage limit configured in MrRSS has been reached. Adjust it and try again.',
+ rate_limited: 'The AI service is receiving too many requests. Please try again later.',
+ authentication_failed: 'AI authentication failed. Check the API key and access permissions.',
+ payment_required:
+ 'The AI service has insufficient quota or balance. Check the provider account.',
+ model_or_endpoint_not_found:
+ 'The AI model or endpoint is unavailable. Check the configuration.',
+ request_too_large: 'The content sent to AI is too large. Shorten it or choose another model.',
+ timeout: 'The AI service response timed out. Please try again.',
+ network_error: 'Could not reach the AI service. Check the network, proxy, and endpoint.',
+ provider_unavailable: 'The AI service is temporarily unavailable. Please try again later.',
+ invalid_response: 'The AI service returned an invalid response. Retry or choose another model.',
+ provider_rejected_request:
+ 'The AI service rejected the request. Check the model and endpoint settings.',
+ request_failed: 'The AI request failed. Check the AI configuration and try again.',
+ },
common: {
cancel: 'Cancel',
confirm: 'Confirm',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 8bc59a00..22b74255 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -141,6 +141,21 @@ const zh: TranslationMessages = {
searchFailed: 'AI 搜索失败,请检查 AI 设置。',
showingResults: '正在显示 AI 搜索结果',
},
+ aiErrors: {
+ configuration_invalid: 'AI 配置不完整或无效,请检查接口地址和模型。',
+ usage_limit_reached: '已达到 MrRSS 设置的 AI 使用上限,请调整上限后重试。',
+ rate_limited: 'AI 服务请求过于频繁,请稍后再试。',
+ authentication_failed: 'AI 服务鉴权失败,请检查 API Key 和访问权限。',
+ payment_required: 'AI 服务额度不足,请检查服务商账户余额。',
+ model_or_endpoint_not_found: 'AI 模型或接口地址不可用,请检查配置。',
+ request_too_large: '发送给 AI 的内容过长,请缩短内容或更换模型。',
+ timeout: 'AI 服务响应超时,请稍后重试。',
+ network_error: '无法连接 AI 服务,请检查网络、代理和接口地址。',
+ provider_unavailable: 'AI 服务暂时不可用,请稍后重试。',
+ invalid_response: 'AI 服务返回内容格式异常,请重试或更换模型。',
+ provider_rejected_request: 'AI 服务拒绝了请求,请检查模型和接口配置。',
+ request_failed: 'AI 请求失败,请检查 AI 配置后重试。',
+ },
common: {
cancel: '取消',
confirm: '确认',
diff --git a/frontend/src/types/aiProfile.ts b/frontend/src/types/aiProfile.ts
index 80a2dc87..a46e68f9 100644
--- a/frontend/src/types/aiProfile.ts
+++ b/frontend/src/types/aiProfile.ts
@@ -22,6 +22,7 @@ export interface AIProfileTestResult {
model_available: boolean;
response_time_ms: number;
error_message?: string;
+ error_code?: string;
}
export interface AIProfileFormData {
diff --git a/frontend/src/utils/aiError.ts b/frontend/src/utils/aiError.ts
new file mode 100644
index 00000000..d3a848cc
--- /dev/null
+++ b/frontend/src/utils/aiError.ts
@@ -0,0 +1,118 @@
+import i18n from '@/i18n';
+
+export interface ParsedAIError {
+ code: string;
+ message: string;
+ payload?: unknown;
+}
+
+const knownCodes = new Set([
+ 'configuration_invalid',
+ 'usage_limit_reached',
+ 'rate_limited',
+ 'authentication_failed',
+ 'payment_required',
+ 'model_or_endpoint_not_found',
+ 'request_too_large',
+ 'timeout',
+ 'network_error',
+ 'provider_unavailable',
+ 'invalid_response',
+ 'provider_rejected_request',
+ 'request_failed',
+]);
+
+const legacyCodeAliases: Record = {
+ AI_CONFIG_FAILED: 'configuration_invalid',
+ AI_QUOTA_EXCEEDED: 'rate_limited',
+ AI_INVALID_REQUEST: 'provider_rejected_request',
+ AI_REQUEST_FAILED: 'request_failed',
+};
+
+function asRecord(value: unknown): Record | null {
+ return value !== null && typeof value === 'object' ? (value as Record) : null;
+}
+
+function payloadCode(payload: unknown): string {
+ const record = asRecord(payload);
+ if (!record) return '';
+ const error = asRecord(record.error);
+ const candidate = record.error_code ?? error?.code ?? record.code;
+ if (typeof candidate !== 'string') return '';
+ return legacyCodeAliases[candidate] || candidate.toLowerCase();
+}
+
+function payloadText(payload: unknown): string {
+ if (typeof payload === 'string') return payload;
+ if (payload instanceof Error) return payload.message;
+ const record = asRecord(payload);
+ if (!record) return '';
+ const error = asRecord(record.error);
+ const values = [record.error, error?.message, record.message];
+ return values.find((value): value is string => typeof value === 'string') || '';
+}
+
+function inferCode(payload: unknown, status?: number): string {
+ const explicit = payloadCode(payload);
+ if (knownCodes.has(explicit)) return explicit;
+
+ switch (status) {
+ case 401:
+ case 403:
+ return 'authentication_failed';
+ case 402:
+ return 'payment_required';
+ case 404:
+ return 'model_or_endpoint_not_found';
+ case 408:
+ case 504:
+ return 'timeout';
+ case 413:
+ return 'request_too_large';
+ case 429:
+ return 'rate_limited';
+ default:
+ if (status && status >= 500) return 'provider_unavailable';
+ }
+
+ const text = payloadText(payload).toLowerCase();
+ if (/\b429\b|rate.?limit|too many requests/.test(text)) return 'rate_limited';
+ if (/\b401\b|\b403\b|unauthori[sz]ed|forbidden|invalid api.?key|authentication/.test(text)) {
+ return 'authentication_failed';
+ }
+ if (/\b402\b|insufficient (quota|balance)|payment required|credit/.test(text)) {
+ return 'payment_required';
+ }
+ if (/\b404\b|model.*not found|endpoint.*not found/.test(text)) {
+ return 'model_or_endpoint_not_found';
+ }
+ if (/\b413\b|request.*too large|context length/.test(text)) return 'request_too_large';
+ if (/timeout|timed out|deadline exceeded/.test(text)) return 'timeout';
+ if (/connection refused|network|no such host|failed to fetch/.test(text)) return 'network_error';
+ if (/invalid json|invalid response|empty response|no choices/.test(text))
+ return 'invalid_response';
+ return 'request_failed';
+}
+
+export function getAIErrorMessage(payload: unknown, status?: number): string {
+ const code = inferCode(payload, status);
+ return String(i18n.global.t(`aiErrors.${code}`));
+}
+
+export async function readAIError(response: Response): Promise {
+ let raw = '';
+ try {
+ raw = await response.text();
+ } catch {
+ // A response body is optional; the HTTP status still provides a stable
+ // classification without exposing transport details.
+ }
+ let payload: unknown;
+ try {
+ payload = JSON.parse(raw);
+ } catch {
+ payload = raw;
+ }
+ const code = inferCode(payload, response.status);
+ return { code, message: getAIErrorMessage(payload, response.status), payload };
+}
diff --git a/internal/ai/deepseek_test.go b/internal/ai/deepseek_test.go
index a80a9776..e1aeae8d 100644
--- a/internal/ai/deepseek_test.go
+++ b/internal/ai/deepseek_test.go
@@ -1,6 +1,12 @@
package ai
-import "testing"
+import (
+ "context"
+ "errors"
+ "net"
+ "strings"
+ "testing"
+)
func TestDeepSeekFormatEndpointNormalizesBaseURLs(t *testing.T) {
handler := &DeepSeekHandler{}
@@ -45,3 +51,38 @@ func TestDeepSeekFormatEndpointNormalizesBaseURLs(t *testing.T) {
})
}
}
+
+func TestClassifyUserFacingErrorDoesNotExposeProviderResponse(t *testing.T) {
+ const secret = `{"error":{"message":"provider detail sk-secret-value"}}`
+ tests := []struct {
+ name string
+ err error
+ code string
+ }{
+ {name: "authentication", err: errors.New("OpenAI API returned status 401: " + secret), code: ErrorCodeAuthenticationFailed},
+ {name: "payment", err: errors.New("OpenAI API returned status 402: " + secret), code: ErrorCodePaymentRequired},
+ {name: "not found", err: errors.New("OpenAI API returned status 404: " + secret), code: ErrorCodeModelOrEndpointNotFound},
+ {name: "too large", err: errors.New("OpenAI API returned status 413: " + secret), code: ErrorCodeRequestTooLarge},
+ {name: "rate limited", err: errors.New("OpenAI API returned status 429: " + secret), code: ErrorCodeRateLimited},
+ {name: "provider unavailable", err: errors.New("OpenAI API returned status 503: " + secret), code: ErrorCodeProviderUnavailable},
+ {name: "timeout", err: context.DeadlineExceeded, code: ErrorCodeTimeout},
+ {name: "network", err: &net.DNSError{Err: "no such host", Name: "private.example"}, code: ErrorCodeNetwork},
+ {name: "invalid response", err: errors.New("invalid JSON response"), code: ErrorCodeInvalidResponse},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ClassifyUserFacingError(tt.err)
+ if got.Code != tt.code {
+ t.Fatalf("code = %q, want %q", got.Code, tt.code)
+ }
+ if got.Message == "" || strings.Contains(got.Message, "sk-secret-value") || strings.Contains(got.Message, "provider detail") {
+ t.Fatalf("unsafe user-facing message: %q", got.Message)
+ }
+ })
+ }
+
+ if got := RedactEndpoint("https://user:secret@api.example.com/v1/chat?token=secret#fragment"); got != "https://api.example.com/v1/chat" {
+ t.Fatalf("RedactEndpoint leaked or changed the endpoint: %q", got)
+ }
+}
diff --git a/internal/ai/user_error.go b/internal/ai/user_error.go
new file mode 100644
index 00000000..0cf187f9
--- /dev/null
+++ b/internal/ai/user_error.go
@@ -0,0 +1,152 @@
+package ai
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+const (
+ ErrorCodeConfigurationInvalid = "configuration_invalid"
+ ErrorCodeUsageLimitReached = "usage_limit_reached"
+ ErrorCodeRateLimited = "rate_limited"
+ ErrorCodeAuthenticationFailed = "authentication_failed"
+ ErrorCodePaymentRequired = "payment_required"
+ ErrorCodeModelOrEndpointNotFound = "model_or_endpoint_not_found"
+ ErrorCodeRequestTooLarge = "request_too_large"
+ ErrorCodeTimeout = "timeout"
+ ErrorCodeNetwork = "network_error"
+ ErrorCodeProviderUnavailable = "provider_unavailable"
+ ErrorCodeInvalidResponse = "invalid_response"
+ ErrorCodeProviderRejectedRequest = "provider_rejected_request"
+ ErrorCodeRequestFailed = "request_failed"
+)
+
+// UserFacingError is safe to expose over the HTTP API. It deliberately keeps
+// provider response bodies and request credentials out of user-visible text.
+type UserFacingError struct {
+ Code string
+ Message string
+ HTTPStatus int
+}
+
+// RedactEndpoint keeps only the non-sensitive origin and path for diagnostic
+// logs. Userinfo, query parameters, and fragments may contain API credentials.
+func RedactEndpoint(raw string) string {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return "configured endpoint"
+ }
+ parsed.User = nil
+ parsed.RawQuery = ""
+ parsed.Fragment = ""
+ return parsed.String()
+}
+
+// ClassifyUserFacingError converts provider, transport, and response parsing
+// failures into a stable public code and a short, non-sensitive message.
+func ClassifyUserFacingError(err error) UserFacingError {
+ if err == nil {
+ return userFacingError(ErrorCodeRequestFailed)
+ }
+ if errors.Is(err, context.DeadlineExceeded) {
+ return userFacingError(ErrorCodeTimeout)
+ }
+ if errors.Is(err, context.Canceled) {
+ return userFacingError(ErrorCodeRequestFailed)
+ }
+
+ message := strings.ToLower(err.Error())
+ if statusCode := providerStatusCode(message); statusCode > 0 {
+ switch {
+ case statusCode == http.StatusTooManyRequests:
+ return userFacingError(ErrorCodeRateLimited)
+ case statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden:
+ return userFacingError(ErrorCodeAuthenticationFailed)
+ case statusCode == http.StatusPaymentRequired:
+ return userFacingError(ErrorCodePaymentRequired)
+ case statusCode == http.StatusNotFound:
+ return userFacingError(ErrorCodeModelOrEndpointNotFound)
+ case statusCode == http.StatusRequestEntityTooLarge:
+ return userFacingError(ErrorCodeRequestTooLarge)
+ case statusCode >= http.StatusInternalServerError:
+ return userFacingError(ErrorCodeProviderUnavailable)
+ case statusCode >= http.StatusBadRequest:
+ return userFacingError(ErrorCodeProviderRejectedRequest)
+ }
+ }
+
+ var networkErr net.Error
+ if errors.As(err, &networkErr) {
+ if networkErr.Timeout() {
+ return userFacingError(ErrorCodeTimeout)
+ }
+ return userFacingError(ErrorCodeNetwork)
+ }
+
+ switch {
+ case strings.Contains(message, "usage limit"), strings.Contains(message, "token limit"):
+ return userFacingError(ErrorCodeUsageLimitReached)
+ case strings.Contains(message, "timeout"), strings.Contains(message, "deadline exceeded"):
+ return userFacingError(ErrorCodeTimeout)
+ case strings.Contains(message, "connection refused"), strings.Contains(message, "no such host"), strings.Contains(message, "network is unreachable"):
+ return userFacingError(ErrorCodeNetwork)
+ case strings.Contains(message, "invalid json"), strings.Contains(message, "decode"), strings.Contains(message, "empty response"), strings.Contains(message, "empty content"), strings.Contains(message, "no choices"):
+ return userFacingError(ErrorCodeInvalidResponse)
+ default:
+ return userFacingError(ErrorCodeRequestFailed)
+ }
+}
+
+var providerStatusPattern = regexp.MustCompile(`(?i)(?:status|http)[^0-9]{0,12}([1-5][0-9]{2})`)
+
+func providerStatusCode(message string) int {
+ match := providerStatusPattern.FindStringSubmatch(message)
+ if len(match) != 2 {
+ return 0
+ }
+ statusCode, _ := strconv.Atoi(match[1])
+ return statusCode
+}
+
+// UserFacingErrorForCode returns the canonical safe message and HTTP status
+// for an explicitly detected application error.
+func UserFacingErrorForCode(code string) UserFacingError {
+ return userFacingError(code)
+}
+
+func userFacingError(code string) UserFacingError {
+ switch code {
+ case ErrorCodeConfigurationInvalid:
+ return UserFacingError{Code: code, Message: "AI configuration is incomplete or invalid", HTTPStatus: http.StatusBadRequest}
+ case ErrorCodeUsageLimitReached:
+ return UserFacingError{Code: code, Message: "The configured AI usage limit has been reached", HTTPStatus: http.StatusTooManyRequests}
+ case ErrorCodeRateLimited:
+ return UserFacingError{Code: code, Message: "The AI service is receiving too many requests", HTTPStatus: http.StatusTooManyRequests}
+ case ErrorCodeAuthenticationFailed:
+ return UserFacingError{Code: code, Message: "AI service authentication failed", HTTPStatus: http.StatusUnauthorized}
+ case ErrorCodePaymentRequired:
+ return UserFacingError{Code: code, Message: "The AI service account has insufficient quota or balance", HTTPStatus: http.StatusPaymentRequired}
+ case ErrorCodeModelOrEndpointNotFound:
+ return UserFacingError{Code: code, Message: "The configured AI model or endpoint was not found", HTTPStatus: http.StatusNotFound}
+ case ErrorCodeRequestTooLarge:
+ return UserFacingError{Code: code, Message: "The AI request is too large for the configured service", HTTPStatus: http.StatusRequestEntityTooLarge}
+ case ErrorCodeTimeout:
+ return UserFacingError{Code: code, Message: "The AI service response timed out", HTTPStatus: http.StatusGatewayTimeout}
+ case ErrorCodeNetwork:
+ return UserFacingError{Code: code, Message: "MRSS could not connect to the AI service", HTTPStatus: http.StatusBadGateway}
+ case ErrorCodeProviderUnavailable:
+ return UserFacingError{Code: code, Message: "The AI service is temporarily unavailable", HTTPStatus: http.StatusBadGateway}
+ case ErrorCodeInvalidResponse:
+ return UserFacingError{Code: code, Message: "The AI service returned an invalid response", HTTPStatus: http.StatusBadGateway}
+ case ErrorCodeProviderRejectedRequest:
+ return UserFacingError{Code: code, Message: "The AI service rejected the request", HTTPStatus: http.StatusBadRequest}
+ default:
+ return UserFacingError{Code: ErrorCodeRequestFailed, Message: "The AI request failed", HTTPStatus: http.StatusBadGateway}
+ }
+}
diff --git a/internal/handlers/ai/ai_handlers.go b/internal/handlers/ai/ai_handlers.go
index 1d959341..3d265a07 100644
--- a/internal/handlers/ai/ai_handlers.go
+++ b/internal/handlers/ai/ai_handlers.go
@@ -21,6 +21,7 @@ type TestResult struct {
ResponseTimeMs int64 `json:"response_time_ms"`
TestTime string `json:"test_time"`
ErrorMessage string `json:"error_message,omitempty"`
+ ErrorCode string `json:"error_code,omitempty"`
}
// HandleTestAIConfig handles POST /api/ai/test to test AI configuration
@@ -71,7 +72,8 @@ func HandleTestAIConfig(h *core.Handler, w http.ResponseWriter, r *http.Request)
}
if !result.ConfigValid {
- result.ErrorMessage = "Configuration incomplete: " + strings.Join(validationErrors, ", ")
+ result.ErrorMessage = ai.UserFacingErrorForCode(ai.ErrorCodeConfigurationInvalid).Message
+ result.ErrorCode = ai.ErrorCodeConfigurationInvalid
response.JSON(w, result)
return
}
@@ -80,7 +82,8 @@ func HandleTestAIConfig(h *core.Handler, w http.ResponseWriter, r *http.Request)
parsedURL, err := url.Parse(endpoint)
if err != nil {
result.ConfigValid = false
- result.ErrorMessage = "Invalid endpoint URL: " + err.Error()
+ result.ErrorMessage = ai.UserFacingErrorForCode(ai.ErrorCodeConfigurationInvalid).Message
+ result.ErrorCode = ai.ErrorCodeConfigurationInvalid
response.JSON(w, result)
return
}
@@ -88,7 +91,8 @@ func HandleTestAIConfig(h *core.Handler, w http.ResponseWriter, r *http.Request)
// Both HTTP and HTTPS are allowed
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
result.ConfigValid = false
- result.ErrorMessage = "API endpoint must use HTTP or HTTPS"
+ result.ErrorMessage = ai.UserFacingErrorForCode(ai.ErrorCodeConfigurationInvalid).Message
+ result.ErrorCode = ai.ErrorCodeConfigurationInvalid
response.JSON(w, result)
return
}
@@ -99,9 +103,11 @@ func HandleTestAIConfig(h *core.Handler, w http.ResponseWriter, r *http.Request)
// Create HTTP client with proxy support if configured
httpClient, err := createHTTPClientWithProxy(h)
if err != nil {
+ publicErr := ai.ClassifyUserFacingError(err)
result.ConnectionSuccess = false
result.ModelAvailable = false
- result.ErrorMessage = fmt.Sprintf("Failed to create HTTP client: %v", err)
+ result.ErrorMessage = publicErr.Message
+ result.ErrorCode = publicErr.Code
result.ResponseTimeMs = time.Since(startTime).Milliseconds()
response.JSON(w, result)
return
@@ -121,9 +127,11 @@ func HandleTestAIConfig(h *core.Handler, w http.ResponseWriter, r *http.Request)
_, err = client.Request("", "test")
if err != nil {
+ publicErr := ai.ClassifyUserFacingError(err)
result.ConnectionSuccess = false
result.ModelAvailable = false
- result.ErrorMessage = fmt.Sprintf("Connection failed: %v", err)
+ result.ErrorMessage = publicErr.Message
+ result.ErrorCode = publicErr.Code
} else {
result.ConnectionSuccess = true
result.ModelAvailable = true
diff --git a/internal/handlers/ai/ai_profiles_handlers.go b/internal/handlers/ai/ai_profiles_handlers.go
index b7116778..a4384bb2 100644
--- a/internal/handlers/ai/ai_profiles_handlers.go
+++ b/internal/handlers/ai/ai_profiles_handlers.go
@@ -44,6 +44,7 @@ type ProfileTestResult struct {
ModelAvailable bool `json:"model_available"`
ResponseTimeMs int64 `json:"response_time_ms"`
ErrorMessage string `json:"error_message,omitempty"`
+ ErrorCode string `json:"error_code,omitempty"`
}
// HandleListAIProfiles handles GET /api/ai/profiles
@@ -477,7 +478,8 @@ func testAIProfileConnection(h *core.Handler, profile *models.AIProfile) Profile
}
if !result.ConfigValid {
- result.ErrorMessage = "Configuration incomplete: " + strings.Join(validationErrors, ", ")
+ result.ErrorMessage = ai.UserFacingErrorForCode(ai.ErrorCodeConfigurationInvalid).Message
+ result.ErrorCode = ai.ErrorCodeConfigurationInvalid
result.ResponseTimeMs = time.Since(startTime).Milliseconds()
return result
}
@@ -486,14 +488,16 @@ func testAIProfileConnection(h *core.Handler, profile *models.AIProfile) Profile
parsedURL, err := url.Parse(profile.Endpoint)
if err != nil {
result.ConfigValid = false
- result.ErrorMessage = "Invalid endpoint URL: " + err.Error()
+ result.ErrorMessage = ai.UserFacingErrorForCode(ai.ErrorCodeConfigurationInvalid).Message
+ result.ErrorCode = ai.ErrorCodeConfigurationInvalid
result.ResponseTimeMs = time.Since(startTime).Milliseconds()
return result
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
result.ConfigValid = false
- result.ErrorMessage = "API endpoint must use HTTP or HTTPS"
+ result.ErrorMessage = ai.UserFacingErrorForCode(ai.ErrorCodeConfigurationInvalid).Message
+ result.ErrorCode = ai.ErrorCodeConfigurationInvalid
result.ResponseTimeMs = time.Since(startTime).Milliseconds()
return result
}
@@ -501,9 +505,11 @@ func testAIProfileConnection(h *core.Handler, profile *models.AIProfile) Profile
// Create HTTP client with proxy support if configured
httpClient, err := createHTTPClientWithProxyForProfile(h)
if err != nil {
+ publicErr := ai.ClassifyUserFacingError(err)
result.ConnectionSuccess = false
result.ModelAvailable = false
- result.ErrorMessage = fmt.Sprintf("Failed to create HTTP client: %v", err)
+ result.ErrorMessage = publicErr.Message
+ result.ErrorCode = publicErr.Code
result.ResponseTimeMs = time.Since(startTime).Milliseconds()
return result
}
@@ -524,9 +530,11 @@ func testAIProfileConnection(h *core.Handler, profile *models.AIProfile) Profile
_, err = client.Request("", "test")
if err != nil {
+ publicErr := ai.ClassifyUserFacingError(err)
result.ConnectionSuccess = false
result.ModelAvailable = false
- result.ErrorMessage = fmt.Sprintf("Connection failed: %v", err)
+ result.ErrorMessage = publicErr.Message
+ result.ErrorCode = publicErr.Code
} else {
result.ConnectionSuccess = true
result.ModelAvailable = true
diff --git a/internal/handlers/ai/ai_search.go b/internal/handlers/ai/ai_search.go
index 1601f6bb..e745605f 100644
--- a/internal/handlers/ai/ai_search.go
+++ b/internal/handlers/ai/ai_search.go
@@ -5,7 +5,6 @@ import (
"fmt"
"log"
"net/http"
- "net/url"
"regexp"
"strings"
"time"
@@ -29,6 +28,7 @@ type AISearchResponse struct {
SearchTerms string `json:"search_terms,omitempty"`
ExpandedTerms *SearchTerms `json:"expanded_terms,omitempty"`
Error string `json:"error,omitempty"`
+ ErrorCode string `json:"error_code,omitempty"`
TotalCount int `json:"total_count"`
}
@@ -99,18 +99,6 @@ func normalizeSearchPatterns(values []string, max int) []string {
return result
}
-func endpointForLog(rawEndpoint string) string {
- parsed, err := url.Parse(strings.TrimSpace(rawEndpoint))
- if err != nil || parsed.Scheme == "" || parsed.Host == "" {
- return ""
- }
- parsed.User = nil
- parsed.RawQuery = ""
- parsed.ForceQuery = false
- parsed.Fragment = ""
- return parsed.String()
-}
-
func normalizeTerms(values []string, max int) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{})
@@ -176,16 +164,18 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
var req AISearchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.JSON(w, AISearchResponse{
- Success: false,
- Error: "Invalid request format",
+ Success: false,
+ Error: "Invalid search request",
+ ErrorCode: ai.ErrorCodeProviderRejectedRequest,
})
return
}
if strings.TrimSpace(req.Query) == "" {
response.JSON(w, AISearchResponse{
- Success: false,
- Error: "Search query is required",
+ Success: false,
+ Error: "Search query is required",
+ ErrorCode: ai.ErrorCodeProviderRejectedRequest,
})
return
}
@@ -201,7 +191,7 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
apiKey = cfg.APIKey
endpoint = cfg.Endpoint
model = cfg.Model
- log.Printf("[AI Search] Using AI profile for search (endpoint: %s, model: %s)", endpointForLog(endpoint), model)
+ log.Printf("[AI Search] Using AI profile for search (endpoint: %s, model: %s)", ai.RedactEndpoint(endpoint), model)
}
}
@@ -219,14 +209,15 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
if model == "" {
model = defaults.AIModel
}
- log.Printf("[AI Search] Using global AI settings for search (endpoint: %s, model: %s)", endpointForLog(endpoint), model)
+ log.Printf("[AI Search] Using global AI settings for search (endpoint: %s, model: %s)", ai.RedactEndpoint(endpoint), model)
}
// Validate AI configuration
if endpoint == "" || model == "" {
response.JSON(w, AISearchResponse{
- Success: false,
- Error: "AI is not configured. Please configure AI settings first.",
+ Success: false,
+ Error: ai.UserFacingErrorForCode(ai.ErrorCodeConfigurationInvalid).Message,
+ ErrorCode: ai.ErrorCodeConfigurationInvalid,
})
return
}
@@ -234,9 +225,11 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
// Create AI client
httpClient, err := createHTTPClientWithProxy(h)
if err != nil {
+ publicErr := ai.ClassifyUserFacingError(err)
response.JSON(w, AISearchResponse{
- Success: false,
- Error: fmt.Sprintf("Failed to create HTTP client: %v", err),
+ Success: false,
+ Error: publicErr.Message,
+ ErrorCode: publicErr.Code,
})
return
}
@@ -254,9 +247,12 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
systemPrompt := buildAISearchPrompt()
aiResponse, err := client.Request(systemPrompt, req.Query)
if err != nil {
+ publicErr := ai.ClassifyUserFacingError(err)
+ log.Printf("[AI Search] Request failed code=%s status=%d", publicErr.Code, publicErr.HTTPStatus)
response.JSON(w, AISearchResponse{
- Success: false,
- Error: fmt.Sprintf("AI request failed: %v", err),
+ Success: false,
+ Error: publicErr.Message,
+ ErrorCode: publicErr.Code,
})
return
}
@@ -264,9 +260,11 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
// Parse search terms from AI response
searchTerms, err := parseSearchTermsAdvanced(aiResponse)
if err != nil {
+ publicErr := ai.UserFacingErrorForCode(ai.ErrorCodeInvalidResponse)
response.JSON(w, AISearchResponse{
- Success: false,
- Error: fmt.Sprintf("Failed to parse search terms: %v", err),
+ Success: false,
+ Error: publicErr.Message,
+ ErrorCode: publicErr.Code,
})
return
}
@@ -294,7 +292,8 @@ func HandleAISearch(h *core.Handler, w http.ResponseWriter, r *http.Request) {
log.Printf("[AI Search] Query error: %v", err)
response.JSON(w, AISearchResponse{
Success: false,
- Error: fmt.Sprintf("Search query failed: %v", err),
+ Error: "The article search could not be completed",
+ ErrorCode: ai.ErrorCodeRequestFailed,
SearchTerms: strings.Join(allTerms, ", "),
})
return
diff --git a/internal/handlers/summary/summary_handlers.go b/internal/handlers/summary/summary_handlers.go
index f3fac401..cb1f4140 100644
--- a/internal/handlers/summary/summary_handlers.go
+++ b/internal/handlers/summary/summary_handlers.go
@@ -149,7 +149,7 @@ func HandleSummarizeArticle(h *core.Handler, w http.ResponseWriter, r *http.Requ
apiKey = cfg.APIKey
endpoint = cfg.Endpoint
model = cfg.Model
- log.Printf("Using AI profile for summarization (endpoint: %s, model: %s)", endpoint, model)
+ log.Printf("AI summary profile selected endpoint=%s model=%s", ai.RedactEndpoint(endpoint), model)
}
}
@@ -182,7 +182,8 @@ func HandleSummarizeArticle(h *core.Handler, w http.ResponseWriter, r *http.Requ
}
aiResult, err := aiSummarizer.Summarize(content, summaryLength)
if err != nil {
- log.Printf("Error generating AI summary, falling back to local: %v", err)
+ publicErr := ai.ClassifyUserFacingError(err)
+ log.Printf("AI summary failed code=%s status=%d; using local fallback", publicErr.Code, publicErr.HTTPStatus)
// Fallback to local algorithm on any AI error
summarizer := summary.NewSummarizer()
result = summarizer.Summarize(content, summaryLength)
diff --git a/internal/handlers/translation/translation_handlers.go b/internal/handlers/translation/translation_handlers.go
index 7ba9e7be..b66c2308 100644
--- a/internal/handlers/translation/translation_handlers.go
+++ b/internal/handlers/translation/translation_handlers.go
@@ -21,9 +21,10 @@ type TestCustomTranslationRequest struct {
// TestCustomTranslationResponse represents the response from a custom translation test
type TestCustomTranslationResponse struct {
- Success bool `json:"success"`
- Result string `json:"result,omitempty"`
- Error string `json:"error,omitempty"`
+ Success bool `json:"success"`
+ Result string `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+ ErrorCode string `json:"error_code,omitempty"`
}
// HandleTranslateArticle translates an article's title.
@@ -270,7 +271,8 @@ func HandleTranslateText(h *core.Handler, w http.ResponseWriter, r *http.Request
// If AI fails, fallback to Google Translate
if err != nil {
- log.Printf("AI translation failed, falling back to Google Translate: %v", err)
+ publicErr := ai.ClassifyUserFacingError(err)
+ log.Printf("AI translation failed code=%s status=%d; using Google Translate fallback", publicErr.Code, publicErr.HTTPStatus)
googleTranslator := translation.NewGoogleFreeTranslatorWithDB(h.DB)
translatedText, err = translation.TranslateMarkdownPreservingStructure(req.Text, googleTranslator, req.TargetLang)
}
@@ -412,7 +414,9 @@ func HandleTestCustomTranslation(h *core.Handler, w http.ResponseWriter, r *http
}
if err != nil {
- resp["error"] = err.Error()
+ publicErr := ai.ClassifyUserFacingError(err)
+ resp["error"] = publicErr.Message
+ resp["error_code"] = publicErr.Code
w.WriteHeader(http.StatusBadRequest)
} else {
resp["translation"] = result
From 89a76b0c0d12e00c896df16b00fde1f4b2544e47 Mon Sep 17 00:00:00 2001
From: marcomarcogd <35049765+marcomarcogd@users.noreply.github.com>
Date: Tue, 25 Aug 2026 11:00:18 +0800
Subject: [PATCH 27/59] fix(chat): persist article conversations
---
frontend/cypress/e2e/article-operations.cy.ts | 150 ++++++++++++++++++
.../components/article/ArticleChatPanel.vue | 57 ++++---
frontend/src/i18n/locales/en.ts | 1 +
frontend/src/i18n/locales/zh.ts | 1 +
internal/database/article_db_test.go | 33 ++++
internal/database/chat_db.go | 4 +-
internal/handlers/chat/chat_handlers.go | 104 ++++++++++--
7 files changed, 309 insertions(+), 41 deletions(-)
diff --git a/frontend/cypress/e2e/article-operations.cy.ts b/frontend/cypress/e2e/article-operations.cy.ts
index 8cb0d71c..29ae89cc 100644
--- a/frontend/cypress/e2e/article-operations.cy.ts
+++ b/frontend/cypress/e2e/article-operations.cy.ts
@@ -309,4 +309,154 @@ describe('Article Operations', () => {
cy.wait('@searchArticleContent');
cy.contains('Body for search result 102').should('be.visible');
});
+
+ it('should preserve AI chat history across new conversations and request failures', () => {
+ const settingsState: Record = {
+ language: 'en-US',
+ theme: 'light',
+ layout_mode: 'normal',
+ default_view_mode: 'rendered',
+ ai_chat_enabled: 'true',
+ translation_enabled: 'false',
+ summary_enabled: 'false',
+ full_text_fetch_enabled: 'false',
+ update_check_enabled: 'false',
+ };
+ const feed = {
+ id: 1,
+ title: 'Chat Feed',
+ url: 'https://example.com/chat.xml',
+ category: '',
+ article_view_mode: 'global',
+ };
+ const article = {
+ id: 1,
+ feed_id: 1,
+ feed_title: feed.title,
+ title: 'Chat article',
+ url: 'https://example.com/chat/article',
+ published_at: '2026-08-25T00:00:00Z',
+ is_read: false,
+ is_favorite: false,
+ is_hidden: false,
+ is_read_later: false,
+ };
+ let nextSessionID = 1;
+ const sessions: Array> = [];
+ const messages = new Map>>();
+ const timestamp = () => new Date().toISOString();
+
+ cy.intercept('/api/**', { statusCode: 200, body: {} });
+ cy.intercept('GET', '/api/settings', { statusCode: 200, body: settingsState });
+ cy.intercept('GET', '/api/feeds', { statusCode: 200, body: [feed] }).as('chatFeeds');
+ cy.intercept('GET', '/api/tags', { statusCode: 200, body: [] });
+ cy.intercept('GET', '/api/saved-filters', { statusCode: 200, body: [] });
+ cy.intercept(
+ { method: 'GET', pathname: '/api/articles' },
+ { statusCode: 200, body: [article] }
+ ).as('chatArticles');
+ cy.intercept('GET', '/api/articles/unread-counts', { statusCode: 200, body: {} });
+ cy.intercept('GET', '/api/articles/filter-counts', { statusCode: 200, body: {} });
+ cy.intercept('GET', '/api/progress', {
+ statusCode: 200,
+ body: { is_running: true, pool_task_count: 0, queue_task_count: 0 },
+ });
+ cy.intercept('GET', '/api/articles/content*', {
+ statusCode: 200,
+ body: { content: 'Article context for AI chat
', cached: true },
+ }).as('chatArticleContent');
+ cy.intercept('POST', '/api/articles/read*', { statusCode: 200, body: { success: true } });
+ cy.intercept('GET', '/api/ai/chat/sessions*', (req) => {
+ req.reply({ statusCode: 200, body: sessions });
+ }).as('chatSessions');
+ cy.intercept('GET', '/api/ai/chat/messages*', (req) => {
+ req.reply({ statusCode: 200, body: messages.get(Number(req.query.session_id)) || [] });
+ }).as('chatMessages');
+ cy.intercept('POST', '/api/ai/chat/session/create', (req) => {
+ const id = nextSessionID++;
+ const session = {
+ id,
+ article_id: 1,
+ title: req.body.title,
+ created_at: timestamp(),
+ updated_at: timestamp(),
+ message_count: 0,
+ };
+ sessions.unshift(session);
+ messages.set(id, []);
+ req.reply({ statusCode: 200, body: session });
+ }).as('createChatSession');
+ cy.intercept('POST', '/api/ai-chat', (req) => {
+ const lastMessage = req.body.messages.at(-1)?.content || '';
+ let sessionID = Number(req.body.session_id || 0);
+ if (!sessionID) {
+ sessionID = nextSessionID++;
+ sessions.unshift({
+ id: sessionID,
+ article_id: 1,
+ title: lastMessage.slice(0, 60),
+ created_at: timestamp(),
+ updated_at: timestamp(),
+ message_count: 0,
+ });
+ }
+ const stored = messages.get(sessionID) || [];
+ stored.push({
+ id: stored.length + 1,
+ role: 'user',
+ content: lastMessage,
+ created_at: timestamp(),
+ });
+ messages.set(sessionID, stored);
+
+ if (lastMessage === 'trigger failure') {
+ req.reply({
+ statusCode: 500,
+ body: { error: 'Failed to get response from AI. Please try again.', session_id: sessionID },
+ });
+ return;
+ }
+
+ stored.push({
+ id: stored.length + 1,
+ role: 'assistant',
+ content: 'Persisted answer',
+ created_at: timestamp(),
+ });
+ const session = sessions.find((item) => item.id === sessionID);
+ if (session) session.message_count = stored.length;
+ req.reply({
+ statusCode: 200,
+ body: { response: 'Persisted answer', session_id: sessionID, history_saved: true },
+ });
+ }).as('aiChat');
+
+ cy.reload();
+ cy.wait('@chatFeeds');
+ cy.wait('@chatArticles');
+ cy.get('[data-article-id="1"]').click();
+ cy.wait('@chatArticleContent');
+ cy.get('button[title="AI Chat"]').click();
+ cy.wait('@chatSessions');
+
+ cy.get('input[placeholder="Type a message..."]').type('First question{enter}');
+ cy.wait('@aiChat');
+ cy.contains('.chat-panel', 'Persisted answer').should('be.visible');
+
+ cy.get('[data-testid="chat-new-session"]').click();
+ cy.wait('@createChatSession');
+ cy.wait('@chatMessages');
+ cy.contains('.chat-panel', 'Persisted answer').should('not.exist');
+ cy.get('[data-testid="chat-session-switcher"]').click();
+ cy.get('.chat-panel [data-session-id="1"]').click();
+ cy.wait('@chatMessages');
+ cy.contains('.chat-panel', 'Persisted answer').should('be.visible');
+
+ cy.get('input[placeholder="Type a message..."]').type('trigger failure{enter}');
+ cy.wait('@aiChat');
+ cy.wait('@chatMessages');
+ cy.contains('.chat-panel', 'trigger failure').should('be.visible');
+ cy.contains('Failed to get response from AI. Please try again.').should('be.visible');
+ });
+
});
diff --git a/frontend/src/components/article/ArticleChatPanel.vue b/frontend/src/components/article/ArticleChatPanel.vue
index f9990b48..bee2d2ed 100644
--- a/frontend/src/components/article/ArticleChatPanel.vue
+++ b/frontend/src/components/article/ArticleChatPanel.vue
@@ -86,7 +86,8 @@ async function loadSessions() {
}
}
-async function selectSession(sessionId: number) {
+async function selectSession(sessionId: number, force = false) {
+ if (isLoading.value && !force) return;
try {
const response = await fetch(`/api/ai/chat/messages?session_id=${sessionId}`);
if (response.ok) {
@@ -106,6 +107,7 @@ async function selectSession(sessionId: number) {
}
async function createNewSession() {
+ if (isLoading.value) return;
try {
const response = await fetch('/api/ai/chat/session/create', {
method: 'POST',
@@ -131,6 +133,7 @@ async function createNewSession() {
async function deleteSession(sessionId: number, e: Event) {
e.stopPropagation();
+ if (isLoading.value) return;
const confirmed = await window.showConfirm({
title: t('common.confirm'),
message: t('article.chat.confirmDeleteSession'),
@@ -158,6 +161,7 @@ async function deleteSession(sessionId: number, e: Event) {
function startEditSession(session: ChatSession, e: Event) {
e.stopPropagation();
+ if (isLoading.value) return;
editingSessionId.value = session.id;
editingSessionTitle.value = session.title;
}
@@ -277,19 +281,25 @@ async function sendMessage() {
created_at: new Date().toISOString(),
});
- if (data.session_id && data.session_id !== currentSessionId.value) {
+ if (data.session_id) {
currentSessionId.value = data.session_id;
await loadSessions();
}
+ if (data.history_saved === false) {
+ window.showToast(t('article.chat.historySaveFailed'), 'warning');
+ }
+
isFirstMessage.value = false;
} else {
const errorText = await response.text();
- console.error('AI chat error response:', response.status, errorText);
+ console.error('AI chat request failed:', response.status);
let errorMessage = t('article.chat.aiChatError');
+ let persistedSessionID = 0;
try {
const errorData = JSON.parse(errorText);
+ persistedSessionID = Number(errorData.session_id || 0);
// Extract error message from various possible formats
if (typeof errorData.error === 'string') {
errorMessage = errorData.error;
@@ -303,24 +313,19 @@ async function sendMessage() {
errorMessage = t('article.chat.aiChatError');
}
} catch {
- errorMessage = errorText || t('article.chat.aiChatError');
+ errorMessage = t('article.chat.aiChatError');
}
- messages.value.push({
- id: 0,
- role: 'assistant',
- content: errorMessage,
- created_at: new Date().toISOString(),
- });
+ if (persistedSessionID > 0) {
+ currentSessionId.value = persistedSessionID;
+ await loadSessions();
+ await selectSession(persistedSessionID, true);
+ }
+ window.showToast(errorMessage, 'error');
}
} catch (e) {
console.error('AI chat error:', e);
- messages.value.push({
- id: 0,
- role: 'assistant',
- content: t('article.chat.aiChatError'),
- created_at: new Date().toISOString(),
- });
+ window.showToast(t('article.chat.aiChatError'), 'error');
} finally {
isLoading.value = false;
await nextTick();
@@ -367,8 +372,10 @@ const currentSessionTitle = computed(() => {
{{ currentSessionTitle }}
@@ -377,8 +384,10 @@ const currentSessionTitle = computed(() => {
@@ -412,8 +421,12 @@ const currentSessionTitle = computed(() => {
v-for="session in sessions"
:key="session.id"
class="flex items-center gap-2 p-2 rounded-lg hover:bg-bg-tertiary cursor-pointer group"
- :class="{ 'bg-bg-tertiary': session.id === currentSessionId }"
- @click="selectSession(session.id)"
+ :class="{
+ 'bg-bg-tertiary': session.id === currentSessionId,
+ 'pointer-events-none opacity-60': isLoading,
+ }"
+ :data-session-id="session.id"
+ @click.stop="selectSession(session.id)"
>
@@ -490,9 +503,9 @@ const currentSessionTitle = computed(() => {
{{ msg.content }}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 78901587..70ea9ce9 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -64,6 +64,7 @@ const en: TranslationMessages = {
chat: {
aiChat: 'AI Chat',
aiChatError: 'Failed to get response from AI. Please try again.',
+ historySaveFailed: 'The answer was generated but could not be saved to chat history.',
aiChatInputPlaceholder: 'Type a message...',
aiChatWelcome: 'Ask me anything about this article!',
confirmDeleteSession: 'Are you sure you want to delete this chat session?',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 8bc59a00..d5399f17 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -61,6 +61,7 @@ const zh: TranslationMessages = {
chat: {
aiChat: 'AI 聊天',
aiChatError: '无法获取 AI 响应,请重试。',
+ historySaveFailed: '回答已生成,但未能保存到历史记录。',
aiChatInputPlaceholder: '输入消息...',
aiChatWelcome: '请问关于这篇文章的任何问题!',
confirmDeleteSession: '确定要删除这个对话吗?',
diff --git a/internal/database/article_db_test.go b/internal/database/article_db_test.go
index 9d24be96..a6c4ef6b 100644
--- a/internal/database/article_db_test.go
+++ b/internal/database/article_db_test.go
@@ -605,6 +605,39 @@ func TestSaveArticlesUpdatePreservesRelatedData(t *testing.T) {
if session == nil || session.MessageCount != 1 {
t.Fatalf("chat data was not preserved: session=%+v", session)
}
+
+ newerSessionID, err := db.CreateChatSession(articleID, "New conversation")
+ if err != nil {
+ t.Fatalf("CreateChatSession(newer) error: %v", err)
+ }
+ if _, err := db.Exec(
+ `UPDATE chat_sessions SET updated_at = '2026-08-25 10:00:00' WHERE id IN (?, ?)`,
+ sessionID, newerSessionID,
+ ); err != nil {
+ t.Fatalf("align chat session timestamps: %v", err)
+ }
+ firstMessageID, err := db.CreateChatMessage(newerSessionID, "user", "First in the same second", "")
+ if err != nil {
+ t.Fatalf("CreateChatMessage(first) error: %v", err)
+ }
+ secondMessageID, err := db.CreateChatMessage(newerSessionID, "assistant", "Second in the same second", "thinking")
+ if err != nil {
+ t.Fatalf("CreateChatMessage(second) error: %v", err)
+ }
+ if _, err := db.Exec(
+ `UPDATE chat_sessions SET updated_at = '2026-08-25 10:00:00' WHERE id IN (?, ?)`,
+ sessionID, newerSessionID,
+ ); err != nil {
+ t.Fatalf("restore aligned chat session timestamps: %v", err)
+ }
+ sessions, err := db.GetChatSessionsByArticle(articleID)
+ if err != nil || len(sessions) != 2 || sessions[0].ID != newerSessionID {
+ t.Fatalf("chat session order = %+v, err=%v", sessions, err)
+ }
+ messages, err := db.GetChatMessages(newerSessionID)
+ if err != nil || len(messages) != 2 || messages[0].ID != firstMessageID || messages[1].ID != secondMessageID {
+ t.Fatalf("chat message order = %+v, err=%v", messages, err)
+ }
}
func TestArticleDeduplicationByUniqueID(t *testing.T) {
diff --git a/internal/database/chat_db.go b/internal/database/chat_db.go
index 5dd98a66..826774e1 100644
--- a/internal/database/chat_db.go
+++ b/internal/database/chat_db.go
@@ -67,7 +67,7 @@ func (db *DB) GetChatSessionsByArticle(articleID int64) ([]ChatSession, error) {
(SELECT COUNT(*) FROM chat_messages WHERE session_id = chat_sessions.id) as message_count
FROM chat_sessions
WHERE article_id = ?
- ORDER BY updated_at DESC
+ ORDER BY updated_at DESC, id DESC
`, articleID)
if err != nil {
return nil, fmt.Errorf("failed to get chat sessions: %w", err)
@@ -149,7 +149,7 @@ func (db *DB) GetChatMessages(sessionID int64) ([]ChatMessage, error) {
SELECT id, session_id, role, content, thinking, created_at
FROM chat_messages
WHERE session_id = ?
- ORDER BY created_at ASC
+ ORDER BY created_at ASC, id ASC
`, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to get chat messages: %w", err)
diff --git a/internal/handlers/chat/chat_handlers.go b/internal/handlers/chat/chat_handlers.go
index 9adbf055..7887f835 100644
--- a/internal/handlers/chat/chat_handlers.go
+++ b/internal/handlers/chat/chat_handlers.go
@@ -24,6 +24,8 @@ type ChatMessage struct {
// ChatRequest represents the incoming chat request
type ChatRequest struct {
Messages []ChatMessage `json:"messages"`
+ SessionID int64 `json:"session_id,omitempty"`
+ ArticleID int64 `json:"article_id,omitempty"`
ArticleTitle string `json:"article_title,omitempty"`
ArticleURL string `json:"article_url,omitempty"`
ArticleContent string `json:"article_content,omitempty"`
@@ -32,8 +34,16 @@ type ChatRequest struct {
// ChatResponse represents the response from the AI chat
type ChatResponse struct {
- Response string `json:"response"`
- HTML string `json:"html,omitempty"` // Rendered HTML version of markdown response
+ Response string `json:"response"`
+ HTML string `json:"html,omitempty"` // Rendered HTML version of markdown response
+ Thinking string `json:"thinking,omitempty"`
+ SessionID int64 `json:"session_id,omitempty"`
+ HistorySaved bool `json:"history_saved"`
+}
+
+type chatErrorResponse struct {
+ Error string `json:"error"`
+ SessionID int64 `json:"session_id,omitempty"`
}
// HandleAIChat handles chat requests for article discussions
@@ -72,13 +82,17 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) {
return
}
+ sessionID, historyEnabled, err := persistUserChatMessage(h, &req)
+ if err != nil {
+ log.Printf("AI chat history preparation failed session=%d", req.SessionID)
+ writeChatError(w, "Failed to save chat history", http.StatusInternalServerError, sessionID)
+ return
+ }
+
// Check if AI usage limit is reached
if h.AITracker.IsLimitReached() {
log.Printf("AI usage limit reached for chat")
- w.WriteHeader(http.StatusTooManyRequests)
- response.JSON(w, map[string]string{
- "error": "AI usage limit reached",
- })
+ writeChatError(w, "AI usage limit reached", http.StatusTooManyRequests, sessionID)
return
}
@@ -93,7 +107,7 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) {
apiKey = cfg.APIKey
endpoint = cfg.Endpoint
model = cfg.Model
- log.Printf("Using AI profile for chat (endpoint: %s, model: %s)", endpoint, model)
+ log.Printf("Using AI profile for chat (model: %s)", model)
}
}
@@ -110,7 +124,7 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) {
if model == "" {
model = "gpt-4o-mini"
}
- log.Printf("Using global AI settings for chat (endpoint: %s, model: %s)", endpoint, model)
+ log.Printf("Using global AI settings for chat (model: %s)", model)
}
// Optimize context to reduce token usage
@@ -128,7 +142,7 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) {
// Create HTTP client with proxy support if configured
httpClient, err := createHTTPClientWithProxy(h)
if err != nil {
- log.Printf("Failed to create HTTP client with proxy: %v", err)
+ log.Printf("Failed to create HTTP client with proxy")
httpClient = &http.Client{Timeout: 60 * time.Second}
} else {
httpClient.Timeout = 60 * time.Second
@@ -146,8 +160,8 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) {
// Send chat request using universal client
result, err := client.RequestWithMessages(messagesMap)
if err != nil {
- log.Printf("AI chat request failed: %v", err)
- response.Error(w, err, http.StatusInternalServerError)
+ log.Printf("AI chat request failed")
+ writeChatError(w, "Failed to get response from AI. Please try again.", http.StatusInternalServerError, sessionID)
return
}
@@ -159,11 +173,6 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) {
// Convert markdown response to HTML
htmlResponse := textutil.ConvertMarkdownToHTML(respContent)
- // Log thinking if present (for debugging)
- if thinking != "" {
- log.Printf("AI chat thinking: %s", thinking)
- }
-
// Track AI usage (estimate tokens from input and output)
estimatedTokens := estimateChatTokens(optimizedMessages, respContent)
if err := h.AITracker.AddUsage(int64(estimatedTokens)); err != nil {
@@ -173,7 +182,68 @@ func HandleAIChat(h *core.Handler, w http.ResponseWriter, r *http.Request) {
// Track statistics
_ = h.DB.IncrementStat("ai_chat")
- response.JSON(w, ChatResponse{Response: respContent, HTML: htmlResponse})
+ historySaved := historyEnabled
+ if historyEnabled {
+ if _, saveErr := h.DB.CreateChatMessage(sessionID, "assistant", respContent, thinking); saveErr != nil {
+ log.Printf("AI chat assistant history save failed session=%d", sessionID)
+ historySaved = false
+ }
+ }
+
+ response.JSON(w, ChatResponse{
+ Response: respContent, HTML: htmlResponse, Thinking: thinking,
+ SessionID: sessionID, HistorySaved: historySaved,
+ })
+}
+
+func persistUserChatMessage(h *core.Handler, req *ChatRequest) (int64, bool, error) {
+ if req.ArticleID <= 0 {
+ // Backward compatibility for callers that do not send article_id.
+ return req.SessionID, false, nil
+ }
+
+ lastUserMessage := ""
+ for index := len(req.Messages) - 1; index >= 0; index-- {
+ if req.Messages[index].Role == "user" {
+ lastUserMessage = strings.TrimSpace(req.Messages[index].Content)
+ break
+ }
+ }
+ if lastUserMessage == "" {
+ return req.SessionID, false, fmt.Errorf("latest user message is missing")
+ }
+
+ sessionID := req.SessionID
+ if sessionID > 0 {
+ session, err := h.DB.GetChatSession(sessionID)
+ if err != nil {
+ return sessionID, false, err
+ }
+ if session == nil || session.ArticleID != req.ArticleID {
+ return sessionID, false, fmt.Errorf("chat session does not belong to the article")
+ }
+ } else {
+ title := []rune(lastUserMessage)
+ if len(title) > 60 {
+ title = title[:60]
+ }
+ var err error
+ sessionID, err = h.DB.CreateChatSession(req.ArticleID, string(title))
+ if err != nil {
+ return 0, false, err
+ }
+ }
+
+ if _, err := h.DB.CreateChatMessage(sessionID, "user", lastUserMessage, ""); err != nil {
+ return sessionID, false, err
+ }
+ return sessionID, true, nil
+}
+
+func writeChatError(w http.ResponseWriter, message string, status int, sessionID int64) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(chatErrorResponse{Error: message, SessionID: sessionID})
}
// optimizeChatContext reduces the chat context to save tokens while preserving important information
From 0704834d810f84c64df2dbd182656325cd19a98f Mon Sep 17 00:00:00 2001
From: Yuntsy <207946431@qq.com>
Date: Sat, 29 Aug 2026 20:40:38 +0800
Subject: [PATCH 28/59] refactor: make the macOS SwiftUI client the only
frontend
---
.github/workflows/pre-release-check.yml | 258 +-
.github/workflows/release.yml | 520 +-
.github/workflows/test.yml | 87 +-
.gitignore | 17 +-
.pre-commit-config.yaml | 8 +-
Dockerfile.server | 9 +-
Makefile | 187 +-
Taskfile.yml | 58 -
build/README.md | 329 +-
build/Taskfile.yml | 104 -
build/config.yml | 71 -
build/darwin/Info.dev.plist | 32 -
build/darwin/Info.plist | 65 -
build/darwin/Taskfile.yml | 215 -
build/darwin/create-dmg.sh | 78 -
build/darwin/entitlements.plist | 18 -
build/docker/Dockerfile.cross | 42 -
build/docker/build-script.sh | 60 -
build/ios/Assets.xcassets | 116 -
build/ios/Info.dev.plist | 62 -
build/ios/Info.plist | 59 -
build/ios/LaunchScreen.storyboard | 53 -
build/ios/build.sh | 72 -
build/ios/entitlements.plist | 21 -
build/ios/project.pbxproj | 222 -
build/linux/Taskfile.yml | 169 -
build/linux/create-appimage.sh | 225 -
build/linux/desktop | 11 -
build/linux/nfpm/nfpm.yaml | 67 -
build/windows/Taskfile.yml | 176 -
build/windows/icon.ico | Bin 81960 -> 0 bytes
build/windows/icon.png | Bin 1211131 -> 0 bytes
build/windows/info.json | 15 -
build/windows/installer.nsi | 118 -
build/windows/nsis/wails_tools.nsh | 240 -
build/windows/wails.exe.manifest | 22 -
frontend-swift/README.md | 15 +-
frontend-swift/build-app.sh | 7 -
frontend-swift/run.sh | 2 +-
frontend/.husky/pre-commit | 4 -
frontend/.prettierignore | 8 -
frontend/.prettierrc | 8 -
.../wailsapp/wails/v3/internal/eventcreate.js | 9 +
.../wailsapp/wails/v3/internal/eventdata.d.ts | 2 +
frontend/cypress.config.ts | 62 -
frontend/cypress/README.md | 381 -
frontend/cypress/e2e/app-smoke.cy.ts | 192 -
frontend/cypress/e2e/article-operations.cy.ts | 462 -
frontend/cypress/e2e/auto-update-quick.cy.ts | 278 -
frontend/cypress/e2e/auto-update.cy.ts | 512 -
frontend/cypress/e2e/feed-management.cy.ts | 453 -
.../cypress/e2e/language-selector-test.cy.ts | 34 -
frontend/cypress/e2e/quick-test.cy.ts | 18 -
.../cypress/e2e/settings-persistence.cy.ts | 551 -
frontend/cypress/e2e/simple-test.cy.ts | 66 -
frontend/cypress/e2e/theme-and-language.cy.ts | 232 -
frontend/cypress/e2e/verify-test.cy.ts | 35 -
frontend/cypress/fixtures/example.json | 47 -
frontend/cypress/fixtures/settings.json | 70 -
frontend/cypress/plugins/index.ts | 73 -
frontend/cypress/scripts/setup-tests.ts | 179 -
frontend/cypress/support/commands.ts | 74 -
frontend/cypress/support/component.ts | 39 -
frontend/cypress/support/e2e.ts | 51 -
frontend/eslint.config.js | 168 -
frontend/index.html | 43 -
frontend/knip.json | 20 -
frontend/package-lock.json | 9073 -----------------
frontend/package.json | 75 -
frontend/package.json.md5 | 1 -
frontend/postcss.config.js | 10 -
frontend/public/assets/ai_icons/README | 3 -
frontend/public/assets/ai_icons/claude.svg | 1 -
frontend/public/assets/ai_icons/deepseek.svg | 1 -
frontend/public/assets/ai_icons/gemini.svg | 1 -
frontend/public/assets/ai_icons/grok.svg | 1 -
frontend/public/assets/ai_icons/meta.svg | 1 -
frontend/public/assets/ai_icons/minimax.svg | 1 -
frontend/public/assets/ai_icons/openai.svg | 1 -
frontend/public/assets/ai_icons/qwen.svg | 1 -
frontend/public/assets/ai_icons/wenxin.svg | 1 -
frontend/public/assets/ai_icons/yuanbao.svg | 1 -
frontend/public/assets/ai_icons/zhipu.svg | 1 -
frontend/public/assets/logo-circle.png | Bin 801921 -> 0 bytes
frontend/public/assets/logo.svg | 1 -
frontend/public/assets/plugin_icons/README | 3 -
.../public/assets/plugin_icons/freshrss.svg | 1 -
.../public/assets/plugin_icons/notion.svg | 4 -
.../public/assets/plugin_icons/obsidian.svg | 51 -
.../public/assets/plugin_icons/rsshub.svg | 1 -
.../public/assets/plugin_icons/zotero.png | Bin 22661 -> 0 bytes
.../public/assets/video_icons/bilibili.svg | 1 -
.../public/assets/video_icons/youtube.svg | 1 -
frontend/scripts/test-e2e.bat | 96 -
frontend/scripts/test-e2e.sh | 104 -
frontend/src/App.test.ts | 151 -
frontend/src/App.vue | 478 -
.../src/components/article/AISearchBar.vue | 181 -
.../components/article/ArticleCardItem.vue | 343 -
.../components/article/ArticleChatButton.vue | 21 -
.../components/article/ArticleChatPanel.vue | 631 --
.../src/components/article/ArticleContent.css | 1376 ---
.../src/components/article/ArticleContent.vue | 1216 ---
.../src/components/article/ArticleDetail.vue | 186 -
.../components/article/ArticleDetailModal.vue | 425 -
.../src/components/article/ArticleItem.vue | 549 -
.../src/components/article/ArticleList.vue | 1359 ---
.../src/components/article/ArticleToolbar.vue | 224 -
.../imageGallery/components/ImageCard.vue | 204 -
.../components/ImageGalleryGrid.vue | 94 -
.../components/ImageGalleryHeader.vue | 68 -
.../components/ImageViewerModal.vue | 493 -
.../components/ThumbnailStrip.vue | 215 -
.../composables/useGalleryKeyboard.ts | 65 -
.../composables/useImageActions.ts | 229 -
.../composables/useImageGalleryData.ts | 148 -
.../composables/useImageViewer.ts | 158 -
.../composables/useMasonryLayout.ts | 120 -
.../components/article/imageGallery/index.vue | 552 -
.../article/imageGallery/types/index.ts | 152 -
.../components/article/parts/ArticleBody.vue | 170 -
.../article/parts/ArticleSummary.vue | 445 -
.../components/article/parts/ArticleTitle.vue | 119 -
.../components/article/parts/AudioPlayer.vue | 471 -
.../components/article/parts/FloatingToc.vue | 422 -
.../components/article/parts/VideoPlayer.vue | 139 -
frontend/src/components/common/BaseModal.vue | 255 -
.../src/components/common/BaseMultiSelect.vue | 365 -
frontend/src/components/common/BaseSelect.vue | 457 -
.../src/components/common/ContextMenu.vue | 187 -
frontend/src/components/common/FindInPage.vue | 372 -
.../src/components/common/ImageViewer.vue | 457 -
.../src/components/common/ModalFooter.vue | 153 -
frontend/src/components/common/Toast.vue | 121 -
frontend/src/components/common/select.css | 201 -
.../src/components/modals/SettingsModal.vue | 332 -
.../modals/common/ConfirmDialog.vue | 92 -
.../components/modals/common/InputDialog.vue | 116 -
.../modals/common/MultiSelectDialog.vue | 189 -
.../discovery/DiscoverAllFeedsModal.vue | 190 -
.../modals/discovery/DiscoverFeedsModal.vue | 223 -
.../modals/discovery/DiscoveredFeedItem.vue | 126 -
.../modals/discovery/DiscoveryProgress.vue | 46 -
.../modals/discovery/DiscoveryResults.vue | 52 -
.../components/modals/feed/AddFeedModal.vue | 20 -
.../components/modals/feed/EditFeedModal.vue | 27 -
.../components/modals/feed/FeedFormModal.vue | 643 --
.../modals/feed/parts/AdvancedSettings.vue | 340 -
.../modals/feed/parts/CategorySelector.vue | 114 -
.../modals/feed/parts/EmailConfig.vue | 259 -
.../modals/feed/parts/ScriptSelector.vue | 95 -
.../modals/feed/parts/TagSelector.vue | 148 -
.../components/modals/feed/parts/UrlInput.vue | 66 -
.../modals/feed/parts/XPathConfig.vue | 261 -
.../modals/filter/ArticleFilterModal.vue | 189 -
.../modals/filter/SavedFilterModal.vue | 259 -
.../components/modals/rules/RuleAction.vue | 74 -
.../modals/rules/RuleConditionItem.vue | 275 -
.../modals/rules/RuleEditorModal.vue | 358 -
.../modals/rules/RuleLogicConnector.vue | 57 -
.../modals/settings/about/AboutTab.vue | 221 -
.../modals/settings/ai/AIFeatureSettings.vue | 139 -
.../modals/settings/ai/AIProfileList.vue | 335 -
.../modals/settings/ai/AIProfileModal.vue | 387 -
.../modals/settings/ai/AIProfileSelector.vue | 81 -
.../components/modals/settings/ai/AITab.vue | 44 -
.../modals/settings/ai/AIUsageSettings.vue | 221 -
.../modals/settings/content/ContentTab.vue | 69 -
.../settings/content/SummarySettings.vue | 198 -
.../settings/content/TranslationSettings.vue | 667 --
.../settings/feeds/BatchActionsDropdown.vue | 151 -
.../settings/feeds/BatchTagSelectorModal.vue | 204 -
.../settings/feeds/DataManagementSettings.vue | 56 -
.../settings/feeds/DiscoverySettings.vue | 37 -
.../settings/feeds/FeedManagementSettings.vue | 677 --
.../modals/settings/feeds/FeedsTab.vue | 124 -
.../settings/general/ApplicationSettings.vue | 126 -
.../general/DataManagementSettings.vue | 284 -
.../modals/settings/general/GeneralTab.vue | 68 -
.../settings/general/UpdateSettings.vue | 87 -
.../settings/network/NetworkSettings.vue | 154 -
.../modals/settings/network/NetworkTab.vue | 38 -
.../modals/settings/network/ProxySettings.vue | 169 -
.../settings/plugins/FreshRSSSettings.vue | 313 -
.../settings/plugins/NotionSettings.vue | 118 -
.../settings/plugins/ObsidianSettings.vue | 101 -
.../modals/settings/plugins/PluginsTab.vue | 54 -
.../settings/plugins/RSSHubSettings.vue | 186 -
.../settings/plugins/ZoteroSettings.vue | 118 -
.../reading/ArticleDisplaySettings.vue | 76 -
.../settings/reading/ContentSettings.vue | 67 -
.../reading/CustomizationSettings.vue | 189 -
.../settings/reading/InteractionSettings.vue | 48 -
.../settings/reading/ReadingDisplayTab.vue | 66 -
.../settings/reading/TypographySettings.vue | 91 -
.../modals/settings/rules/RuleItem.vue | 312 -
.../modals/settings/rules/RulesTab.vue | 400 -
.../settings/shortcuts/ShortcutItem.vue | 102 -
.../settings/shortcuts/ShortcutsTab.vue | 385 -
.../settings/statistics/StatisticsTab.vue | 416 -
.../modals/settings/tags/TagFormModal.vue | 185 -
.../settings/tags/TagManagementModal.vue | 261 -
.../modals/update/UpdateAvailableDialog.vue | 199 -
.../components/settings/FontFamilySelect.vue | 107 -
.../settings/advanced/KeyValueInput.vue | 73 -
.../settings/advanced/KeyValueList.vue | 182 -
.../settings/base/NestedSettingsContainer.vue | 17 -
.../base/SettingControl/ButtonControl.vue | 72 -
.../base/SettingControl/InputControl.vue | 72 -
.../base/SettingControl/NumberControl.vue | 107 -
.../base/SettingControl/SelectControl.vue | 51 -
.../base/SettingControl/TextAreaControl.vue | 62 -
.../base/SettingControl/ToggleControl.vue | 47 -
.../components/settings/base/SettingGroup.vue | 67 -
.../components/settings/base/SettingItem.vue | 55 -
.../components/settings/base/StatusBox.vue | 70 -
.../settings/base/StatusBoxGroup.vue | 94 -
.../settings/base/SubSettingItem.vue | 55 -
.../src/components/settings/base/TipBox.vue | 135 -
.../settings/composite/SettingWithSelect.vue | 40 -
.../settings/composite/SettingWithToggle.vue | 30 -
frontend/src/components/settings/index.ts | 23 -
frontend/src/components/settings/styles.css | 39 -
.../src/components/sidebar/ActivityBar.vue | 417 -
frontend/src/components/sidebar/FeedList.vue | 883 --
.../components/sidebar/SavedFilterItem.vue | 110 -
frontend/src/components/sidebar/Sidebar.vue | 357 -
.../components/sidebar/SidebarCategory.vue | 501 -
.../src/components/sidebar/SidebarFeed.vue | 326 -
frontend/src/composables/ai/useAIProfiles.ts | 245 -
frontend/src/composables/ai/useAIProvider.ts | 97 -
.../composables/article/floatingToc.test.ts | 157 -
.../src/composables/article/floatingToc.ts | 174 -
.../composables/article/useArticleActions.ts | 544 -
.../composables/article/useArticleDetail.ts | 949 --
.../composables/article/useArticleFilter.ts | 124 -
.../article/useArticleRendering.test.ts | 117 -
.../article/useArticleRendering.ts | 344 -
.../composables/article/useArticleSummary.ts | 217 -
.../article/useArticleTranslation.ts | 173 -
.../article/useContentTranslation.test.ts | 310 -
.../article/useContentTranslation.ts | 307 -
.../composables/article/useSavedFilters.ts | 164 -
.../src/composables/core/useAppUpdates.ts | 159 -
.../composables/core/useSettings.generated.ts | 476 -
frontend/src/composables/core/useSettings.ts | 102 -
.../composables/core/useSettingsAutoSave.ts | 260 -
.../composables/core/useSettingsValidation.ts | 90 -
frontend/src/composables/core/useSidebar.ts | 496 -
.../src/composables/core/useWindowState.ts | 165 -
.../discovery/useDiscoverAllFeeds.ts | 299 -
.../composables/discovery/useFeedDiscovery.ts | 166 -
.../discovery/useFeedSubscription.ts | 85 -
frontend/src/composables/feed/useFeedForm.ts | 431 -
.../src/composables/feed/useFeedManagement.ts | 459 -
.../composables/filter/useFilterConditions.ts | 88 -
.../src/composables/filter/useFilterFields.ts | 304 -
.../src/composables/rules/useRuleActions.ts | 34 -
.../composables/rules/useRuleConditions.ts | 55 -
.../src/composables/rules/useRuleOptions.ts | 259 -
frontend/src/composables/ui/useContextMenu.ts | 40 -
frontend/src/composables/ui/useDragDrop.ts | 427 -
.../composables/ui/useKeyboardShortcuts.ts | 476 -
frontend/src/composables/ui/useModalClose.ts | 66 -
.../src/composables/ui/useNotifications.ts | 141 -
.../src/composables/ui/useResizablePanels.ts | 106 -
frontend/src/composables/ui/useSelect.ts | 392 -
.../composables/ui/useShowPreviewImages.ts | 45 -
frontend/src/config/defaults.ts | 13 -
frontend/src/i18n/index.ts | 21 -
frontend/src/i18n/locales/en.ts | 1248 ---
frontend/src/i18n/locales/zh.ts | 1221 ---
frontend/src/i18n/types.ts | 5 -
frontend/src/main.ts | 65 -
frontend/src/stores/app.ts | 908 --
frontend/src/style.css | 235 -
frontend/src/test/mocks/wails.ts | 22 -
frontend/src/test/setup.ts | 112 -
frontend/src/types/aiProfile.ts | 45 -
frontend/src/types/context-menu.ts | 23 -
frontend/src/types/discovery.ts | 41 -
frontend/src/types/filter.ts | 43 -
frontend/src/types/global.d.ts | 51 -
frontend/src/types/models.ts | 188 -
frontend/src/types/select.ts | 56 -
frontend/src/types/settings.generated.ts | 122 -
frontend/src/types/settings.ts | 60 -
frontend/src/utils/aiError.ts | 118 -
frontend/src/utils/bilibili.test.ts | 158 -
frontend/src/utils/bilibili.ts | 122 -
frontend/src/utils/browser.ts | 42 -
frontend/src/utils/clipboard.ts | 53 -
frontend/src/utils/date.ts | 103 -
frontend/src/utils/fontDetector.ts | 321 -
frontend/src/utils/imageCache.ts | 142 -
frontend/src/utils/mediaProxy.ts | 230 -
frontend/src/utils/youtube.test.ts | 148 -
frontend/src/utils/youtube.ts | 82 -
frontend/src/vite-env.d.ts | 7 -
frontend/tailwind.config.js | 55 -
frontend/tsconfig.json | 34 -
frontend/tsconfig.node.json | 12 -
frontend/vite.config.js | 17 -
frontend/vitest.config.js | 58 -
go.mod | 9 +-
go.sum | 18 -
internal/handlers/browser/browser_handlers.go | 97 +-
.../browser/browser_handlers_server.go | 69 -
.../custom_css/custom_css_handlers.go | 166 +-
.../custom_css/custom_css_handlers_server.go | 228 -
internal/handlers/opml/opml_handlers.go | 309 +-
.../handlers/opml/opml_handlers_server.go | 150 -
main-core.go | 240 -
main.go | 667 +-
scripts/check.ps1 | 37 -
scripts/check.sh | 14 +-
scripts/pre-release.ps1 | 51 -
scripts/pre-release.sh | 22 +-
318 files changed, 282 insertions(+), 65595 deletions(-)
delete mode 100644 Taskfile.yml
delete mode 100644 build/Taskfile.yml
delete mode 100644 build/config.yml
delete mode 100644 build/darwin/Info.dev.plist
delete mode 100644 build/darwin/Info.plist
delete mode 100644 build/darwin/Taskfile.yml
delete mode 100644 build/darwin/create-dmg.sh
delete mode 100644 build/darwin/entitlements.plist
delete mode 100644 build/docker/Dockerfile.cross
delete mode 100644 build/docker/build-script.sh
delete mode 100644 build/ios/Assets.xcassets
delete mode 100644 build/ios/Info.dev.plist
delete mode 100644 build/ios/Info.plist
delete mode 100644 build/ios/LaunchScreen.storyboard
delete mode 100644 build/ios/build.sh
delete mode 100644 build/ios/entitlements.plist
delete mode 100644 build/ios/project.pbxproj
delete mode 100644 build/linux/Taskfile.yml
delete mode 100755 build/linux/create-appimage.sh
delete mode 100644 build/linux/desktop
delete mode 100644 build/linux/nfpm/nfpm.yaml
delete mode 100644 build/windows/Taskfile.yml
delete mode 100644 build/windows/icon.ico
delete mode 100644 build/windows/icon.png
delete mode 100644 build/windows/info.json
delete mode 100644 build/windows/installer.nsi
delete mode 100644 build/windows/nsis/wails_tools.nsh
delete mode 100644 build/windows/wails.exe.manifest
delete mode 100644 frontend/.husky/pre-commit
delete mode 100644 frontend/.prettierignore
delete mode 100644 frontend/.prettierrc
create mode 100644 frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js
create mode 100644 frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
delete mode 100644 frontend/cypress.config.ts
delete mode 100644 frontend/cypress/README.md
delete mode 100644 frontend/cypress/e2e/app-smoke.cy.ts
delete mode 100644 frontend/cypress/e2e/article-operations.cy.ts
delete mode 100644 frontend/cypress/e2e/auto-update-quick.cy.ts
delete mode 100644 frontend/cypress/e2e/auto-update.cy.ts
delete mode 100644 frontend/cypress/e2e/feed-management.cy.ts
delete mode 100644 frontend/cypress/e2e/language-selector-test.cy.ts
delete mode 100644 frontend/cypress/e2e/quick-test.cy.ts
delete mode 100644 frontend/cypress/e2e/settings-persistence.cy.ts
delete mode 100644 frontend/cypress/e2e/simple-test.cy.ts
delete mode 100644 frontend/cypress/e2e/theme-and-language.cy.ts
delete mode 100644 frontend/cypress/e2e/verify-test.cy.ts
delete mode 100644 frontend/cypress/fixtures/example.json
delete mode 100644 frontend/cypress/fixtures/settings.json
delete mode 100644 frontend/cypress/plugins/index.ts
delete mode 100644 frontend/cypress/scripts/setup-tests.ts
delete mode 100644 frontend/cypress/support/commands.ts
delete mode 100644 frontend/cypress/support/component.ts
delete mode 100644 frontend/cypress/support/e2e.ts
delete mode 100644 frontend/eslint.config.js
delete mode 100644 frontend/index.html
delete mode 100644 frontend/knip.json
delete mode 100644 frontend/package-lock.json
delete mode 100644 frontend/package.json
delete mode 100644 frontend/package.json.md5
delete mode 100644 frontend/postcss.config.js
delete mode 100644 frontend/public/assets/ai_icons/README
delete mode 100644 frontend/public/assets/ai_icons/claude.svg
delete mode 100644 frontend/public/assets/ai_icons/deepseek.svg
delete mode 100644 frontend/public/assets/ai_icons/gemini.svg
delete mode 100644 frontend/public/assets/ai_icons/grok.svg
delete mode 100644 frontend/public/assets/ai_icons/meta.svg
delete mode 100644 frontend/public/assets/ai_icons/minimax.svg
delete mode 100644 frontend/public/assets/ai_icons/openai.svg
delete mode 100644 frontend/public/assets/ai_icons/qwen.svg
delete mode 100644 frontend/public/assets/ai_icons/wenxin.svg
delete mode 100644 frontend/public/assets/ai_icons/yuanbao.svg
delete mode 100644 frontend/public/assets/ai_icons/zhipu.svg
delete mode 100644 frontend/public/assets/logo-circle.png
delete mode 100644 frontend/public/assets/logo.svg
delete mode 100644 frontend/public/assets/plugin_icons/README
delete mode 100644 frontend/public/assets/plugin_icons/freshrss.svg
delete mode 100644 frontend/public/assets/plugin_icons/notion.svg
delete mode 100644 frontend/public/assets/plugin_icons/obsidian.svg
delete mode 100644 frontend/public/assets/plugin_icons/rsshub.svg
delete mode 100644 frontend/public/assets/plugin_icons/zotero.png
delete mode 100644 frontend/public/assets/video_icons/bilibili.svg
delete mode 100644 frontend/public/assets/video_icons/youtube.svg
delete mode 100644 frontend/scripts/test-e2e.bat
delete mode 100644 frontend/scripts/test-e2e.sh
delete mode 100644 frontend/src/App.test.ts
delete mode 100644 frontend/src/App.vue
delete mode 100644 frontend/src/components/article/AISearchBar.vue
delete mode 100644 frontend/src/components/article/ArticleCardItem.vue
delete mode 100644 frontend/src/components/article/ArticleChatButton.vue
delete mode 100644 frontend/src/components/article/ArticleChatPanel.vue
delete mode 100644 frontend/src/components/article/ArticleContent.css
delete mode 100644 frontend/src/components/article/ArticleContent.vue
delete mode 100644 frontend/src/components/article/ArticleDetail.vue
delete mode 100644 frontend/src/components/article/ArticleDetailModal.vue
delete mode 100644 frontend/src/components/article/ArticleItem.vue
delete mode 100644 frontend/src/components/article/ArticleList.vue
delete mode 100644 frontend/src/components/article/ArticleToolbar.vue
delete mode 100644 frontend/src/components/article/imageGallery/components/ImageCard.vue
delete mode 100644 frontend/src/components/article/imageGallery/components/ImageGalleryGrid.vue
delete mode 100644 frontend/src/components/article/imageGallery/components/ImageGalleryHeader.vue
delete mode 100644 frontend/src/components/article/imageGallery/components/ImageViewerModal.vue
delete mode 100644 frontend/src/components/article/imageGallery/components/ThumbnailStrip.vue
delete mode 100644 frontend/src/components/article/imageGallery/composables/useGalleryKeyboard.ts
delete mode 100644 frontend/src/components/article/imageGallery/composables/useImageActions.ts
delete mode 100644 frontend/src/components/article/imageGallery/composables/useImageGalleryData.ts
delete mode 100644 frontend/src/components/article/imageGallery/composables/useImageViewer.ts
delete mode 100644 frontend/src/components/article/imageGallery/composables/useMasonryLayout.ts
delete mode 100644 frontend/src/components/article/imageGallery/index.vue
delete mode 100644 frontend/src/components/article/imageGallery/types/index.ts
delete mode 100644 frontend/src/components/article/parts/ArticleBody.vue
delete mode 100644 frontend/src/components/article/parts/ArticleSummary.vue
delete mode 100644 frontend/src/components/article/parts/ArticleTitle.vue
delete mode 100644 frontend/src/components/article/parts/AudioPlayer.vue
delete mode 100644 frontend/src/components/article/parts/FloatingToc.vue
delete mode 100644 frontend/src/components/article/parts/VideoPlayer.vue
delete mode 100644 frontend/src/components/common/BaseModal.vue
delete mode 100644 frontend/src/components/common/BaseMultiSelect.vue
delete mode 100644 frontend/src/components/common/BaseSelect.vue
delete mode 100644 frontend/src/components/common/ContextMenu.vue
delete mode 100644 frontend/src/components/common/FindInPage.vue
delete mode 100644 frontend/src/components/common/ImageViewer.vue
delete mode 100644 frontend/src/components/common/ModalFooter.vue
delete mode 100644 frontend/src/components/common/Toast.vue
delete mode 100644 frontend/src/components/common/select.css
delete mode 100644 frontend/src/components/modals/SettingsModal.vue
delete mode 100644 frontend/src/components/modals/common/ConfirmDialog.vue
delete mode 100644 frontend/src/components/modals/common/InputDialog.vue
delete mode 100644 frontend/src/components/modals/common/MultiSelectDialog.vue
delete mode 100644 frontend/src/components/modals/discovery/DiscoverAllFeedsModal.vue
delete mode 100644 frontend/src/components/modals/discovery/DiscoverFeedsModal.vue
delete mode 100644 frontend/src/components/modals/discovery/DiscoveredFeedItem.vue
delete mode 100644 frontend/src/components/modals/discovery/DiscoveryProgress.vue
delete mode 100644 frontend/src/components/modals/discovery/DiscoveryResults.vue
delete mode 100644 frontend/src/components/modals/feed/AddFeedModal.vue
delete mode 100644 frontend/src/components/modals/feed/EditFeedModal.vue
delete mode 100644 frontend/src/components/modals/feed/FeedFormModal.vue
delete mode 100644 frontend/src/components/modals/feed/parts/AdvancedSettings.vue
delete mode 100644 frontend/src/components/modals/feed/parts/CategorySelector.vue
delete mode 100644 frontend/src/components/modals/feed/parts/EmailConfig.vue
delete mode 100644 frontend/src/components/modals/feed/parts/ScriptSelector.vue
delete mode 100644 frontend/src/components/modals/feed/parts/TagSelector.vue
delete mode 100644 frontend/src/components/modals/feed/parts/UrlInput.vue
delete mode 100644 frontend/src/components/modals/feed/parts/XPathConfig.vue
delete mode 100644 frontend/src/components/modals/filter/ArticleFilterModal.vue
delete mode 100644 frontend/src/components/modals/filter/SavedFilterModal.vue
delete mode 100644 frontend/src/components/modals/rules/RuleAction.vue
delete mode 100644 frontend/src/components/modals/rules/RuleConditionItem.vue
delete mode 100644 frontend/src/components/modals/rules/RuleEditorModal.vue
delete mode 100644 frontend/src/components/modals/rules/RuleLogicConnector.vue
delete mode 100644 frontend/src/components/modals/settings/about/AboutTab.vue
delete mode 100644 frontend/src/components/modals/settings/ai/AIFeatureSettings.vue
delete mode 100644 frontend/src/components/modals/settings/ai/AIProfileList.vue
delete mode 100644 frontend/src/components/modals/settings/ai/AIProfileModal.vue
delete mode 100644 frontend/src/components/modals/settings/ai/AIProfileSelector.vue
delete mode 100644 frontend/src/components/modals/settings/ai/AITab.vue
delete mode 100644 frontend/src/components/modals/settings/ai/AIUsageSettings.vue
delete mode 100644 frontend/src/components/modals/settings/content/ContentTab.vue
delete mode 100644 frontend/src/components/modals/settings/content/SummarySettings.vue
delete mode 100644 frontend/src/components/modals/settings/content/TranslationSettings.vue
delete mode 100644 frontend/src/components/modals/settings/feeds/BatchActionsDropdown.vue
delete mode 100644 frontend/src/components/modals/settings/feeds/BatchTagSelectorModal.vue
delete mode 100644 frontend/src/components/modals/settings/feeds/DataManagementSettings.vue
delete mode 100644 frontend/src/components/modals/settings/feeds/DiscoverySettings.vue
delete mode 100644 frontend/src/components/modals/settings/feeds/FeedManagementSettings.vue
delete mode 100644 frontend/src/components/modals/settings/feeds/FeedsTab.vue
delete mode 100644 frontend/src/components/modals/settings/general/ApplicationSettings.vue
delete mode 100644 frontend/src/components/modals/settings/general/DataManagementSettings.vue
delete mode 100644 frontend/src/components/modals/settings/general/GeneralTab.vue
delete mode 100644 frontend/src/components/modals/settings/general/UpdateSettings.vue
delete mode 100644 frontend/src/components/modals/settings/network/NetworkSettings.vue
delete mode 100644 frontend/src/components/modals/settings/network/NetworkTab.vue
delete mode 100644 frontend/src/components/modals/settings/network/ProxySettings.vue
delete mode 100644 frontend/src/components/modals/settings/plugins/FreshRSSSettings.vue
delete mode 100644 frontend/src/components/modals/settings/plugins/NotionSettings.vue
delete mode 100644 frontend/src/components/modals/settings/plugins/ObsidianSettings.vue
delete mode 100644 frontend/src/components/modals/settings/plugins/PluginsTab.vue
delete mode 100644 frontend/src/components/modals/settings/plugins/RSSHubSettings.vue
delete mode 100644 frontend/src/components/modals/settings/plugins/ZoteroSettings.vue
delete mode 100644 frontend/src/components/modals/settings/reading/ArticleDisplaySettings.vue
delete mode 100644 frontend/src/components/modals/settings/reading/ContentSettings.vue
delete mode 100644 frontend/src/components/modals/settings/reading/CustomizationSettings.vue
delete mode 100644 frontend/src/components/modals/settings/reading/InteractionSettings.vue
delete mode 100644 frontend/src/components/modals/settings/reading/ReadingDisplayTab.vue
delete mode 100644 frontend/src/components/modals/settings/reading/TypographySettings.vue
delete mode 100644 frontend/src/components/modals/settings/rules/RuleItem.vue
delete mode 100644 frontend/src/components/modals/settings/rules/RulesTab.vue
delete mode 100644 frontend/src/components/modals/settings/shortcuts/ShortcutItem.vue
delete mode 100644 frontend/src/components/modals/settings/shortcuts/ShortcutsTab.vue
delete mode 100644 frontend/src/components/modals/settings/statistics/StatisticsTab.vue
delete mode 100644 frontend/src/components/modals/settings/tags/TagFormModal.vue
delete mode 100644 frontend/src/components/modals/settings/tags/TagManagementModal.vue
delete mode 100644 frontend/src/components/modals/update/UpdateAvailableDialog.vue
delete mode 100644 frontend/src/components/settings/FontFamilySelect.vue
delete mode 100644 frontend/src/components/settings/advanced/KeyValueInput.vue
delete mode 100644 frontend/src/components/settings/advanced/KeyValueList.vue
delete mode 100644 frontend/src/components/settings/base/NestedSettingsContainer.vue
delete mode 100644 frontend/src/components/settings/base/SettingControl/ButtonControl.vue
delete mode 100644 frontend/src/components/settings/base/SettingControl/InputControl.vue
delete mode 100644 frontend/src/components/settings/base/SettingControl/NumberControl.vue
delete mode 100644 frontend/src/components/settings/base/SettingControl/SelectControl.vue
delete mode 100644 frontend/src/components/settings/base/SettingControl/TextAreaControl.vue
delete mode 100644 frontend/src/components/settings/base/SettingControl/ToggleControl.vue
delete mode 100644 frontend/src/components/settings/base/SettingGroup.vue
delete mode 100644 frontend/src/components/settings/base/SettingItem.vue
delete mode 100644 frontend/src/components/settings/base/StatusBox.vue
delete mode 100644 frontend/src/components/settings/base/StatusBoxGroup.vue
delete mode 100644 frontend/src/components/settings/base/SubSettingItem.vue
delete mode 100644 frontend/src/components/settings/base/TipBox.vue
delete mode 100644 frontend/src/components/settings/composite/SettingWithSelect.vue
delete mode 100644 frontend/src/components/settings/composite/SettingWithToggle.vue
delete mode 100644 frontend/src/components/settings/index.ts
delete mode 100644 frontend/src/components/settings/styles.css
delete mode 100644 frontend/src/components/sidebar/ActivityBar.vue
delete mode 100644 frontend/src/components/sidebar/FeedList.vue
delete mode 100644 frontend/src/components/sidebar/SavedFilterItem.vue
delete mode 100644 frontend/src/components/sidebar/Sidebar.vue
delete mode 100644 frontend/src/components/sidebar/SidebarCategory.vue
delete mode 100644 frontend/src/components/sidebar/SidebarFeed.vue
delete mode 100644 frontend/src/composables/ai/useAIProfiles.ts
delete mode 100644 frontend/src/composables/ai/useAIProvider.ts
delete mode 100644 frontend/src/composables/article/floatingToc.test.ts
delete mode 100644 frontend/src/composables/article/floatingToc.ts
delete mode 100644 frontend/src/composables/article/useArticleActions.ts
delete mode 100644 frontend/src/composables/article/useArticleDetail.ts
delete mode 100644 frontend/src/composables/article/useArticleFilter.ts
delete mode 100644 frontend/src/composables/article/useArticleRendering.test.ts
delete mode 100644 frontend/src/composables/article/useArticleRendering.ts
delete mode 100644 frontend/src/composables/article/useArticleSummary.ts
delete mode 100644 frontend/src/composables/article/useArticleTranslation.ts
delete mode 100644 frontend/src/composables/article/useContentTranslation.test.ts
delete mode 100644 frontend/src/composables/article/useContentTranslation.ts
delete mode 100644 frontend/src/composables/article/useSavedFilters.ts
delete mode 100644 frontend/src/composables/core/useAppUpdates.ts
delete mode 100644 frontend/src/composables/core/useSettings.generated.ts
delete mode 100644 frontend/src/composables/core/useSettings.ts
delete mode 100644 frontend/src/composables/core/useSettingsAutoSave.ts
delete mode 100644 frontend/src/composables/core/useSettingsValidation.ts
delete mode 100644 frontend/src/composables/core/useSidebar.ts
delete mode 100644 frontend/src/composables/core/useWindowState.ts
delete mode 100644 frontend/src/composables/discovery/useDiscoverAllFeeds.ts
delete mode 100644 frontend/src/composables/discovery/useFeedDiscovery.ts
delete mode 100644 frontend/src/composables/discovery/useFeedSubscription.ts
delete mode 100644 frontend/src/composables/feed/useFeedForm.ts
delete mode 100644 frontend/src/composables/feed/useFeedManagement.ts
delete mode 100644 frontend/src/composables/filter/useFilterConditions.ts
delete mode 100644 frontend/src/composables/filter/useFilterFields.ts
delete mode 100644 frontend/src/composables/rules/useRuleActions.ts
delete mode 100644 frontend/src/composables/rules/useRuleConditions.ts
delete mode 100644 frontend/src/composables/rules/useRuleOptions.ts
delete mode 100644 frontend/src/composables/ui/useContextMenu.ts
delete mode 100644 frontend/src/composables/ui/useDragDrop.ts
delete mode 100644 frontend/src/composables/ui/useKeyboardShortcuts.ts
delete mode 100644 frontend/src/composables/ui/useModalClose.ts
delete mode 100644 frontend/src/composables/ui/useNotifications.ts
delete mode 100644 frontend/src/composables/ui/useResizablePanels.ts
delete mode 100644 frontend/src/composables/ui/useSelect.ts
delete mode 100644 frontend/src/composables/ui/useShowPreviewImages.ts
delete mode 100644 frontend/src/config/defaults.ts
delete mode 100644 frontend/src/i18n/index.ts
delete mode 100644 frontend/src/i18n/locales/en.ts
delete mode 100644 frontend/src/i18n/locales/zh.ts
delete mode 100644 frontend/src/i18n/types.ts
delete mode 100644 frontend/src/main.ts
delete mode 100644 frontend/src/stores/app.ts
delete mode 100644 frontend/src/style.css
delete mode 100644 frontend/src/test/mocks/wails.ts
delete mode 100644 frontend/src/test/setup.ts
delete mode 100644 frontend/src/types/aiProfile.ts
delete mode 100644 frontend/src/types/context-menu.ts
delete mode 100644 frontend/src/types/discovery.ts
delete mode 100644 frontend/src/types/filter.ts
delete mode 100644 frontend/src/types/global.d.ts
delete mode 100644 frontend/src/types/models.ts
delete mode 100644 frontend/src/types/select.ts
delete mode 100644 frontend/src/types/settings.generated.ts
delete mode 100644 frontend/src/types/settings.ts
delete mode 100644 frontend/src/utils/aiError.ts
delete mode 100644 frontend/src/utils/bilibili.test.ts
delete mode 100644 frontend/src/utils/bilibili.ts
delete mode 100644 frontend/src/utils/browser.ts
delete mode 100644 frontend/src/utils/clipboard.ts
delete mode 100644 frontend/src/utils/date.ts
delete mode 100644 frontend/src/utils/fontDetector.ts
delete mode 100644 frontend/src/utils/imageCache.ts
delete mode 100644 frontend/src/utils/mediaProxy.ts
delete mode 100644 frontend/src/utils/youtube.test.ts
delete mode 100644 frontend/src/utils/youtube.ts
delete mode 100644 frontend/src/vite-env.d.ts
delete mode 100644 frontend/tailwind.config.js
delete mode 100644 frontend/tsconfig.json
delete mode 100644 frontend/tsconfig.node.json
delete mode 100644 frontend/vite.config.js
delete mode 100644 frontend/vitest.config.js
delete mode 100644 internal/handlers/browser/browser_handlers_server.go
delete mode 100644 internal/handlers/custom_css/custom_css_handlers_server.go
delete mode 100644 internal/handlers/opml/opml_handlers_server.go
delete mode 100644 main-core.go
delete mode 100644 scripts/check.ps1
delete mode 100644 scripts/pre-release.ps1
diff --git a/.github/workflows/pre-release-check.yml b/.github/workflows/pre-release-check.yml
index a8a7a756..91e2cdd9 100644
--- a/.github/workflows/pre-release-check.yml
+++ b/.github/workflows/pre-release-check.yml
@@ -5,40 +5,10 @@ on:
jobs:
build-check:
- name: Build Check ${{ matrix.platform }}-${{ matrix.arch }}
- runs-on: ${{ matrix.os }}
+ name: Build macOS Client Bundle
+ runs-on: macos-latest
permissions:
contents: read
- strategy:
- fail-fast: false
- matrix:
- include:
- # Linux AMD64 build
- - os: ubuntu-24.04
- platform: linux
- arch: amd64
- ext: ''
- # Linux ARM64 build - using native ARM64 runner
- - os: ubuntu-24.04-arm
- platform: linux
- arch: arm64
- ext: ''
- # Windows AMD64 build
- - os: windows-latest
- platform: windows
- arch: amd64
- ext: '.exe'
- # Windows ARM64 build - using native ARM64 runner
- - os: windows-11-arm
- platform: windows
- arch: arm64
- ext: '.exe'
- # macOS Universal build (includes both Intel and Apple Silicon)
- - os: macos-latest
- platform: darwin
- arch: universal
- ext: '.app'
-
steps:
- uses: actions/checkout@v7
@@ -47,222 +17,22 @@ jobs:
with:
go-version: '1.27'
- - name: Set up Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '24'
-
- - name: Install Task (task runner)
- uses: arduino/setup-task@v3
- with:
- version: 3.x
- repo-token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Install Linux dependencies (AMD64)
- if: matrix.platform == 'linux' && matrix.arch == 'amd64'
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- libgtk-4-dev \
- libwebkitgtk-6.0-dev \
- libsoup-3.0-dev \
- gcc \
- pkg-config \
- wget \
- file
-
- # Verify GTK4/WebKitGTK 6.0 are available for current Wails v3
- pkg-config --modversion gtk4 || exit 1
- pkg-config --modversion webkitgtk-6.0 || exit 1
- echo "GTK4 and WebKitGTK 6.0 installed successfully for Wails v3"
-
- - name: Install Linux dependencies (ARM64)
- if: matrix.platform == 'linux' && matrix.arch == 'arm64'
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- libgtk-4-dev \
- libwebkitgtk-6.0-dev \
- libsoup-3.0-dev \
- gcc \
- pkg-config \
- wget \
- file
-
- # Verify GTK4/WebKitGTK 6.0 are available for current Wails v3
- pkg-config --modversion gtk4 || exit 1
- pkg-config --modversion webkitgtk-6.0 || exit 1
- echo "GTK4 and WebKitGTK 6.0 installed successfully for Wails v3 ARM64"
-
- - name: Install Windows dependencies (AMD64)
- if: matrix.platform == 'windows' && matrix.arch == 'amd64'
- run: |
- choco install mingw nsis -y
- shell: pwsh
-
- - name: Install Windows dependencies (ARM64)
- if: matrix.platform == 'windows' && matrix.arch == 'arm64'
- run: |
- choco install mingw nsis -y
- # MinGW is required for CGO
- shell: pwsh
-
- - name: Install Wails CLI
- shell: bash
- run: |
- WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
- echo "Installing Wails CLI ${WAILS_VERSION}"
- go install github.com/wailsapp/wails/v3/cmd/wails3@${WAILS_VERSION}
-
- - name: Install frontend dependencies
- working-directory: ./frontend
- run: npm ci
-
- - name: Build application (Linux)
- if: matrix.platform == 'linux'
+ - name: Run backend tests
env:
CGO_ENABLED: 1
- run: |
- # wails3 build automatically handles frontend build via Taskfile
- wails3 build
-
- # Verify the build
- if [ ! -f "build/bin/MrRSS" ]; then
- echo "Error: Binary not found"
- exit 1
- fi
-
- # Check architecture
- file build/bin/MrRSS
- echo "Binary built successfully for ${{ matrix.arch }}"
+ run: go test -timeout=5m ./internal/...
- - name: Build application (Windows)
- if: matrix.platform == 'windows'
- shell: pwsh
- env:
- CGO_ENABLED: 1
- run: |
- # wails3 build automatically handles frontend build via Taskfile
- wails3 build
-
- # Verify the build
- if (-not (Test-Path "build/bin/MrRSS.exe")) {
- Write-Error "Binary not created"
- exit 1
- }
-
- Write-Host "Binary built successfully for ${{ matrix.arch }}"
-
-
-
- - name: Build application (macOS)
- if: matrix.platform == 'darwin'
- run: |
- # macOS runners can build universal binaries natively
- # wails3 build automatically handles frontend build via Taskfile
- wails3 build
-
- - name: Create installer (Windows)
- if: matrix.platform == 'windows'
- run: |
- # Find NSIS installation
- $nsisPath = Get-Command makensis -ErrorAction SilentlyContinue
- if (-not $nsisPath) {
- # Try common installation paths
- $possiblePaths = @(
- "C:\Program Files (x86)\NSIS\makensis.exe",
- "C:\Program Files\NSIS\makensis.exe"
- )
- foreach ($path in $possiblePaths) {
- if (Test-Path $path) {
- $nsisPath = $path
- break
- }
- }
- } else {
- $nsisPath = $nsisPath.Source
- }
-
- if (-not $nsisPath) {
- Write-Error "NSIS not found"
- exit 1
- }
-
- Write-Host "Using NSIS at: $nsisPath"
-
- # Get version from package.json
- $packageJson = Get-Content "frontend/package.json" -Raw | ConvertFrom-Json
- $version = $packageJson.version
- $arch = "${{ matrix.arch }}"
- $installerPath = "build/windows/installer.nsi"
-
- if (Test-Path $installerPath) {
- $content = Get-Content $installerPath -Raw
- $content = $content -replace '!define APP_VERSION ".*"', "!define APP_VERSION `"$version`""
- $content = $content -replace 'OutFile ".*"', "OutFile `"..\bin\MrRSS-$version-windows-$arch-installer.exe`""
- Set-Content $installerPath $content
-
- # Build NSIS installer
- & $nsisPath $installerPath
-
- if ($LASTEXITCODE -ne 0) {
- Write-Error "NSIS build failed with exit code $LASTEXITCODE"
- exit 1
- }
- } else {
- Write-Warning "Installer script not found at $installerPath"
- exit 1
- }
- shell: pwsh
- continue-on-error: true
-
- - name: Create installer (Linux)
- if: matrix.platform == 'linux'
- run: |
- # Clean up any cached appimagetool to ensure correct architecture is downloaded
- rm -f build/appimagetool-*.AppImage
-
- # Debug: Show system architecture info
- echo "System architecture: $(uname -m)"
- echo "Target architecture: ${{ matrix.arch }}"
-
- # Debug: Verify binary architecture
- echo "Binary architecture check:"
- file build/bin/MrRSS || true
-
- chmod +x build/linux/create-appimage.sh
- ARCH=${{ matrix.arch }} ./build/linux/create-appimage.sh || echo "AppImage creation failed, will use tar.gz"
- continue-on-error: true
-
- - name: Create installer (macOS)
- if: matrix.platform == 'darwin'
- run: |
- codesign --deep --force --verify --verbose \
- --sign - \
- --entitlements build/darwin/entitlements.plist \
- build/bin/MrRSS.app
- codesign --verify --deep --strict --verbose=2 build/bin/MrRSS.app
- chmod +x build/darwin/create-dmg.sh
- ./build/darwin/create-dmg.sh
+ - name: Run macOS client tests
+ working-directory: ./frontend-swift
+ run: swift test
- - name: Package application (Linux - tar.gz fallback)
- if: matrix.platform == 'linux'
+ - name: Build the application bundle
run: |
- cd build/bin
- if [ -f "MrRSS" ]; then
- # Get version from package.json
- VERSION=$(jq -r '.version' ../../frontend/package.json)
- tar -czf MrRSS-${VERSION}-linux-${{ matrix.arch }}.tar.gz MrRSS
- echo "Created tar.gz package"
- else
- echo "Error: MrRSS binary not found"
- exit 1
- fi
+ chmod +x frontend-swift/build-app.sh
+ frontend-swift/build-app.sh "pre-release-check"
- - name: Verify build artifacts
- shell: bash
+ - name: Verify bundle contents
run: |
- echo "Build verification for ${{ matrix.platform }}-${{ matrix.arch }}"
- echo "Checking build/bin directory..."
- ls -lh build/bin/ || echo "No artifacts found"
- echo "Build check completed successfully"
+ test -x frontend-swift/dist/MrRSS-SwiftUI.app/Contents/MacOS/MrRSS-SwiftUI
+ test -x frontend-swift/dist/MrRSS-SwiftUI.app/Contents/Resources/mrrss-server
+ test -f frontend-swift/dist/MrRSS-SwiftUI.app/Contents/Info.plist
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c8416fb3..feecb837 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -146,509 +146,8 @@ jobs:
files: build/skills/MrRSS-${{ needs.create-release.outputs.version }}-skills.zip
token: ${{ secrets.GITHUB_TOKEN }}
- build-release:
- name: Build ${{ matrix.platform }}-${{ matrix.arch }}
- needs: create-release
- runs-on: ${{ matrix.os }}
- permissions:
- contents: write
- strategy:
- fail-fast: false
- matrix:
- include:
- # Desktop platforms - always build
- # Linux AMD64 build
- - os: ubuntu-24.04
- platform: linux
- arch: amd64
- ext: ''
- build_condition: 'true'
- # Linux ARM64 build - using native ARM64 runner
- - os: ubuntu-24.04-arm
- platform: linux
- arch: arm64
- ext: ''
- build_condition: 'true'
- # Windows AMD64 build
- - os: windows-latest
- platform: windows
- arch: amd64
- ext: '.exe'
- build_condition: 'true'
- # Windows ARM64 build - using native ARM64 runner
- - os: windows-11-arm
- platform: windows
- arch: arm64
- ext: '.exe'
- build_condition: 'true'
- # macOS Universal build (includes both Intel and Apple Silicon)
- - os: macos-latest
- platform: darwin
- arch: universal
- ext: '.app'
- build_condition: 'true'
-
- # Mobile platforms - only for Wails v3 (commented out by default)
- # Uncomment these when you want to build mobile versions
- # - os: macos-latest
- # platform: ios
- # arch: arm64
- # ext: '.ipa'
- # build_condition: 'wails_v3_only'
- # - os: ubuntu-24.04
- # platform: android
- # arch: arm64
- # ext: '.apk'
- # build_condition: 'wails_v3_only'
-
- steps:
- - uses: actions/checkout@v7
- with:
- ref: ${{ needs.create-release.outputs.tag }}
-
- - name: Check if artifacts already exist
- id: check_artifacts
- shell: bash
- run: |
- # Get release info and check for existing assets
- TAG="${{ needs.create-release.outputs.tag }}"
- VERSION="${{ needs.create-release.outputs.version }}"
-
- # Determine expected artifact names for this platform/arch
- if [ "${{ matrix.platform }}" = "windows" ]; then
- INSTALLER="MrRSS-${VERSION}-windows-${{ matrix.arch }}-installer.exe"
- PACKAGE="" # No package for Windows
- elif [ "${{ matrix.platform }}" = "linux" ]; then
- INSTALLER="MrRSS-${VERSION}-linux-${{ matrix.arch }}.AppImage"
- PACKAGE="MrRSS-${VERSION}-linux-${{ matrix.arch }}.tar.gz"
- elif [ "${{ matrix.platform }}" = "darwin" ]; then
- INSTALLER="MrRSS-${VERSION}-darwin-${{ matrix.arch }}.dmg"
- PACKAGE="" # No package for macOS
- elif [ "${{ matrix.platform }}" = "ios" ]; then
- INSTALLER="MrRSS-${VERSION}-ios-${{ matrix.arch }}.ipa"
- PACKAGE="" # No package for iOS
- elif [ "${{ matrix.platform }}" = "android" ]; then
- INSTALLER="MrRSS-${VERSION}-android-${{ matrix.arch }}.apk"
- PACKAGE="" # No package for Android
- fi
-
- # Check if release has these assets
- RELEASE_JSON=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
- "https://api.github.com/repos/${{ github.repository }}/releases/tags/${TAG}")
-
- ASSETS=$(echo "$RELEASE_JSON" | jq -r '.assets[].name')
-
- echo "Checking for existing assets..."
- echo "Expected installer: $INSTALLER"
- if [ -n "$PACKAGE" ]; then
- echo "Expected package: $PACKAGE"
- fi
- echo "Found assets:"
- echo "$ASSETS"
-
- # Check if any of our artifacts exist
- SKIP_BUILD="false"
- if echo "$ASSETS" | grep -q "$INSTALLER"; then
- echo "Installer for ${{ matrix.platform }}-${{ matrix.arch }} already exists in release"
- SKIP_BUILD="true"
- elif [ -n "$PACKAGE" ] && echo "$ASSETS" | grep -q "$PACKAGE"; then
- echo "Package for ${{ matrix.platform }}-${{ matrix.arch }} already exists in release"
- SKIP_BUILD="true"
- fi
-
- echo "skip_build=$SKIP_BUILD" >> $GITHUB_OUTPUT
-
- if [ "$SKIP_BUILD" = "true" ]; then
- echo "⏭️ Skipping build for ${{ matrix.platform }}-${{ matrix.arch }} (already exists)"
- else
- echo "✅ Proceeding with build for ${{ matrix.platform }}-${{ matrix.arch }}"
- fi
-
- - name: Set up Go
- if: steps.check_artifacts.outputs.skip_build == 'false'
- uses: actions/setup-go@v7
- with:
- go-version: '1.27'
-
- - name: Set up Node.js
- if: steps.check_artifacts.outputs.skip_build == 'false'
- uses: actions/setup-node@v7
- with:
- node-version: '24'
-
- - name: Install Task (task runner)
- if: steps.check_artifacts.outputs.skip_build == 'false'
- uses: arduino/setup-task@v3
- with:
- version: 3.x
- repo-token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Install Linux dependencies (AMD64)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'linux' && matrix.arch == 'amd64'
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- libgtk-4-dev \
- libwebkitgtk-6.0-dev \
- libsoup-3.0-dev \
- gcc \
- pkg-config \
- wget \
- file
-
- # Verify GTK4/WebKitGTK 6.0 are available for current Wails v3
- pkg-config --modversion gtk4 || exit 1
- pkg-config --modversion webkitgtk-6.0 || exit 1
- echo "GTK4 and WebKitGTK 6.0 installed successfully for Wails v3"
-
- - name: Install Linux dependencies (ARM64)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'linux' && matrix.arch == 'arm64'
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- libgtk-4-dev \
- libwebkitgtk-6.0-dev \
- libsoup-3.0-dev \
- gcc \
- pkg-config \
- wget \
- file
-
- # Verify GTK4/WebKitGTK 6.0 are available for current Wails v3
- pkg-config --modversion gtk4 || exit 1
- pkg-config --modversion webkitgtk-6.0 || exit 1
- echo "GTK4 and WebKitGTK 6.0 installed successfully for Wails v3 ARM64"
-
- - name: Install Windows dependencies (AMD64)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'windows' && matrix.arch == 'amd64'
- run: |
- choco install mingw nsis -y
- shell: pwsh
-
- - name: Install Windows dependencies (ARM64)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'windows' && matrix.arch == 'arm64'
- run: |
- choco install mingw nsis -y
- # MinGW is required for CGO
- shell: pwsh
-
- - name: Install Wails CLI
- if: steps.check_artifacts.outputs.skip_build == 'false'
- shell: bash
- run: |
- WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
- echo "Installing Wails CLI ${WAILS_VERSION}"
- go install github.com/wailsapp/wails/v3/cmd/wails3@${WAILS_VERSION}
-
- - name: Install frontend dependencies
- if: steps.check_artifacts.outputs.skip_build == 'false'
- working-directory: ./frontend
- run: npm ci
-
- - name: Build application (Linux)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'linux'
- env:
- CGO_ENABLED: 1
- ARCH: ${{ matrix.arch }}
- run: |
- # wails3 build via Taskfile automatically handles frontend build
- task linux:build
-
- # Verify the build
- if [ ! -f "build/bin/MrRSS" ]; then
- echo "Error: Binary not found"
- exit 1
- fi
-
- # Check architecture
- file build/bin/MrRSS
- echo "Binary built successfully for ${{ matrix.arch }}"
-
- - name: Build application (Windows)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'windows'
- shell: pwsh
- env:
- CGO_ENABLED: 1
- ARCH: ${{ matrix.arch }}
- run: |
- # wails3 build via Taskfile automatically handles frontend build
- task windows:build
-
- # Verify the build
- if (-not (Test-Path "build/bin/MrRSS.exe")) {
- Write-Error "Binary not created"
- exit 1
- }
-
- Write-Host "Binary built successfully for ${{ matrix.arch }}"
-
- - name: Build application (macOS)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'darwin'
- env:
- CGO_ENABLED: 1
- ARCH: ${{ matrix.arch }}
- run: |
- # macOS runners can build universal binaries natively
- # wails3 build via Taskfile automatically handles frontend build
- task darwin:build
-
- - name: Create installer (Windows)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'windows'
- run: |
- # Find NSIS installation
- $nsisPath = Get-Command makensis -ErrorAction SilentlyContinue
- if (-not $nsisPath) {
- # Try common installation paths
- $possiblePaths = @(
- "C:\Program Files (x86)\NSIS\makensis.exe",
- "C:\Program Files\NSIS\makensis.exe"
- )
- foreach ($path in $possiblePaths) {
- if (Test-Path $path) {
- $nsisPath = $path
- break
- }
- }
- } else {
- $nsisPath = $nsisPath.Source
- }
-
- if (-not $nsisPath) {
- Write-Error "NSIS not found"
- exit 1
- }
-
- Write-Host "Using NSIS at: $nsisPath"
-
- # Update version and architecture in installer script
- $version = "${{ needs.create-release.outputs.version }}"
- $arch = "${{ matrix.arch }}"
- $installerPath = "build/windows/installer.nsi"
-
- if (Test-Path $installerPath) {
- $content = Get-Content $installerPath -Raw
- $content = $content -replace '!define APP_VERSION ".*"', "!define APP_VERSION `"$version`""
- $content = $content -replace 'OutFile ".*"', "OutFile `"..\bin\MrRSS-$version-windows-$arch-installer.exe`""
- Set-Content $installerPath $content
-
- # Build NSIS installer
- & $nsisPath $installerPath
-
- if ($LASTEXITCODE -ne 0) {
- Write-Error "NSIS build failed with exit code $LASTEXITCODE"
- exit 1
- }
- } else {
- Write-Warning "Installer script not found at $installerPath"
- exit 1
- }
- shell: pwsh
- continue-on-error: true
-
- - name: Create portable package (Windows)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'windows'
- run: |
- $version = "${{ needs.create-release.outputs.version }}"
- $arch = "${{ matrix.arch }}"
- $portableDir = "build/portable-temp"
-
- # Create portable directory structure
- New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
-
- # Copy executable
- Copy-Item "build/bin/MrRSS.exe" -Destination $portableDir
-
- # Create portable.txt marker
- New-Item -ItemType File -Path "$portableDir/portable.txt" | Out-Null
-
- # Create README for portable version
- @"
- MrRSS Portable Edition
-
- This is the portable version of MrRSS. All data will be stored in the 'data' folder
- next to this executable.
-
- To run: Double-click MrRSS.exe
-
- For more information, visit: https://github.com/DevXDojo/MrRSS
- "@ | Out-File -FilePath "$portableDir/README.txt" -Encoding UTF8
-
- # Create zip file
- $zipName = "MrRSS-$version-windows-$arch-portable.zip"
- Compress-Archive -Path "$portableDir/*" -DestinationPath "build/bin/$zipName" -Force
-
- Write-Host "Created portable package: $zipName"
- shell: pwsh
-
- - name: Create installer (Linux)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'linux'
- run: |
- # Clean up any cached appimagetool to ensure correct architecture is downloaded
- rm -f build/appimagetool-*.AppImage
-
- # Debug: Show system architecture info
- echo "System architecture: $(uname -m)"
- echo "Target architecture: ${{ matrix.arch }}"
-
- # Debug: Verify binary architecture
- echo "Binary architecture check:"
- file build/bin/MrRSS || true
-
- chmod +x build/linux/create-appimage.sh
- ARCH=${{ matrix.arch }} ./build/linux/create-appimage.sh || echo "AppImage creation failed, will use tar.gz"
- continue-on-error: true
-
- - name: Create installer (macOS)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'darwin'
- run: |
- codesign --deep --force --verify --verbose \
- --sign - \
- --entitlements build/darwin/entitlements.plist \
- build/bin/MrRSS.app
- codesign --verify --deep --strict --verbose=2 build/bin/MrRSS.app
- chmod +x build/darwin/create-dmg.sh
- ./build/darwin/create-dmg.sh
-
- - name: Create portable package (macOS)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'darwin'
- run: |
- VERSION="${{ needs.create-release.outputs.version }}"
- ARCH="${{ matrix.arch }}"
- PORTABLE_DIR="build/portable-temp"
-
- # Create portable directory structure
- mkdir -p "$PORTABLE_DIR"
-
- # Copy .app bundle
- cp -R build/bin/MrRSS.app "$PORTABLE_DIR/"
-
- # Create portable.txt marker
- touch "$PORTABLE_DIR/portable.txt"
-
- # Create README for portable version
- cat > "$PORTABLE_DIR/README.txt" << 'EOF'
- MrRSS Portable Edition
-
- This is the portable version of MrRSS. All data will be stored in the 'data' folder
- next to the MrRSS.app.
-
- To run: Double-click MrRSS.app
-
- For more information, visit: https://github.com/DevXDojo/MrRSS
- EOF
-
- # Create zip file
- cd "$PORTABLE_DIR"
- zip -r "../bin/MrRSS-${VERSION}-darwin-${ARCH}-portable.zip" *
- cd ../..
-
- echo "Created portable package: MrRSS-${VERSION}-darwin-${ARCH}-portable.zip"
-
- - name: Package application (Linux - tar.gz fallback)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'linux'
- run: |
- cd build/bin
- if [ -f "MrRSS" ]; then
- tar -czf MrRSS-${{ needs.create-release.outputs.version }}-linux-${{ matrix.arch }}.tar.gz MrRSS
- else
- echo "Error: MrRSS binary not found"
- exit 1
- fi
-
- - name: Create portable package (Linux)
- if: steps.check_artifacts.outputs.skip_build == 'false' && matrix.platform == 'linux'
- run: |
- VERSION="${{ needs.create-release.outputs.version }}"
- ARCH="${{ matrix.arch }}"
- PORTABLE_DIR="build/portable-temp"
-
- # Create portable directory structure
- mkdir -p "$PORTABLE_DIR"
-
- # Copy executable
- cp build/bin/MrRSS "$PORTABLE_DIR/"
- chmod +x "$PORTABLE_DIR/MrRSS"
-
- # Create portable.txt marker
- touch "$PORTABLE_DIR/portable.txt"
-
- # Create README for portable version
- cat > "$PORTABLE_DIR/README.txt" << 'EOF'
- MrRSS Portable Edition
-
- This is the portable version of MrRSS. All data will be stored in the 'data' folder
- next to this executable.
-
- To run: ./MrRSS
-
- For more information, visit: https://github.com/DevXDojo/MrRSS
- EOF
-
- # Create tar.gz file
- cd "$PORTABLE_DIR"
- tar -czf "../bin/MrRSS-${VERSION}-linux-${ARCH}-portable.tar.gz" *
- cd ../..
-
- echo "Created portable package: MrRSS-${VERSION}-linux-${ARCH}-portable.tar.gz"
-
- - name: Collect artifacts
- if: steps.check_artifacts.outputs.skip_build == 'false'
- id: collect_artifacts
- shell: bash
- run: |
- cd build/bin
- artifacts=""
-
- # Check for installer and portable package
- if [ "${{ matrix.platform }}" = "windows" ]; then
- installer="MrRSS-${{ needs.create-release.outputs.version }}-windows-${{ matrix.arch }}-installer.exe"
- portable="MrRSS-${{ needs.create-release.outputs.version }}-windows-${{ matrix.arch }}-portable.zip"
- if [ -f "$installer" ]; then
- artifacts="$artifacts$installer,"
- fi
- if [ -f "$portable" ]; then
- artifacts="$artifacts$portable,"
- fi
- elif [ "${{ matrix.platform }}" = "linux" ]; then
- installer="MrRSS-${{ needs.create-release.outputs.version }}-linux-${{ matrix.arch }}.AppImage"
- portable="MrRSS-${{ needs.create-release.outputs.version }}-linux-${{ matrix.arch }}-portable.tar.gz"
- if [ -f "$installer" ]; then
- artifacts="$artifacts$installer,"
- fi
- # Check for tar.gz fallback
- package="MrRSS-${{ needs.create-release.outputs.version }}-linux-${{ matrix.arch }}.tar.gz"
- if [ -f "$package" ]; then
- artifacts="$artifacts$package,"
- fi
- if [ -f "$portable" ]; then
- artifacts="$artifacts$portable,"
- fi
- elif [ "${{ matrix.platform }}" = "darwin" ]; then
- installer="MrRSS-${{ needs.create-release.outputs.version }}-darwin-${{ matrix.arch }}.dmg"
- portable="MrRSS-${{ needs.create-release.outputs.version }}-darwin-${{ matrix.arch }}-portable.zip"
- if [ -f "$installer" ]; then
- artifacts="$artifacts$installer,"
- fi
- if [ -f "$portable" ]; then
- artifacts="$artifacts$portable,"
- fi
- fi
-
- echo "artifacts=$artifacts" >> $GITHUB_OUTPUT
- echo "Found artifacts: $artifacts"
-
- - name: Upload Release Assets
- if: steps.check_artifacts.outputs.skip_build == 'false'
- uses: softprops/action-gh-release@v3
- with:
- tag_name: ${{ needs.create-release.outputs.tag }}
- files: |
- build/bin/MrRSS-${{ needs.create-release.outputs.version }}-*
- token: ${{ secrets.GITHUB_TOKEN }}
- fail_on_unmatched_files: false
-
- build-swiftui-macos:
- name: Build SwiftUI macOS Universal Client
+ build-macos-client:
+ name: Build macOS Universal Client
needs: create-release
runs-on: macos-latest
permissions:
@@ -661,20 +160,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: '1.24'
-
- - name: Set up Node.js
- uses: actions/setup-node@v6
- with:
- node-version: '20'
- cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
-
- - name: Build embedded web assets
- working-directory: ./frontend
- run: |
- npm ci
- npm run build
+ go-version: '1.27'
- name: Build SwiftUI application bundle
run: |
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b5726390..7b76cadf 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,9 +2,9 @@ name: Test
on:
push:
- branches: [ main ]
+ branches: [ main, feature/macos-client ]
pull_request:
- branches: [ main, develop ]
+ branches: [ main, develop, feature/macos-client ]
workflow_dispatch:
jobs:
@@ -16,34 +16,11 @@ jobs:
steps:
- uses: actions/checkout@v7
- - name: Install Linux dependencies (Wails v3)
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- libgtk-4-dev \
- libwebkitgtk-6.0-dev \
- libsoup-3.0-dev \
- gcc \
- pkg-config
-
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: '1.27'
- - name: Set up Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '24'
- cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
-
- - name: Build frontend (required for embed)
- working-directory: ./frontend
- run: |
- npm ci
- npm run build
-
- name: Download dependencies
run: go mod download
@@ -62,37 +39,6 @@ jobs:
files: ./coverage.out
flags: backend
- test-frontend:
- name: Test Frontend
- runs-on: ubuntu-latest
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@v7
-
- - name: Set up Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '24'
- cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
-
- - name: Install dependencies
- working-directory: ./frontend
- run: npm ci
-
- - name: Run linter
- working-directory: ./frontend
- run: npm run lint
-
- - name: Run unit tests
- working-directory: ./frontend
- run: npm test
-
- - name: Build frontend
- working-directory: ./frontend
- run: npm run build
-
validate-skills:
name: Validate Skills
runs-on: ubuntu-latest
@@ -114,8 +60,8 @@ jobs:
python skills/mrrss-assistant/scripts/generate_api_reference.py docs/SERVER_MODE/swagger.json skills/mrrss-assistant/references/api.md
git diff --exit-code skills/mrrss-assistant/references/api.md
- test-swiftui-macos:
- name: Test SwiftUI macOS Client
+ test-macos-client:
+ name: Test macOS Client
runs-on: macos-latest
permissions:
contents: read
@@ -138,36 +84,11 @@ jobs:
steps:
- uses: actions/checkout@v7
- - name: Install Linux dependencies (Wails v3)
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- libgtk-4-dev \
- libwebkitgtk-6.0-dev \
- libsoup-3.0-dev \
- gcc \
- pkg-config
-
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: '1.27'
- - name: Set up Node.js
- uses: actions/setup-node@v7
- with:
- node-version: '24'
- cache: 'npm'
- cache-dependency-path: frontend/package-lock.json
-
- - name: Install frontend dependencies
- working-directory: ./frontend
- run: npm ci
-
- - name: Build frontend (required for embed)
- working-directory: ./frontend
- run: npm run build
-
- name: Verify Go code compiles
env:
CGO_ENABLED: 1
diff --git a/.gitignore b/.gitignore
index 6039f6e6..bcbf81f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,15 +44,10 @@ go.work.sum
.idea/
.vscode/
-# Wails
-build/bin/
-build/dmg/
-build/appimage/
+# Build output
+bin/
build/skills/
-build/appimagetool-x86_64.AppImage
dist/
-wailsjs/wailsjs/runtime/
-frontend/bindings/
# database files
*.db
@@ -60,18 +55,12 @@ frontend/bindings/
*.db-wal
data/
-# vue & vite
+# node (website only)
node_modules/
-dist/
package.json.md5
*.syso
unused-files.json
-# Cypress
-frontend/cypress/videos/
-frontend/cypress/screenshots/
-frontend/cypress/downloads/
-
# log
*.log
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a7089386..21b41f5b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -28,9 +28,9 @@ repos:
language: system
files: go\.mod$
pass_filenames: false
- - id: eslint
- name: eslint
- entry: powershell.exe -Command "cd frontend; npm run lint"
+ - id: swift-build
+ name: swift-build
+ entry: swift build --package-path frontend-swift
language: system
- files: \.(js|ts|vue)$
+ files: ^frontend-swift/.*\.swift$
pass_filenames: false
diff --git a/Dockerfile.server b/Dockerfile.server
index 66fb2b0a..a8700453 100644
--- a/Dockerfile.server
+++ b/Dockerfile.server
@@ -4,8 +4,8 @@
# Build stage
FROM golang:1.27-alpine AS builder
-# Install git, Node.js, and build tools (needed for frontend build and CGO)
-RUN apk add --no-cache git nodejs npm gcc musl-dev pkgconfig
+# Install git and build tools (needed for CGO)
+RUN apk add --no-cache git gcc musl-dev pkgconfig
# Set working directory
WORKDIR /app
@@ -19,11 +19,8 @@ RUN go mod download
# Copy source code
COPY . .
-# Build frontend
-RUN cd frontend && npm install && npm run build
-
# Build the server version
-RUN CGO_ENABLED=1 GOOS=linux go build -tags server -trimpath -buildvcs=false -ldflags="-w -s" -o mrrss-server ./main-core.go
+RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -buildvcs=false -ldflags="-w -s" -o mrrss-server .
# Runtime stage - minimal alpine image
FROM alpine:latest
diff --git a/Makefile b/Makefile
index 828a1c8c..4ee787b4 100644
--- a/Makefile
+++ b/Makefile
@@ -1,193 +1,108 @@
-# Makefile for MrRSS (Wails v3 + Task)
-.PHONY: help dev build package run test test-frontend test-backend lint lint-frontend format format-backend install-deps update-deps check setup clean love swagger swagger-validate swagger-serve static-check
-
-# Detect OS
-ifeq ($(OS),Windows_NT)
- DETECTED_OS := Windows
- SHELL := pwsh.exe
- .SHELLFLAGS := -Command
- TASK := task.exe
-else
- DETECTED_OS := $(shell uname -s)
- SHELL := /bin/bash
- TASK := task
-endif
-
-# Default target
+# Makefile for MrRSS (Go backend + native macOS SwiftUI client)
+.PHONY: help dev build build-app run test test-client test-backend test-coverage \
+ lint lint-client lint-backend format format-backend install-deps update-deps \
+ check setup clean swagger swagger-validate static-check pre-commit release-check love
+
+SWIFT_PACKAGE := frontend-swift
+
help: ## Show this help message
- @echo "MrRSS Development Makefile ($(DETECTED_OS))"
+ @echo "MrRSS Development Makefile (macOS client)"
@echo ""
- @echo "Wails v3 Build System - Using Task Runner"
- @echo ""
- @echo "Available targets:"
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}'
- @echo ""
- @echo "💡 Tip: Use 'task --list' to see all available tasks"
-# Development (Wails v3 + Task)
-dev: ## Start development server with hot reload
- $(TASK) dev
+# Development
+dev: ## Start the backend and the macOS client together
+ ./$(SWIFT_PACKAGE)/run.sh
-# Building (Wails v3 + Task)
-build: ## Build application for current platform
- $(TASK) build
+serve: ## Start only the backend on 127.0.0.1:1234
+ MRRSS_DEBUG=1 go run . -host 127.0.0.1 -port 1234
-package: ## Package application with installer
- $(TASK) package
+# Building
+build: build-backend build-client ## Build the backend and the macOS client
-run: ## Run the built application
- $(TASK) run
+build-backend: ## Build the backend binary
+ go build -v -o bin/mrrss-server .
-build-frontend: ## Build frontend only
- $(TASK) common:build:frontend
+build-client: ## Build the macOS client executable
+ swift build --package-path $(SWIFT_PACKAGE)
-build-backend: ## Build backend only
- go build -v -o build/bin/ ./...
+build-app: ## Build the signed .app bundle and DMG (VERSION=x.y.z)
+ ./$(SWIFT_PACKAGE)/build-app.sh $(or $(VERSION),dev)
-# Testing
-test: test-frontend test-backend ## Run all tests
-
-test-frontend: ## Run frontend tests
- cd frontend && npm test
+run: build-backend ## Run the backend binary
+ ./bin/mrrss-server -host 127.0.0.1 -port 1234
-test-frontend-e2e: ## Run frontend E2E tests with Cypress
- cd frontend && npm run test:e2e
+# Testing
+test: test-backend test-client ## Run all tests
test-backend: ## Run backend tests
go test -v -timeout=5m -cover ./internal/...
+test-client: ## Run macOS client tests
+ swift test --package-path $(SWIFT_PACKAGE)
+
test-coverage: ## Run backend tests with coverage
go test -v -timeout=5m -coverprofile=coverage.out -covermode=atomic ./internal/...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"
-test-all: test-frontend test-frontend-e2e test-backend ## Run all tests including E2E
-
-# Code Quality
-lint: lint-frontend lint-backend ## Run all linters
-
-lint-frontend: ## Run frontend linter
- cd frontend && npm run lint
+# Code quality
+lint: lint-backend lint-client ## Run all linters
lint-backend: ## Run backend linter
go vet ./...
-ifeq ($(DETECTED_OS),Windows)
- powershell -Command '$$files = Get-ChildItem -Recurse -Include *.go | Where-Object { $$_ -notmatch "docs[\\/]SERVER_MODE" }; $$result = gofmt -d $$files ; if ($$result) { Write-Host $$result -ForegroundColor Red; exit 1 }'
- powershell -Command '$$files = Get-ChildItem -Recurse -Include *.go | Where-Object { $$_ -notmatch "docs[\\/]SERVER_MODE" }; $$importsResult = goimports -d $$files ; if ($$importsResult) { Write-Host $$importsResult -ForegroundColor Red; exit 1 }'
-else
find . -name '*.go' -not -path './docs/SERVER_MODE/*' -exec gofmt -d {} + | tee /dev/stderr | test -z "$$(cat)"
find . -name '*.go' -not -path './docs/SERVER_MODE/*' -exec goimports -d {} + | tee /dev/stderr | test -z "$$(cat)"
-endif
-format: format-frontend format-backend ## Format all code
+lint-client: ## Check macOS client formatting
+ @if command -v swift-format >/dev/null 2>&1; then \
+ swift-format lint --recursive $(SWIFT_PACKAGE)/Sources $(SWIFT_PACKAGE)/Tests; \
+ else \
+ echo "swift-format not installed, skipping"; \
+ fi
-format-frontend: ## Format frontend code
- cd frontend && npm run format
+format: format-backend ## Format all code
format-backend: ## Format backend code
-ifeq ($(DETECTED_OS),Windows)
- powershell -Command '$$files = Get-ChildItem -Recurse -Include *.go | Where-Object { $$_ -notmatch "docs[\\/]SERVER_MODE" }; gofmt -w $$files; goimports -w $$files'
-else
find . -name '*.go' -not -path './docs/SERVER_MODE/*' -exec gofmt -w {} +
find . -name '*.go' -not -path './docs/SERVER_MODE/*' -exec goimports -w {} +
-endif
-
-# Dependencies
-install-deps: install-frontend-deps install-backend-deps ## Install all dependencies
-install-frontend-deps: ## Install frontend dependencies
- cd frontend && npm install
+static-check: ## Run staticcheck for Go code analysis
+ staticcheck ./...
-install-backend-deps: ## Install backend dependencies
+# Dependencies
+install-deps: ## Install backend dependencies
go mod download
-update-deps: update-frontend-deps update-backend-deps ## Update all dependencies
-
-update-frontend-deps: ## Update frontend dependencies
- cd frontend && npm update
-
-update-backend-deps: ## Update backend dependencies
+update-deps: ## Update backend dependencies
go get -u ./...
go mod tidy
-# Setup
setup: install-deps ## Initial project setup
pre-commit install
-# Task runner commands
-task-list: ## List all available tasks
- $(TASK) --list
-
-task-summary: ## Show task summary
- $(TASK) --summary build dev package
-
-icons: ## Generate platform icons
- $(TASK) common:generate:icons
-
-bindings: ## Generate TypeScript bindings
- $(TASK) common:generate:bindings
-
-setup-docker: ## Setup Docker for cross-compilation
- $(TASK) common:setup:docker
-
# Clean
clean: ## Clean build artifacts
-ifeq ($(DETECTED_OS),Windows)
- -Remove-Item -Recurse -Force build/bin,frontend/dist,coverage.out,coverage.html,*.syso 2>$$null
-else
- rm -rf build/bin frontend/dist coverage.out coverage.html *.syso
-endif
- @echo "✅ Cleaned build artifacts"
-
-# Development helpers
+ rm -rf bin coverage.out coverage.html $(SWIFT_PACKAGE)/.build $(SWIFT_PACKAGE)/dist
+ @echo "Cleaned build artifacts"
+
check: lint test build ## Run full check (lint, test, build)
-ifeq ($(DETECTED_OS),Windows)
- powershell -File scripts/check.ps1
-else
./scripts/check.sh
-endif
pre-commit: ## Run pre-commit hooks on all files
pre-commit run --all-files
release-check: check ## Run all checks before release
-ifeq ($(DETECTED_OS),Windows)
- powershell -File scripts/pre-release.ps1
-else
./scripts/pre-release.sh
-endif
-
-love: ## Show some love
- @echo "❤️ MrRSS loves you too! ❤️"
-
-# Platform-specific builds
-build-windows: ## Build for Windows
- $(TASK) windows:build
-
-build-linux: ## Build for Linux
- $(TASK) linux:build
-
-build-darwin: ## Build for macOS
- $(TASK) darwin:build
-# API Documentation
+# API documentation
swagger: ## Generate Swagger API documentation (JSON only)
- swag init -g main-core.go --parseDependency --parseInternal -o docs/SERVER_MODE
-ifeq ($(DETECTED_OS),Windows)
- powershell -Command "Remove-Item -ErrorAction SilentlyContinue docs/SERVER_MODE/docs.go, docs/SERVER_MODE/swagger.yaml"
-else
+ swag init -g main.go --parseDependency --parseInternal -o docs/SERVER_MODE
$(RM) docs/SERVER_MODE/docs.go docs/SERVER_MODE/swagger.yaml
-endif
- @echo "✅ Swagger JSON documentation generated: docs/SERVER_MODE/swagger.json"
+ @echo "Swagger JSON documentation generated: docs/SERVER_MODE/swagger.json"
swagger-validate: ## Validate Swagger annotations
- @echo "🔍 Validating Swagger annotations..."
- swag init -g main-core.go --parseDependency --parseInternal --parseInternal -o docs/SERVER_MODE
+ swag init -g main.go --parseDependency --parseInternal -o docs/SERVER_MODE
-swagger-serve: ## Generate docs and serve Swagger UI
- swag init -g main-core.go --parseDependency --parseInternal -o docs/SERVER_MODE
- @echo "🌐 Starting development server..."
- $(TASK) dev
-
-static-check: ## Run staticcheck for Go code analysis
- staticcheck ./...
+love: ## Show some love
+ @echo "MrRSS loves you too!"
diff --git a/Taskfile.yml b/Taskfile.yml
deleted file mode 100644
index a8557b0a..00000000
--- a/Taskfile.yml
+++ /dev/null
@@ -1,58 +0,0 @@
-version: '3'
-
-includes:
- common: ./build/Taskfile.yml
- windows: ./build/windows/Taskfile.yml
- darwin: ./build/darwin/Taskfile.yml
- linux: ./build/linux/Taskfile.yml
- # iOS and Android are optional - only include if needed
- # ios: ./build/ios/Taskfile.yml
- # android: ./build/android/Taskfile.yml
-
-vars:
- APP_NAME: "MrRSS"
- BIN_DIR: "build/bin"
- VITE_PORT: '{{.WAILS_VITE_PORT | default 5173}}'
-
-tasks:
- build:
- summary: Builds the application
- cmds:
- - task: "{{OS}}:build"
-
- package:
- summary: Packages a production build of the application
- cmds:
- - task: "{{OS}}:package"
-
- run:
- summary: Runs the application
- cmds:
- - task: "{{OS}}:run"
-
- dev:
- summary: Runs the application in development mode (monitoring disabled)
- cmds:
- - wails3 dev -config ./build/config.yml
- env:
- MRRSS_DEBUG: "1"
-
- setup:docker:
- summary: Builds Docker image for cross-compilation (~800MB download)
- cmds:
- - task: common:setup:docker
-
- build:server:
- summary: Builds the server version using Docker
- cmds:
- - task: "{{OS}}:build:server"
-
- docker:build:server:
- summary: Builds the server version Docker image
- cmds:
- - docker build -f Dockerfile.server -t mrrss-server:latest .
-
- docker:run:server:
- summary: Runs the server version in Docker
- cmds:
- - docker run -p 1234:1234 -v {{.PWD}}/data:/app/data mrrss-server:latest
diff --git a/build/README.md b/build/README.md
index c91a15b0..4ca26c77 100644
--- a/build/README.md
+++ b/build/README.md
@@ -1,319 +1,36 @@
-# Wails v3 Build System Guide
+# Build Assets
-This document describes how to build and package MrRSS using the Wails v3 build system with Task runner.
+This directory holds the icon assets used when packaging the macOS client.
-## Prerequisites
+| File | Purpose |
+| --- | --- |
+| `appicon.png` | Source icon artwork |
+| `darwin/icons.icns` | Icon bundled into `MrRSS-SwiftUI.app` |
-### Required Tools
+## Building the application
-- **Go 1.27+**: [https://golang.org/dl/](https://golang.org/dl/)
-- **Node.js 18+**: [https://nodejs.org/](https://nodejs.org/)
-- **Wails CLI v3**: `go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.117`
-- **Task**: [https://taskfile.dev/installation/](https://taskfile.dev/installation/)
-
-### Platform-Specific Dependencies
-
-#### Windows
-
-```powershell
-choco install mingw nsis -y
-```
-
-#### Linux (Ubuntu/Debian)
-
-```bash
-sudo apt-get install -y \
- libgtk-4-dev \
- libwebkitgtk-6.0-dev \
- libsoup-3.0-dev \
- gcc \
- pkg-config
-```
-
-#### macOS
-
-```bash
-xcode-select --install
-```
-
-## Quick Start
-
-### Development Mode
-
-Run the application in development mode with hot reload:
-
-```bash
-# Using wails3 dev command
-wails3 dev
-
-# Or using task
-task dev
-```
-
-The dev server will start on port 5173 (configurable via WAILS_VITE_PORT).
-
-### Building for Your Platform
-
-Build for your current platform:
-
-```bash
-# Using task (recommended)
-task build
-
-# Or directly via platform-specific task
-task windows:build # on Windows
-task linux:build # on Linux
-task darwin:build # on macOS
-```
-
-Build output: `build/bin/`
-
-### Packaging
-
-Create installers and packages:
+The macOS client is a Swift package in `frontend-swift`, and the Go backend is
+built as a plain binary that the bundle launches on demand.
```bash
-# Using task
-task package
-
-# Platform-specific
-task windows:package # Creates NSIS installer
-task linux:package # Creates AppImage + tar.gz
-task darwin:package # Creates DMG
-```
-
-## Advanced Usage
-
-### Cross-Platform Builds
-
-#### Using Docker (Linux/Windows only)
-
-First, build the Docker image (one-time setup):
-
-```bash
-task setup:docker
-```
-
-Then build for other platforms:
-
-```bash
-# From any OS, build for Windows
-task windows:build CGO_ENABLED=1
-
-# From any OS, build for Linux
-task linux:build CGO_ENABLED=1
-```
-
-**Note**: macOS builds should be done on native macOS runners due to signing requirements.
-
-### Architecture-Specific Builds
-
-```bash
-# Build for specific architecture
-task windows:build ARCH=amd64
-task windows:build ARCH=arm64
-
-task linux:build ARCH=amd64
-task linux:build ARCH=arm64
-
-task darwin:build ARCH=universal # Intel + Apple Silicon
-```
-
-### Mobile Platforms (Wails v3 only)
-
-**Note**: iOS and Android support is experimental in Wails v3 alpha.
-
-```bash
-# iOS (requires macOS + Xcode)
-task ios:build
-
-# Android (requires Android SDK)
-task android:build
-```
-
-## Task Commands Reference
-
-### Common Tasks
-
-- `task build` - Build application for current platform
-- `task package` - Package application with installer
-- `task run` - Run the built application
-- `task dev` - Run in development mode
-- `task setup:docker` - Build Docker image for cross-compilation
-
-### Platform-Specific Tasks
-
-#### Windows
-
-- `task windows:build` - Build Windows executable
-- `task windows:package` - Create NSIS installer
-- `task windows:sign` - Sign executable (requires certificate)
-
-#### Linux
+# Build the .app bundle and the DMG
+make build-app VERSION=1.3.28
-- `task linux:build` - Build Linux binary
-- `task linux:package` - Create AppImage + packages
-- `task linux:create:appimage` - Create AppImage only
-- `task linux:create:tarball` - Create tar.gz only
-
-#### macOS
-
-- `task darwin:build` - Build macOS app bundle
-- `task darwin:package` - Create DMG installer
-- `task darwin:sign` - Sign app bundle (requires certificate)
-- `task darwin:notarize` - Notarize with Apple (requires profile)
-
-### Frontend Tasks
-
-- `task common:install:frontend:deps` - Install frontend dependencies
-- `task common:build:frontend` - Build frontend for production
-- `task common:dev:frontend` - Run frontend dev server
-
-### Utility Tasks
-
-- `task common:generate:icons` - Generate platform icons from appicon.png
-- `task common:generate:bindings` - Generate TypeScript bindings
-- `task common:update:build-assets` - Update build assets from config
-
-## Configuration
-
-### Main Configuration
-
-Edit `build/config.yml` to change:
-
-- Application name and version
-- Company information
-- Bundle identifiers
-- Dev mode settings
-
-### Platform-Specific Configuration
-
-Each platform has its own Taskfile in `build//Taskfile.yml`:
-
-- `build/windows/Taskfile.yml` - Windows build configuration
-- `build/linux/Taskfile.yml` - Linux build configuration
-- `build/darwin/Taskfile.yml` - macOS build configuration
-
-### Signing Configuration
-
-#### Windows
-
-Edit `build/windows/Taskfile.yml`:
-
-```yaml
-vars:
- SIGN_CERTIFICATE: "path/to/certificate.pfx"
- TIMESTAMP_SERVER: "http://timestamp.digicert.com"
-```
-
-Then setup password:
-
-```bash
-wails3 setup signing
-```
-
-#### macOS
-
-Edit `build/darwin/Taskfile.yml`:
-
-```yaml
-vars:
- SIGN_IDENTITY: "Developer ID Application: Your Name (TEAM_ID)"
- NOTARIZATION_PROFILE: "notarization-profile-name"
-```
-
-#### Linux
-
-Edit `build/linux/Taskfile.yml`:
-
-```yaml
-vars:
- PGP_KEY: "path/to/signing-key.asc"
-```
-
-## GitHub Actions
-
-The project includes automated workflows:
-
-### Release Workflow
-
-Triggered manually via GitHub Actions UI:
-
-1. Go to Actions → Release
-2. Click "Run workflow"
-3. Enter version (e.g., `v1.2.21`)
-4. Click "Run workflow"
-
-Builds for all platforms:
-
-- Windows (AMD64, ARM64)
-- Linux (AMD64, ARM64)
-- macOS (Universal)
-
-### Test Build Workflow
-
-Triggered on push/PR to main:
-
-- Tests build on all platforms
-- Validates configuration
-
-## Troubleshooting
-
-### CGO is disabled
-
-**Solution**: CGO must be enabled for Wails v3:
-
-```bash
-export CGO_ENABLED=1
-task build
-```
-
-### Missing dependencies (Linux)
-
-**Solution**: Install all required libraries:
-
-```bash
-sudo apt-get update
-sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc pkg-config
-```
-
-### Task not found
-
-**Solution**: Install Task runner:
-
-```bash
-# macOS
-brew install go-task
-
-# Linux
-sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin
-
-# Windows
-choco install go-task
-```
-
-### Frontend build fails
-
-**Solution**: Clean and reinstall dependencies:
-
-```bash
-cd frontend
-rm -rf node_modules package-lock.json
-npm install
-npm run build
+# Or call the script directly
+./frontend-swift/build-app.sh 1.3.28
```
-## Resources
+The script builds a universal SwiftUI executable, builds the Go backend for
+`arm64` and `x86_64`, merges them with `lipo`, copies the icon and `Info.plist`,
+signs the bundle, and produces `frontend-swift/dist/MrRSS--macos-swiftui-universal.dmg`.
-- [Wails v3 Documentation](https://v3.wails.io/)
-- [Task Documentation](https://taskfile.dev/)
-- [Build Configuration Reference](./config.yml)
-- [GitHub Actions Workflows](../.github/workflows/)
+Set `MRRSS_BUILD_ARCHS` to limit the architectures, for example
+`MRRSS_BUILD_ARCHS=arm64 ./frontend-swift/build-app.sh dev` for a faster local build.
-## Support
+## Requirements
-For issues:
+- macOS 14 or later
+- Xcode 15 or later
+- Go 1.27 or later
-1. Check [GitHub Issues](https://github.com/DevXDojo/MrRSS/issues)
-2. Review [Build Requirements](../docs/BUILD_REQUIREMENTS.md)
-3. Check [Wails Discord](https://discord.gg/wails)
+See [docs/BUILD_REQUIREMENTS.md](../docs/BUILD_REQUIREMENTS.md) for details.
diff --git a/build/Taskfile.yml b/build/Taskfile.yml
deleted file mode 100644
index 32a8d94c..00000000
--- a/build/Taskfile.yml
+++ /dev/null
@@ -1,104 +0,0 @@
-version: '3'
-
-tasks:
- go:mod:tidy:
- summary: Runs `go mod tidy`
- internal: true
- sources:
- - go.mod
- - go.sum
- cmds:
- - go mod tidy
-
- install:frontend:deps:
- summary: Install frontend dependencies
- dir: frontend
- sources:
- - package.json
- - package-lock.json
- generates:
- - node_modules
- preconditions:
- - sh: npm version
- msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
- cmds:
- - npm install
-
- build:frontend:
- label: build:frontend (DEV={{.DEV}})
- summary: Build the frontend project
- dir: frontend
- sources:
- - "**/*"
- generates:
- - dist/**/*
- deps:
- - task: install:frontend:deps
- - task: generate:bindings
- vars:
- BUILD_FLAGS:
- ref: .BUILD_FLAGS
- cmds:
- - npm run {{.BUILD_COMMAND}} -q
- env:
- PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
- vars:
- BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
-
- generate:bindings:
- label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
- summary: Generates bindings for the frontend
- deps:
- - task: go:mod:tidy
- sources:
- - "**/*.[jt]s"
- - exclude: frontend/**/*
- - frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
- - "**/*.go"
- - go.mod
- - go.sum
- generates:
- - frontend/bindings/**/*
- cmds:
- - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
-
- generate:icons:
- summary: Generates Windows `.ico` and Mac `.icns` files from an image
- dir: build
- sources:
- - "appicon.png"
- - "windows/icon.png"
- generates:
- - "darwin/icons.icns"
- - "windows/icon.ico"
- cmds:
- # Wails supplies build/windows and build/darwin defaults for omitted output
- # flags. This task already runs from build/, so explicitly disable the
- # unrelated output while preserving the platform-specific source images.
- - wails3 generate icons -input appicon.png -windowsfilename= -macfilename darwin/icons.icns
- - wails3 generate icons -input windows/icon.png -windowsfilename windows/icon.ico -macfilename=
-
- dev:frontend:
- summary: Runs the frontend in development mode
- dir: frontend
- deps:
- - task: install:frontend:deps
- cmds:
- - npm run dev -- --port {{.VITE_PORT}} --strictPort
-
- update:build-assets:
- summary: Updates the build assets
- dir: build
- cmds:
- - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
-
- setup:docker:
- summary: Builds Docker image for cross-compilation (~800MB download)
- desc: |
- Builds the Docker image needed for cross-compiling to any platform.
- Run this once to enable cross-platform builds from any OS.
- cmds:
- - docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
- preconditions:
- - sh: docker info > /dev/null 2>&1
- msg: "Docker is required. Please install Docker first."
diff --git a/build/config.yml b/build/config.yml
deleted file mode 100644
index 3e19293f..00000000
--- a/build/config.yml
+++ /dev/null
@@ -1,71 +0,0 @@
-# This file contains the configuration for this project.
-# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
-# Note that this will overwrite any changes you have made to the assets.
-version: '3'
-
-# This information is used to generate the build assets.
-info:
- companyName: "Ch3nyang" # The name of the company
- productName: "MrRSS" # The name of the application
- productIdentifier: "com.mrrss.app" # The unique product identifier
- description: "A modern, standalone RSS reader" # The application description
- copyright: "Copyright © 2026" # Copyright text
- comments: "Built with Wails" # Comments
- version: "1.3.28" # The application version
-
-# iOS build configuration (uncomment to customise iOS project generation)
-# Note: Keys under `ios` OVERRIDE values under `info` when set.
-# ios:
-# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
-# bundleID: "com.mrrss.app"
-# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
-# displayName: "MrRSS"
-# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
-# version: "1.3.28"
-# # The company/organisation name for templates and project settings
-# company: "Ch3nyang"
-# # Additional comments to embed in Info.plist metadata
-# comments: "Built with Wails"
-
-# Dev mode configuration
-dev_mode:
- root_path: .
- log_level: warn
- debounce: 1000
- ignore:
- dir:
- - .git
- - node_modules
- - frontend
- - bin
- - build
- file:
- - .DS_Store
- - .gitignore
- - .gitkeep
- watched_extension:
- - "*.go"
- - "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
- - "*.ts" # The frontend directory will be excluded entirely by the setting above.
- git_ignore: true
- executes:
- - cmd: wails3 build DEV=true
- type: blocking
- - cmd: wails3 task common:dev:frontend
- type: background
- - cmd: wails3 task run
- type: primary
-
-# File Associations
-# More information at: https://v3.wails.io/noit/done/yet
-fileAssociations:
-# - ext: opml
-# name: OPML
-# description: OPML File
-# iconName: opmlFileIcon
-# role: Editor
-# mimeType: text/x-opml
-
-# Other data
-other:
- - name: MrRSS Project Data
diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist
deleted file mode 100644
index 9f22e4ba..00000000
--- a/build/darwin/Info.dev.plist
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
- CFBundlePackageType
- APPL
- CFBundleName
- MrRSS
- CFBundleExecutable
- MrRSS
- CFBundleIdentifier
- com.mrrss.app
- CFBundleVersion
- 1.3.24
- CFBundleGetInfoString
- Built with Wails
- CFBundleShortVersionString
- 1.3.24
- CFBundleIconFile
- icons
- LSMinimumSystemVersion
- 10.15.0
- NSHighResolutionCapable
- true
- NSHumanReadableCopyright
- Copyright © 2026
- NSAppTransportSecurity
-
- NSAllowsLocalNetworking
-
-
-
-
diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist
deleted file mode 100644
index 4ba250ef..00000000
--- a/build/darwin/Info.plist
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
- CFBundlePackageType
- APPL
- CFBundleName
- MrRSS
- CFBundleExecutable
- MrRSS
- CFBundleIdentifier
- com.mrrss.app
- CFBundleVersion
- 1.3.24
- CFBundleGetInfoString
- Built with Wails
- CFBundleShortVersionString
- 1.3.24
- CFBundleIconFile
- icons
- LSMinimumSystemVersion
- 10.13
- NSHighResolutionCapable
- true
- NSSupportsAutomaticGraphicsSwitching
- true
- NSHumanReadableCopyright
- Copyright © 2026
-
-
-
-
-
-
- NSAppleEventsUsageDescription
- MrRSS needs permission to open articles in your default web browser and execute custom feed scripts.
-
-
- NSNetworkUsageDescription
- MrRSS requires network access to fetch RSS feeds and update content.
-
-
- NSDocumentsFolderUsageDescription
- MrRSS needs access to Documents folder to import and export OPML feed lists.
-
- NSDownloadsFolderUsageDescription
- MrRSS needs access to Downloads folder to import feed lists and save exported content.
-
-
-
-
-
-
-
-
-
-
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoads
-
- NSExceptionDomains
-
-
-
-
diff --git a/build/darwin/Taskfile.yml b/build/darwin/Taskfile.yml
deleted file mode 100644
index 272ecf11..00000000
--- a/build/darwin/Taskfile.yml
+++ /dev/null
@@ -1,215 +0,0 @@
-version: '3'
-
-includes:
- common: ../Taskfile.yml
-
-vars:
- # Signing configuration - edit these values for your project
- # SIGN_IDENTITY: "Developer ID Application: Your Name (TEAM_ID)"
- # NOTARIZATION_PROFILE: "notarization-profile-name"
- #
- # Password/API Key is stored securely in system keychain. Run: wails3 setup signing
-
-tasks:
- build:
- summary: Builds the application for macOS
- deps:
- - task: common:go:mod:tidy
- - task: common:build:frontend
- vars:
- BUILD_FLAGS:
- ref: .BUILD_FLAGS
- DEV:
- ref: .DEV
- - task: common:generate:icons
- cmds:
- # Check if building universal binary
- - |
- if [ "{{.ARCH}}" = "universal" ]; then
- echo "Building universal binary..."
- # Build for amd64
- GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-amd64
- # Build for arm64
- GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-arm64
- # Merge with lipo
- lipo -create -output {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}-amd64 {{.BIN_DIR}}/{{.APP_NAME}}-arm64
- # Clean up intermediate files
- rm -f {{.BIN_DIR}}/{{.APP_NAME}}-amd64 {{.BIN_DIR}}/{{.APP_NAME}}-arm64
- else
- # Build for specific architecture
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}
- fi
- - task: create:app:bundle
- vars:
- BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
- env:
- GOOS: darwin
- CGO_ENABLED: 1
- GOARCH: '{{if ne .ARCH "universal"}}{{.ARCH | default ARCH}}{{else}}{{end}}'
-
- create:app:bundle:
- summary: Creates a macOS .app bundle
- internal: true
- cmds:
- - mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS
- - mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources
- - cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS/
- - cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources/
- - |
- cat > {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Info.plist << EOF
-
-
-
-
- CFBundleExecutable
- {{.APP_NAME}}
- CFBundleIconFile
- icons.icns
- CFBundleIdentifier
- com.mrrss.app
- CFBundleName
- {{.APP_NAME}}
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- 1.3.24
- CFBundleVersion
- 1.3.24
- LSMinimumSystemVersion
- 10.13
- NSHighResolutionCapable
-
- NSSupportsAutomaticGraphicsSwitching
-
- NSHumanReadableCopyright
- Copyright © 2026
- NSAppleEventsUsageDescription
- MrRSS needs permission to open articles in your default web browser and execute custom feed scripts.
- NSNetworkUsageDescription
- MrRSS requires network access to fetch RSS feeds and update content.
- NSDocumentsFolderUsageDescription
- MrRSS needs access to Documents folder to import and export OPML feed lists.
- NSDownloadsFolderUsageDescription
- MrRSS needs access to Downloads folder to import feed lists and save exported content.
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoads
-
- NSExceptionDomains
-
-
-
-
- EOF
-
- package:
- summary: Signs and packages the application into a DMG
- deps:
- - task: sign:dev
- cmds:
- - task: create:dmg
-
- create:dmg:
- summary: Creates a DMG installer
- cmds:
- - |
- if [ -f "build/darwin/create-dmg.sh" ]; then
- chmod +x build/darwin/create-dmg.sh
- ./build/darwin/create-dmg.sh
- else
- echo "Warning: create-dmg.sh not found, skipping DMG creation"
- fi
-
- run:
- cmds:
- - '{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS/{{.APP_NAME}}'
-
- sign:
- summary: Signs the macOS application
- desc: |
- Signs the .app bundle with an Apple Developer certificate.
- Configure SIGN_IDENTITY in the vars section at the top of this file.
- deps:
- - task: build
- cmds:
- - |
- # Check if entitlements file exists
- if [ -f "build/darwin/entitlements.plist" ]; then
- echo "Signing with entitlements..."
- codesign --deep --force --verify --verbose \
- --sign "{{.SIGN_IDENTITY}}" \
- --entitlements build/darwin/entitlements.plist \
- --options runtime \
- {{.BIN_DIR}}/{{.APP_NAME}}.app
- else
- echo "Signing without entitlements (entitlements.plist not found)..."
- codesign --deep --force --verify --verbose \
- --sign "{{.SIGN_IDENTITY}}" \
- --options runtime \
- {{.BIN_DIR}}/{{.APP_NAME}}.app
- fi
- preconditions:
- - sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
- msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
-
- notarize:
- summary: Notarizes the macOS application
- desc: |
- Notarizes the .app bundle or DMG with Apple's notarization service.
- Configure NOTARIZATION_PROFILE in the vars section at the top of this file.
- deps:
- - task: sign
- cmds:
- - xcrun notarytool submit {{.BIN_DIR}}/{{.APP_NAME}}.app --keychain-profile "{{.NOTARIZATION_PROFILE}}" --wait
- - xcrun stapler staple {{.BIN_DIR}}/{{.APP_NAME}}.app
- preconditions:
- - sh: '[ -n "{{.NOTARIZATION_PROFILE}}" ]'
- msg: "NOTARIZATION_PROFILE is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
-
- sign:dev:
- summary: Signs the macOS application for development (ad-hoc signature)
- desc: |
- Signs the .app bundle with ad-hoc signature for local development testing.
- This does not require an Apple Developer certificate.
- deps:
- - task: build
- cmds:
- - |
- # Remove any existing signature
- codesign --remove-signature {{.BIN_DIR}}/{{.APP_NAME}}.app 2>/dev/null || true
-
- # Apply ad-hoc signature with entitlements
- if [ -f "build/darwin/entitlements.plist" ]; then
- echo "Applying ad-hoc signature with entitlements..."
- codesign --deep --force --verify --verbose \
- --sign - \
- --entitlements build/darwin/entitlements.plist \
- {{.BIN_DIR}}/{{.APP_NAME}}.app
- else
- echo "Applying ad-hoc signature without entitlements..."
- codesign --deep --force --verify --verbose \
- --sign - \
- {{.BIN_DIR}}/{{.APP_NAME}}.app
- fi
-
- # Verify signature
- codesign --verify --deep --strict --verbose=2 {{.BIN_DIR}}/{{.APP_NAME}}.app
- build:server:
- summary: Builds the server version using Docker
- deps:
- - task: common:build:frontend
- cmds:
- - docker build -f Dockerfile.server -t mrrss-server:latest .
- - docker create --name mrrss-server-temp mrrss-server:latest
- - docker cp mrrss-server-temp:/app/mrrss-server {{.BIN_DIR}}/{{.APP_NAME}}-server
- - docker rm mrrss-server-temp
- preconditions:
- - sh: docker info > /dev/null 2>&1
- msg: "Docker is required for building the server version"
-
- run:server:
- summary: Runs the server version
- deps:
- - task: build:server
- cmds:
- - '{{.BIN_DIR}}/{{.APP_NAME}}-server'
diff --git a/build/darwin/create-dmg.sh b/build/darwin/create-dmg.sh
deleted file mode 100644
index 7a7dc573..00000000
--- a/build/darwin/create-dmg.sh
+++ /dev/null
@@ -1,78 +0,0 @@
-#!/bin/bash
-# Script to create a macOS DMG installer for MrRSS
-#
-# Application Information:
-# Name: MrRSS
-# Description: A Modern, Cross-Platform Desktop RSS Reader
-# Publisher: Ch3nyang
-# URL: https://github.com/DevXDojo/MrRSS
-# Copyright: Copyright © Ch3nyang
-
-set -e
-
-APP_NAME="MrRSS"
-# Get version from frontend/package.json if available, otherwise use default
-VERSION=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' frontend/package.json 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)".*/\1/' || echo "1.3.24")
-APP_PUBLISHER="Ch3nyang"
-APP_URL="https://github.com/DevXDojo/MrRSS"
-APP_DESCRIPTION="A Modern, Cross-Platform Desktop RSS Reader"
-BUILD_DIR="build/bin"
-DMG_DIR="build/dmg"
-APP_PATH="${BUILD_DIR}/${APP_NAME}.app"
-DMG_NAME="${APP_NAME}-${VERSION}-darwin-universal.dmg"
-
-echo "Creating DMG for ${APP_NAME} ${VERSION}..."
-echo "Publisher: ${APP_PUBLISHER}"
-echo "Description: ${APP_DESCRIPTION}"
-echo ""
-
-# Check if app exists
-if [ ! -d "${APP_PATH}" ]; then
- echo "Error: Application not found at ${APP_PATH}"
- echo "Please build the application first with: wails3 build -platform darwin/universal"
- exit 1
-fi
-
-# Create DMG directory
-rm -rf "${DMG_DIR}"
-mkdir -p "${DMG_DIR}"
-
-# Copy app to DMG directory
-echo "Copying application..."
-cp -R "${APP_PATH}" "${DMG_DIR}/"
-
-# Create Applications symlink
-echo "Creating Applications symlink..."
-ln -s /Applications "${DMG_DIR}/Applications"
-
-# Create DMG
-echo "Creating DMG image..."
-rm -f "${BUILD_DIR}/${DMG_NAME}"
-
-# Use hdiutil to create the DMG
-if ! hdiutil create -volname "${APP_NAME}" \
- -srcfolder "${DMG_DIR}" \
- -ov -format UDZO \
- "${BUILD_DIR}/${DMG_NAME}"; then
- echo "Error: Failed to create DMG with hdiutil"
- echo "This might be due to permissions or disk space issues"
- exit 1
-fi
-
-# Verify the DMG was created
-if [ ! -f "${BUILD_DIR}/${DMG_NAME}" ]; then
- echo "Error: DMG file was not created at ${BUILD_DIR}/${DMG_NAME}"
- exit 1
-fi
-
-# Clean up
-rm -rf "${DMG_DIR}"
-
-echo "DMG created successfully: ${BUILD_DIR}/${DMG_NAME}"
-echo ""
-echo "Installation instructions:"
-echo "1. Open the DMG file"
-echo "2. Drag ${APP_NAME}.app to the Applications folder"
-echo "3. Launch ${APP_NAME} from Applications"
-echo ""
-echo "User data will be stored in: ~/Library/Application Support/MrRSS/"
diff --git a/build/darwin/entitlements.plist b/build/darwin/entitlements.plist
deleted file mode 100644
index 74ad9698..00000000
--- a/build/darwin/entitlements.plist
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
- com.apple.security.automation.apple-events
-
- com.apple.security.files.downloads.read-write
-
- com.apple.security.files.user-selected.read-only
-
- com.apple.security.files.user-selected.read-write
-
- com.apple.security.network.client
-
- com.apple.security.network.server
-
-
-
diff --git a/build/docker/Dockerfile.cross b/build/docker/Dockerfile.cross
deleted file mode 100644
index e1ad1a21..00000000
--- a/build/docker/Dockerfile.cross
+++ /dev/null
@@ -1,42 +0,0 @@
-# Multi-platform cross-compilation Docker image for Wails v3
-# Supports building for Windows, Linux, macOS with CGO enabled using Zig as CC
-
-FROM golang:1.27-bookworm
-
-# Install basic tools
-RUN apt-get update && apt-get install -y \
- wget \
- git \
- xz-utils \
- zip \
- unzip \
- curl \
- && rm -rf /var/lib/apt/lists/*
-
-# Install Zig (for cross-compilation with CGO)
-ARG ZIG_VERSION=0.13.0
-RUN wget https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz && \
- tar -xf zig-linux-x86_64-${ZIG_VERSION}.tar.xz && \
- mv zig-linux-x86_64-${ZIG_VERSION} /usr/local/zig && \
- ln -s /usr/local/zig/zig /usr/local/bin/zig && \
- rm zig-linux-x86_64-${ZIG_VERSION}.tar.xz
-
-# Install Linux development dependencies
-RUN apt-get update && apt-get install -y \
- libgtk-4-dev \
- libwebkitgtk-6.0-dev \
- libsoup-3.0-dev \
- gcc \
- pkg-config \
- && rm -rf /var/lib/apt/lists/*
-
-# Install NSIS for Windows installer creation
-RUN apt-get update && apt-get install -y nsis && rm -rf /var/lib/apt/lists/*
-
-# Create build script
-COPY build-script.sh /usr/local/bin/build-cross
-RUN chmod +x /usr/local/bin/build-cross
-
-WORKDIR /app
-
-ENTRYPOINT ["/usr/local/bin/build-cross"]
diff --git a/build/docker/build-script.sh b/build/docker/build-script.sh
deleted file mode 100644
index da7777c7..00000000
--- a/build/docker/build-script.sh
+++ /dev/null
@@ -1,60 +0,0 @@
-#!/bin/bash
-set -e
-
-PLATFORM=$1
-ARCH=${2:-amd64}
-APP_NAME=${APP_NAME:-MrRSS}
-
-echo "Building for $PLATFORM/$ARCH..."
-
-# Setup Zig as CC for cross-compilation
-export CC="zig cc -target"
-export CXX="zig c++ -target"
-
-case "$PLATFORM" in
- windows)
- if [ "$ARCH" = "amd64" ]; then
- export GOOS=windows
- export GOARCH=amd64
- export CC="$CC x86_64-windows-gnu"
- export CXX="$CXX x86_64-windows-gnu"
- elif [ "$ARCH" = "arm64" ]; then
- export GOOS=windows
- export GOARCH=arm64
- export CC="$CC aarch64-windows-gnu"
- export CXX="$CXX aarch64-windows-gnu"
- fi
- export CGO_ENABLED=1
- go build -tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui" -o bin/${APP_NAME}.exe .
- ;;
-
- linux)
- if [ "$ARCH" = "amd64" ]; then
- export GOOS=linux
- export GOARCH=amd64
- export CC="$CC x86_64-linux-gnu"
- export CXX="$CXX x86_64-linux-gnu"
- elif [ "$ARCH" = "arm64" ]; then
- export GOOS=linux
- export GOARCH=arm64
- export CC="$CC aarch64-linux-gnu"
- export CXX="$CXX aarch64-linux-gnu"
- fi
- export CGO_ENABLED=1
- export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig
- go build -tags production -trimpath -buildvcs=false -ldflags="-w -s" -o bin/${APP_NAME}-${PLATFORM}-${ARCH} .
- ;;
-
- darwin)
- # Note: macOS cross-compilation is complex, better to build natively on macOS
- echo "macOS builds should be done on native macOS runners"
- exit 1
- ;;
-
- *)
- echo "Unknown platform: $PLATFORM"
- exit 1
- ;;
-esac
-
-echo "Build complete!"
diff --git a/build/ios/Assets.xcassets b/build/ios/Assets.xcassets
deleted file mode 100644
index 11a1df69..00000000
--- a/build/ios/Assets.xcassets
+++ /dev/null
@@ -1,116 +0,0 @@
-{
- "info" : {
- "author" : "xcode",
- "version" : 1
- },
- "images" : [
- {
- "filename" : "icon-20@2x.png",
- "idiom" : "iphone",
- "scale" : "2x",
- "size" : "20x20"
- },
- {
- "filename" : "icon-20@3x.png",
- "idiom" : "iphone",
- "scale" : "3x",
- "size" : "20x20"
- },
- {
- "filename" : "icon-29@2x.png",
- "idiom" : "iphone",
- "scale" : "2x",
- "size" : "29x29"
- },
- {
- "filename" : "icon-29@3x.png",
- "idiom" : "iphone",
- "scale" : "3x",
- "size" : "29x29"
- },
- {
- "filename" : "icon-40@2x.png",
- "idiom" : "iphone",
- "scale" : "2x",
- "size" : "40x40"
- },
- {
- "filename" : "icon-40@3x.png",
- "idiom" : "iphone",
- "scale" : "3x",
- "size" : "40x40"
- },
- {
- "filename" : "icon-60@2x.png",
- "idiom" : "iphone",
- "scale" : "2x",
- "size" : "60x60"
- },
- {
- "filename" : "icon-60@3x.png",
- "idiom" : "iphone",
- "scale" : "3x",
- "size" : "60x60"
- },
- {
- "filename" : "icon-20.png",
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "20x20"
- },
- {
- "filename" : "icon-20@2x.png",
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "20x20"
- },
- {
- "filename" : "icon-29.png",
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "29x29"
- },
- {
- "filename" : "icon-29@2x.png",
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "29x29"
- },
- {
- "filename" : "icon-40.png",
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "40x40"
- },
- {
- "filename" : "icon-40@2x.png",
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "40x40"
- },
- {
- "filename" : "icon-76.png",
- "idiom" : "ipad",
- "scale" : "1x",
- "size" : "76x76"
- },
- {
- "filename" : "icon-76@2x.png",
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "76x76"
- },
- {
- "filename" : "icon-83.5@2x.png",
- "idiom" : "ipad",
- "scale" : "2x",
- "size" : "83.5x83.5"
- },
- {
- "filename" : "icon-1024.png",
- "idiom" : "ios-marketing",
- "scale" : "1x",
- "size" : "1024x1024"
- }
- ]
-}
diff --git a/build/ios/Info.dev.plist b/build/ios/Info.dev.plist
deleted file mode 100644
index 4c7e630d..00000000
--- a/build/ios/Info.dev.plist
+++ /dev/null
@@ -1,62 +0,0 @@
-
-
-
-
- CFBundleExecutable
- MrRSS
- CFBundleIdentifier
- com.mrrss.app.dev
- CFBundleName
- MrRSS (Dev)
- CFBundleDisplayName
- MrRSS (Dev)
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- 1.3.24-dev
- CFBundleVersion
- 1.3.24
- LSRequiresIPhoneOS
-
- MinimumOSVersion
- 15.0
- UILaunchStoryboardName
- LaunchScreen
- UIRequiredDeviceCapabilities
-
- armv7
- arm64
-
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoads
-
- NSAllowsLocalNetworking
-
-
-
- WailsDevelopmentMode
-
-
- NSHumanReadableCopyright
- Copyright © 2026
-
-
- CFBundleGetInfoString
- Built with Wails
-
-
-
diff --git a/build/ios/Info.plist b/build/ios/Info.plist
deleted file mode 100644
index 9822a655..00000000
--- a/build/ios/Info.plist
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
- CFBundleExecutable
- MrRSS
- CFBundleIdentifier
- com.mrrss.app
- CFBundleName
- MrRSS
- CFBundleDisplayName
- MrRSS
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- 1.3.24
- CFBundleVersion
- 1.3.24
- LSRequiresIPhoneOS
-
- MinimumOSVersion
- 13.0
- UILaunchStoryboardName
- LaunchScreen
- UIRequiredDeviceCapabilities
-
- armv7
- arm64
-
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoads
-
- NSAllowsLocalNetworking
-
-
-
- NSHumanReadableCopyright
- Copyright © 2026
-
-
- CFBundleGetInfoString
- Built with Wails
-
-
-
diff --git a/build/ios/LaunchScreen.storyboard b/build/ios/LaunchScreen.storyboard
deleted file mode 100644
index bbc331be..00000000
--- a/build/ios/LaunchScreen.storyboard
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/build/ios/build.sh b/build/ios/build.sh
deleted file mode 100644
index 9e77df62..00000000
--- a/build/ios/build.sh
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/bin/bash
-set -e
-
-# Build configuration
-APP_NAME="MrRSS"
-BUNDLE_ID="com.mrrss.app"
-VERSION="1.3.24"
-BUILD_NUMBER="1.3.24"
-BUILD_DIR="build/ios"
-TARGET="simulator"
-
-echo "Building iOS app: $APP_NAME"
-echo "Bundle ID: $BUNDLE_ID"
-echo "Version: $VERSION ($BUILD_NUMBER)"
-echo "Target: $TARGET"
-
-# Ensure build directory exists
-mkdir -p "$BUILD_DIR"
-
-# Determine SDK and target architecture
-if [ "$TARGET" = "simulator" ]; then
- SDK="iphonesimulator"
- ARCH="arm64-apple-ios15.0-simulator"
-elif [ "$TARGET" = "device" ]; then
- SDK="iphoneos"
- ARCH="arm64-apple-ios15.0"
-else
- echo "Unknown target: $TARGET"
- exit 1
-fi
-
-# Get SDK path
-SDK_PATH=$(xcrun --sdk $SDK --show-sdk-path)
-
-# Compile the application
-echo "Compiling with SDK: $SDK"
-xcrun -sdk $SDK clang \
- -target $ARCH \
- -isysroot "$SDK_PATH" \
- -framework Foundation \
- -framework UIKit \
- -framework WebKit \
- -framework CoreGraphics \
- -o "$BUILD_DIR/$APP_NAME" \
- "$BUILD_DIR/main.m"
-
-# Create app bundle
-echo "Creating app bundle..."
-APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
-rm -rf "$APP_BUNDLE"
-mkdir -p "$APP_BUNDLE"
-
-# Move executable
-mv "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/"
-
-# Copy Info.plist
-cp "$BUILD_DIR/Info.plist" "$APP_BUNDLE/"
-
-# Sign the app
-echo "Signing app..."
-codesign --force --sign - "$APP_BUNDLE"
-
-echo "Build complete: $APP_BUNDLE"
-
-# Deploy to simulator if requested
-if [ "$TARGET" = "simulator" ]; then
- echo "Deploying to simulator..."
- xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true
- xcrun simctl install booted "$APP_BUNDLE"
- xcrun simctl launch booted "$BUNDLE_ID"
- echo "App launched on simulator"
-fi
diff --git a/build/ios/entitlements.plist b/build/ios/entitlements.plist
deleted file mode 100644
index a2c7225d..00000000
--- a/build/ios/entitlements.plist
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
- get-task-allow
-
-
-
- com.apple.security.app-sandbox
-
-
-
- com.apple.security.network.client
-
-
-
- com.apple.security.files.user-selected.read-only
-
-
-
diff --git a/build/ios/project.pbxproj b/build/ios/project.pbxproj
deleted file mode 100644
index bbf0ba9e..00000000
--- a/build/ios/project.pbxproj
+++ /dev/null
@@ -1,222 +0,0 @@
-// !$*UTF8*$!
-{
- archiveVersion = 1;
- classes = {};
- objectVersion = 56;
- objects = {
-
-/* Begin PBXBuildFile section */
- C0DEBEEF0000000000000001 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000002 /* main.m */; };
- C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000101 /* UIKit.framework */; };
- C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000102 /* Foundation.framework */; };
- C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000103 /* WebKit.framework */; };
- C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000104 /* Security.framework */; };
- C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000105 /* CoreFoundation.framework */; };
- C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000106 /* libresolv.tbd */; };
- C0DEBEEF00000000000000F7 /* MrRSS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000107 /* MrRSS.a */; };
-/* End PBXBuildFile section */
-
-/* Begin PBXFileReference section */
- C0DEBEEF0000000000000002 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; };
- C0DEBEEF0000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
- C0DEBEEF0000000000000004 /* MrRSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "MrRSS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
- C0DEBEEF0000000000000101 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
- C0DEBEEF0000000000000102 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
- C0DEBEEF0000000000000103 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
- C0DEBEEF0000000000000104 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
- C0DEBEEF0000000000000105 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
- C0DEBEEF0000000000000106 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.text-based-dylib-definition; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
- C0DEBEEF0000000000000107 /* MrRSS.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "MrRSS.a"; path = ../../../bin/MrRSS.a; sourceTree = SOURCE_ROOT; };
-/* End PBXFileReference section */
-
-/* Begin PBXGroup section */
- C0DEBEEF0000000000000010 = {
- isa = PBXGroup;
- children = (
- C0DEBEEF0000000000000020 /* Products */,
- C0DEBEEF0000000000000045 /* Frameworks */,
- C0DEBEEF0000000000000030 /* main */,
- );
- sourceTree = "";
- };
- C0DEBEEF0000000000000020 /* Products */ = {
- isa = PBXGroup;
- children = (
- C0DEBEEF0000000000000004 /* MrRSS.app */,
- );
- name = Products;
- sourceTree = "";
- };
- C0DEBEEF0000000000000030 /* main */ = {
- isa = PBXGroup;
- children = (
- C0DEBEEF0000000000000002 /* main.m */,
- C0DEBEEF0000000000000003 /* Info.plist */,
- );
- path = main;
- sourceTree = SOURCE_ROOT;
- };
- C0DEBEEF0000000000000045 /* Frameworks */ = {
- isa = PBXGroup;
- children = (
- C0DEBEEF0000000000000101 /* UIKit.framework */,
- C0DEBEEF0000000000000102 /* Foundation.framework */,
- C0DEBEEF0000000000000103 /* WebKit.framework */,
- C0DEBEEF0000000000000104 /* Security.framework */,
- C0DEBEEF0000000000000105 /* CoreFoundation.framework */,
- C0DEBEEF0000000000000106 /* libresolv.tbd */,
- C0DEBEEF0000000000000107 /* MrRSS.a */,
- );
- name = Frameworks;
- sourceTree = "";
- };
-/* End PBXGroup section */
-
-/* Begin PBXNativeTarget section */
- C0DEBEEF0000000000000040 /* MrRSS */ = {
- isa = PBXNativeTarget;
- buildConfigurationList = C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "MrRSS" */;
- buildPhases = (
- C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */,
- C0DEBEEF0000000000000050 /* Sources */,
- C0DEBEEF0000000000000056 /* Frameworks */,
- );
- buildRules = (
- );
- dependencies = (
- );
- name = "MrRSS";
- productName = "MrRSS";
- productReference = C0DEBEEF0000000000000004 /* MrRSS.app */;
- productType = "com.apple.product-type.application";
- };
-/* End PBXNativeTarget section */
-
-/* Begin PBXProject section */
- C0DEBEEF0000000000000060 /* Project object */ = {
- isa = PBXProject;
- attributes = {
- LastUpgradeCheck = 1500;
- ORGANIZATIONNAME = "Ch3nyang";
- TargetAttributes = {
- C0DEBEEF0000000000000040 = {
- CreatedOnToolsVersion = 15.0;
- };
- };
- };
- buildConfigurationList = C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */;
- compatibilityVersion = "Xcode 15.0";
- developmentRegion = en;
- hasScannedForEncodings = 0;
- knownRegions = (
- en,
- );
- mainGroup = C0DEBEEF0000000000000010;
- productRefGroup = C0DEBEEF0000000000000020 /* Products */;
- projectDirPath = "";
- projectRoot = "";
- targets = (
- C0DEBEEF0000000000000040 /* MrRSS */,
- );
- };
-/* End PBXProject section */
-
-/* Begin PBXFrameworksBuildPhase section */
- C0DEBEEF0000000000000056 /* Frameworks */ = {
- isa = PBXFrameworksBuildPhase;
- buildActionMask = 2147483647;
- files = (
- C0DEBEEF00000000000000F7 /* MrRSS.a in Frameworks */,
- C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */,
- C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */,
- C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */,
- C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */,
- C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */,
- C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */,
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXFrameworksBuildPhase section */
-
-/* Begin PBXShellScriptBuildPhase section */
- C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- inputFileListPaths = (
- );
- inputPaths = (
- );
- name = "Prebuild: Wails Go Archive";
- outputFileListPaths = (
- );
- outputPaths = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "set -e\nAPP_ROOT=\"${PROJECT_DIR}/../../..\"\nSDK_PATH=$(xcrun --sdk iphonesimulator --show-sdk-path)\nexport GOOS=ios\nexport GOARCH=arm64\nexport CGO_ENABLED=1\nexport CGO_CFLAGS=\"-isysroot ${SDK_PATH} -target arm64-apple-ios15.0-simulator -mios-simulator-version-min=15.0\"\nexport CGO_LDFLAGS=\"-isysroot ${SDK_PATH} -target arm64-apple-ios15.0-simulator\"\ncd \"${APP_ROOT}\"\n# Ensure overlay exists\nif [ ! -f build/ios/xcode/overlay.json ]; then\n wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml || true\nfi\n# Build Go c-archive if missing or older than sources\nif [ ! -f bin/MrRSS.a ]; then\n echo \"Building Go c-archive...\"\n go build -buildmode=c-archive -overlay build/ios/xcode/overlay.json -o bin/MrRSS.a\nfi\n";
- };
-/* End PBXShellScriptBuildPhase section */
-
-/* Begin PBXSourcesBuildPhase section */
- C0DEBEEF0000000000000050 /* Sources */ = {
- isa = PBXSourcesBuildPhase;
- buildActionMask = 2147483647;
- files = (
- C0DEBEEF0000000000000001 /* main.m in Sources */,
- );
- runOnlyForDeploymentPostprocessing = 0;
- };
-/* End PBXSourcesBuildPhase section */
-
-/* Begin XCBuildConfiguration section */
- C0DEBEEF0000000000000090 /* Debug */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- INFOPLIST_FILE = main/Info.plist;
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
- PRODUCT_BUNDLE_IDENTIFIER = "com.mrrss.app";
- PRODUCT_NAME = "MrRSS";
- CODE_SIGNING_ALLOWED = NO;
- SDKROOT = iphonesimulator;
- };
- name = Debug;
- };
- C0DEBEEF00000000000000A0 /* Release */ = {
- isa = XCBuildConfiguration;
- buildSettings = {
- INFOPLIST_FILE = main/Info.plist;
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
- PRODUCT_BUNDLE_IDENTIFIER = "com.mrrss.app";
- PRODUCT_NAME = "MrRSS";
- CODE_SIGNING_ALLOWED = NO;
- SDKROOT = iphonesimulator;
- };
- name = Release;
- };
-/* End XCBuildConfiguration section */
-
-/* Begin XCConfigurationList section */
- C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "MrRSS" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- C0DEBEEF0000000000000090 /* Debug */,
- C0DEBEEF00000000000000A0 /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Debug;
- };
- C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */ = {
- isa = XCConfigurationList;
- buildConfigurations = (
- C0DEBEEF0000000000000090 /* Debug */,
- C0DEBEEF00000000000000A0 /* Release */,
- );
- defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Debug;
- };
-/* End XCConfigurationList section */
- };
- rootObject = C0DEBEEF0000000000000060 /* Project object */;
-}
diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml
deleted file mode 100644
index 6cd23c03..00000000
--- a/build/linux/Taskfile.yml
+++ /dev/null
@@ -1,169 +0,0 @@
-version: '3'
-
-includes:
- common: ../Taskfile.yml
-
-vars:
- # Signing configuration - edit these values for your project
- # PGP_KEY: "path/to/signing-key.asc"
- # SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
- #
- # Password is stored securely in system keychain. Run: wails3 setup signing
-
- # Docker image for cross-compilation (used when building on non-Linux or no CC available)
- CROSS_IMAGE: wails-cross
-
-tasks:
- build:
- summary: Builds the application for Linux
- cmds:
- # Linux requires CGO - use Docker when cross-compiling from non-Linux OR when no C compiler is available
- - task: '{{if and (eq OS "linux") (eq .HAS_CC "true")}}build:native{{else}}build:docker{{end}}'
- vars:
- ARCH: '{{.ARCH}}'
- DEV: '{{.DEV}}'
- OUTPUT: '{{.OUTPUT}}'
- vars:
- DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
- OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
- # Check if a C compiler is available (gcc or clang)
- HAS_CC:
- sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
-
- build:native:
- summary: Builds the application natively on Linux
- internal: true
- deps:
- - task: common:go:mod:tidy
- - task: common:build:frontend
- vars:
- BUILD_FLAGS:
- ref: .BUILD_FLAGS
- DEV:
- ref: .DEV
- - task: common:generate:icons
- - task: generate:dotdesktop
- cmds:
- - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
- vars:
- BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
- DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
- OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
- env:
- GOOS: linux
- CGO_ENABLED: 1
- GOARCH: '{{.ARCH | default ARCH}}'
-
- build:docker:
- summary: Cross-compiles for Linux using Docker with Zig (for macOS/Windows hosts)
- internal: true
- deps:
- - task: common:build:frontend
- - task: common:generate:icons
- - task: generate:dotdesktop
- preconditions:
- - sh: docker info > /dev/null 2>&1
- msg: "Docker is required for cross-compilation to Linux. Please install Docker."
- - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
- msg: |
- Docker image '{{.CROSS_IMAGE}}' not found.
- Build it first: wails3 task setup:docker
- cmds:
- - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME={{.APP_NAME}} {{.CROSS_IMAGE}} linux {{.DOCKER_ARCH}}
- - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
- - mkdir -p {{.BIN_DIR}}
- - mv bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}} {{.OUTPUT}}
- vars:
- DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
- DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
- OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
- # Mount Go module cache for faster builds
- GO_CACHE_MOUNT:
- sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
- # Extract replace directives from go.mod and create -v mounts for each
- REPLACE_MOUNTS:
- sh: |
- grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
- path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
- # Convert relative paths to absolute
- if [ "${path#/}" = "$path" ]; then
- path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
- fi
- # Only mount if directory exists
- if [ -d "$path" ]; then
- echo "-v $path:$path:ro"
- fi
- done | tr '\n' ' '
-
- package:
- summary: Packages the application for Linux
- deps:
- - task: build
- cmds:
- - task: create:appimage
- - task: create:tarball
-
- create:appimage:
- summary: Creates an AppImage
- deps:
- - task: build
- - task: generate:dotdesktop
- cmds:
- - |
- if [ -f "build/linux/create-appimage.sh" ]; then
- chmod +x build/linux/create-appimage.sh
- ARCH={{.ARCH | default "amd64"}} ./build/linux/create-appimage.sh
- else
- echo "Warning: create-appimage.sh not found, skipping AppImage creation"
- fi
-
- create:tarball:
- summary: Creates a tarball package
- deps:
- - task: build
- cmds:
- - cd {{.BIN_DIR}} && tar -czf {{.APP_NAME}}-{{.VERSION}}-linux-{{.ARCH}}.tar.gz {{.APP_NAME}}
- vars:
- VERSION: '1.3.28'
- ARCH: '{{.ARCH | default "amd64"}}'
-
- generate:dotdesktop:
- summary: Generates a `.desktop` file
- dir: build
- cmds:
- - mkdir -p {{.ROOT_DIR}}/build/linux
- - |
- cat > {{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop << EOF
- [Desktop Entry]
- Name={{.APP_NAME}}
- Comment=A modern, standalone RSS reader
- Exec={{.APP_NAME}}
- Icon={{.APP_NAME}}
- Terminal=false
- Type=Application
- Categories=Network;News;
- EOF
-
- run:
- cmds:
- - '{{.BIN_DIR}}/{{.APP_NAME}}'
-
- build:server:
- summary: Builds the server version using Docker
- deps:
- - task: common:build:frontend
- cmds:
- - docker build -f Dockerfile.server -t mrrss-server:latest .
- - docker create --name mrrss-server-temp mrrss-server:latest
- - docker cp mrrss-server-temp:/app/mrrss-server {{.BIN_DIR}}/{{.APP_NAME}}-server
- - docker rm mrrss-server-temp
- preconditions:
- - sh: docker info > /dev/null 2>&1
- msg: "Docker is required for building the server version"
-
- run:server:
- summary: Runs the server version
- deps:
- - task: build:server
- cmds:
- - '{{.BIN_DIR}}/{{.APP_NAME}}-server'
diff --git a/build/linux/create-appimage.sh b/build/linux/create-appimage.sh
deleted file mode 100755
index 2ff5b784..00000000
--- a/build/linux/create-appimage.sh
+++ /dev/null
@@ -1,225 +0,0 @@
-#!/bin/bash
-# Script to create a Linux AppImage for MrRSS
-#
-# Application Information:
-# Name: MrRSS
-# Description: A Modern, Cross-Platform Desktop RSS Reader
-# Publisher: Ch3nyang
-# URL: https://github.com/DevXDojo/MrRSS
-# Copyright: Copyright © Ch3nyang
-
-# Exit on error, but allow some commands to fail gracefully
-set -e
-
-APP_NAME="MrRSS"
-# Get version from frontend/package.json if available, otherwise use default
-VERSION=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' frontend/package.json 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)".*/\1/' || echo "1.3.24")
-# Get architecture from environment variable or default to amd64
-ARCH=${ARCH:-amd64}
-echo "Target architecture: ${ARCH}"
-echo "System architecture: $(uname -m)"
-APP_PUBLISHER="Ch3nyang"
-APP_URL="https://github.com/DevXDojo/MrRSS"
-APP_DESCRIPTION="A Modern, Cross-Platform Desktop RSS Reader"
-BUILD_DIR="build/bin"
-APPDIR="build/appimage/${APP_NAME}.AppDir"
-APPIMAGE_NAME="${APP_NAME}-${VERSION}-linux-${ARCH}.AppImage"
-
-echo "Creating AppImage for ${APP_NAME} ${VERSION}..."
-echo "Publisher: ${APP_PUBLISHER}"
-echo "Description: ${APP_DESCRIPTION}"
-echo ""
-
-# Check if binary exists
-if [ ! -f "${BUILD_DIR}/${APP_NAME}" ]; then
- echo "Error: Binary not found at ${BUILD_DIR}/${APP_NAME}"
- echo "Please build the application first with: wails3 build -platform linux/amd64"
- exit 1
-fi
-
-# Create AppDir structure
-echo "Creating AppDir structure..."
-rm -rf "build/appimage"
-mkdir -p "${APPDIR}/usr/bin"
-mkdir -p "${APPDIR}/usr/share/applications"
-mkdir -p "${APPDIR}/usr/share/icons/hicolor/256x256/apps"
-
-# Copy binary
-echo "Copying binary..."
-cp "${BUILD_DIR}/${APP_NAME}" "${APPDIR}/usr/bin/"
-chmod +x "${APPDIR}/usr/bin/${APP_NAME}"
-
-# Create desktop file
-echo "Creating desktop file..."
-cat > "${APPDIR}/usr/share/applications/${APP_NAME}.desktop" << EOF
-[Desktop Entry]
-Type=Application
-Name=${APP_NAME}
-GenericName=RSS Reader
-Comment=${APP_DESCRIPTION}
-Exec=${APP_NAME}
-Icon=${APP_NAME}
-Categories=Network;News;Feed;
-Terminal=false
-StartupWMClass=${APP_NAME}
-Keywords=RSS;Atom;Feed;News;Reader;
-X-GNOME-UsesNotifications=true
-EOF
-
-# Create AppRun script
-echo "Creating AppRun script..."
-cat > "${APPDIR}/AppRun" << 'EOF'
-#!/bin/bash
-SELF=$(readlink -f "$0")
-HERE=${SELF%/*}
-export PATH="${HERE}/usr/bin:${PATH}"
-export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH}"
-exec "${HERE}/usr/bin/MrRSS" "$@"
-EOF
-chmod +x "${APPDIR}/AppRun"
-
-# Copy icon (if exists, otherwise create placeholder)
-# Icon handling is non-critical - continue even if it fails
-set +e
-if [ -f "imgs/logo.svg" ] && (command -v inkscape &> /dev/null || command -v convert &> /dev/null); then
- echo "Converting icon..."
- # If inkscape is available, convert SVG to PNG
- if command -v inkscape &> /dev/null; then
- inkscape "imgs/logo.svg" -o "${APPDIR}/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png" -w 256 -h 256 2>/dev/null || echo "Warning: inkscape icon conversion failed"
- cp "${APPDIR}/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png" "${APPDIR}/${APP_NAME}.png" 2>/dev/null || true
- elif command -v convert &> /dev/null; then
- convert -background none -size 256x256 "imgs/logo.svg" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png" 2>/dev/null || echo "Warning: ImageMagick icon conversion failed"
- cp "${APPDIR}/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png" "${APPDIR}/${APP_NAME}.png" 2>/dev/null || true
- fi
-elif [ -f "build/appicon.png" ]; then
- # Fallback to pre-built PNG icon from Wails build process
- echo "Using existing PNG icon..."
- cp "build/appicon.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png" 2>/dev/null || echo "Warning: Failed to copy icon"
- cp "build/appicon.png" "${APPDIR}/${APP_NAME}.png" 2>/dev/null || true
-else
- echo "Warning: No icon available - AppImage will be created without an icon"
-fi
-set -e
-
-# Copy desktop file to root
-cp "${APPDIR}/usr/share/applications/${APP_NAME}.desktop" "${APPDIR}/"
-
-# Set APPIMAGE_ARCH early for checks
-APPIMAGE_ARCH="${ARCH}"
-if [ "${ARCH}" = "amd64" ]; then
- APPIMAGE_ARCH="x86_64"
-elif [ "${ARCH}" = "arm64" ]; then
- APPIMAGE_ARCH="aarch64"
-fi
-
-# Download appimagetool if not present
-APPIMAGETOOL_ARCH="x86_64"
-if [ "${ARCH}" = "arm64" ]; then
- APPIMAGETOOL_ARCH="aarch64"
-fi
-echo "Determining appimagetool architecture..."
-echo "ARCH variable: ${ARCH}"
-echo "APPIMAGETOOL_ARCH will be: ${APPIMAGETOOL_ARCH}"
-APPIMAGETOOL="build/appimagetool-${APPIMAGETOOL_ARCH}.AppImage"
-
-# Clean up wrong architecture appimagetool if exists
-if [ "${APPIMAGETOOL_ARCH}" = "aarch64" ] && [ -f "build/appimagetool-x86_64.AppImage" ]; then
- echo "Removing x86_64 appimagetool (need aarch64)..."
- rm -f build/appimagetool-x86_64.AppImage
-elif [ "${APPIMAGETOOL_ARCH}" = "x86_64" ] && [ -f "build/appimagetool-aarch64.AppImage" ]; then
- echo "Removing aarch64 appimagetool (need x86_64)..."
- rm -f build/appimagetool-aarch64.AppImage
-fi
-
-if [ ! -f "${APPIMAGETOOL}" ]; then
- echo "Downloading appimagetool for ${APPIMAGETOOL_ARCH}..."
- APPIMAGETOOL_URL="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${APPIMAGETOOL_ARCH}.AppImage"
- echo "URL: ${APPIMAGETOOL_URL}"
- if ! wget -q "${APPIMAGETOOL_URL}" -O "${APPIMAGETOOL}"; then
- echo "Error: Failed to download appimagetool for ${APPIMAGETOOL_ARCH}"
- echo "Please download it manually from: https://github.com/AppImage/AppImageKit/releases"
- exit 1
- fi
- chmod +x "${APPIMAGETOOL}"
- echo "Downloaded and made executable: ${APPIMAGETOOL}"
- # Verify the architecture of downloaded file
- file "${APPIMAGETOOL}" || true
-fi
-
-# Verify appimagetool is executable
-if [ ! -x "${APPIMAGETOOL}" ]; then
- echo "Warning: appimagetool is not executable, attempting to fix permissions..."
- if ! chmod +x "${APPIMAGETOOL}"; then
- echo "Error: Failed to make appimagetool executable"
- echo "Please check file permissions on: ${APPIMAGETOOL}"
- exit 1
- fi
-fi
-
-# Create AppImage
-echo "Creating AppImage..."
-echo "Current architecture: ${ARCH}"
-echo "AppImage tool architecture: ${APPIMAGETOOL_ARCH}"
-echo "System architecture: $(uname -m)"
-rm -f "${BUILD_DIR}/${APPIMAGE_NAME}"
-
-# Verify binary architecture before creating AppImage
-echo "Verifying binary architecture..."
-BINARY_ARCH=$(file "${APPDIR}/usr/bin/${APP_NAME}" | grep -o 'aarch64\|x86-64\|ARM aarch64' || true)
-echo "Binary architecture: ${BINARY_ARCH}"
-echo "Expected AppImage architecture: ${APPIMAGE_ARCH}"
-
-# Debug: List ALL files in AppDir with their architecture
-echo "Listing all ELF files in AppDir:"
-find "${APPDIR}" -type f -exec file {} \; 2>/dev/null | grep -E 'ELF|executable|shared' || echo "No ELF files found (besides binary)"
-
-if [ -n "${CI}" ] || ! [ -e /dev/fuse ]; then
- echo "FUSE not available, using --appimage-extract-and-run mode"
- echo "Running: ARCH=${APPIMAGE_ARCH} ${APPIMAGETOOL} --appimage-extract-and-run --verbose ${APPDIR} ${BUILD_DIR}/${APPIMAGE_NAME}"
- if ! ARCH="${APPIMAGE_ARCH}" "${APPIMAGETOOL}" --appimage-extract-and-run --no-appstream --verbose "${APPDIR}" "${BUILD_DIR}/${APPIMAGE_NAME}" 2>&1; then
- echo "Error: AppImage creation failed"
- echo "This might be due to architecture mismatch or missing dependencies"
- echo "Checking AppDir contents..."
- find "${APPDIR}" -type f -exec file {} \; | grep -E 'ELF|shared object' || true
-
- # Fallback: create tar.gz instead
- echo "Falling back to tar.gz archive..."
- if [ -f "${APPDIR}/usr/bin/${APP_NAME}" ]; then
- tar czf "${BUILD_DIR}/${APP_NAME}-${VERSION}-linux-${ARCH}.tar.gz" -C "${APPDIR}/usr/bin" "${APP_NAME}"
- echo "Created fallback archive: ${BUILD_DIR}/${APP_NAME}-${VERSION}-linux-${ARCH}.tar.gz"
- else
- echo "Error: Binary not found at ${APPDIR}/usr/bin/${APP_NAME}"
- exit 1
- fi
- exit 0
- fi
-else
- echo "Running: ARCH=${APPIMAGE_ARCH} ${APPIMAGETOOL} --verbose ${APPDIR} ${BUILD_DIR}/${APPIMAGE_NAME}"
- if ! ARCH="${APPIMAGE_ARCH}" "${APPIMAGETOOL}" --no-appstream --verbose "${APPDIR}" "${BUILD_DIR}/${APPIMAGE_NAME}" 2>&1; then
- echo "Error: AppImage creation failed"
- echo "Checking AppDir contents..."
- find "${APPDIR}" -type f -exec file {} \; | grep -E 'ELF|shared object' || true
-
- # Fallback: create tar.gz instead
- echo "Falling back to tar.gz archive..."
- if [ -f "${APPDIR}/usr/bin/${APP_NAME}" ]; then
- tar czf "${BUILD_DIR}/${APP_NAME}-${VERSION}-linux-${ARCH}.tar.gz" -C "${APPDIR}/usr/bin" "${APP_NAME}"
- echo "Created fallback archive: ${BUILD_DIR}/${APP_NAME}-${VERSION}-linux-${ARCH}.tar.gz"
- else
- echo "Error: Binary not found at ${APPDIR}/usr/bin/${APP_NAME}"
- exit 1
- fi
- exit 0
- fi
-fi
-
-# Clean up
-rm -rf "build/appimage"
-
-echo "AppImage created successfully: ${BUILD_DIR}/${APPIMAGE_NAME}"
-echo ""
-echo "Installation instructions:"
-echo "1. Make the AppImage executable: chmod +x ${APPIMAGE_NAME}"
-echo "2. Run the AppImage: ./${APPIMAGE_NAME}"
-echo ""
-echo "User data will be stored in: ~/.local/share/MrRSS/"
diff --git a/build/linux/desktop b/build/linux/desktop
deleted file mode 100644
index 0e33659a..00000000
--- a/build/linux/desktop
+++ /dev/null
@@ -1,11 +0,0 @@
-[Desktop Entry]
-Version=1.0
-Name=MrRSS
-Comment=A modern, standalone RSS reader
-# The Exec line includes %u to pass the URL to the application
-Exec=/usr/local/bin/MrRSS %u
-Terminal=false
-Type=Application
-Icon=MrRSS
-Categories=Utility;
-StartupWMClass=MrRSS
diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml
deleted file mode 100644
index c35c35ed..00000000
--- a/build/linux/nfpm/nfpm.yaml
+++ /dev/null
@@ -1,67 +0,0 @@
-# Feel free to remove those if you don't want/need to use them.
-# Make sure to check the documentation at https://nfpm.goreleaser.com
-#
-# The lines below are called `modelines`. See `:help modeline`
-
-name: "MrRSS"
-arch: ${GOARCH}
-platform: "linux"
-version: "1.3.28"
-section: "default"
-priority: "extra"
-maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
-description: "A modern, standalone RSS reader"
-vendor: "Ch3nyang"
-homepage: "https://mrrss.ch3nyang.top"
-license: "MIT"
-release: "1"
-
-contents:
- - src: "./bin/MrRSS"
- dst: "/usr/local/bin/MrRSS"
- - src: "./build/appicon.png"
- dst: "/usr/share/icons/hicolor/128x128/apps/MrRSS.png"
- - src: "./build/linux/MrRSS.desktop"
- dst: "/usr/share/applications/MrRSS.desktop"
-
-# Default dependencies for Ubuntu 24.04+ with GTK4/WebKitGTK 6.0
-depends:
- - libgtk-4-1
- - libwebkitgtk-6.0-4
-
-# Distribution-specific overrides for different package formats and WebKit versions
-overrides:
- # RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux
- rpm:
- depends:
- - gtk4
- - webkitgtk6.0
-
- # Arch Linux packages
- archlinux:
- depends:
- - gtk4
- - webkitgtk-6.0
-
-# scripts section to ensure desktop database is updated after install
-scripts:
- postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
- # You can also add preremove, postremove if needed
- # preremove: "./build/linux/nfpm/scripts/preremove.sh"
- # postremove: "./build/linux/nfpm/scripts/postremove.sh"
-
-# replaces:
-# - foobar
-# provides:
-# - bar
-# depends:
-# - gtk4
-# - libwebkitgtk
-# recommends:
-# - whatever
-# suggests:
-# - something-else
-# conflicts:
-# - not-foo
-# - not-bar
-# changelog: "changelog.yaml"
diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml
deleted file mode 100644
index f2f086e5..00000000
--- a/build/windows/Taskfile.yml
+++ /dev/null
@@ -1,176 +0,0 @@
-version: '3'
-
-includes:
- common: ../Taskfile.yml
-
-vars:
- # Signing configuration - edit these values for your project
- # SIGN_CERTIFICATE: "path/to/certificate.pfx"
- # SIGN_THUMBPRINT: "certificate-thumbprint" # Alternative to SIGN_CERTIFICATE
- # TIMESTAMP_SERVER: "http://timestamp.digicert.com"
- #
- # Password is stored securely in system keychain. Run: wails3 setup signing
-
- # Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows)
- CROSS_IMAGE: wails-cross
-
-tasks:
- build:
- summary: Builds the application for Windows
- cmds:
- # Auto-detect CGO: if CGO_ENABLED=1, use Docker; otherwise use native Go cross-compile
- - task: '{{if and (ne OS "windows") (eq .CGO_ENABLED "1")}}build:docker{{else}}build:native{{end}}'
- vars:
- ARCH: '{{.ARCH}}'
- DEV: '{{.DEV}}'
- vars:
- # Default to CGO_ENABLED=1 for Wails v3
- CGO_ENABLED: '{{.CGO_ENABLED | default "1"}}'
-
- build:native:
- summary: Builds the application using native Go cross-compilation
- internal: true
- deps:
- - task: common:go:mod:tidy
- - task: common:build:frontend
- vars:
- BUILD_FLAGS:
- ref: .BUILD_FLAGS
- DEV:
- ref: .DEV
- - task: common:generate:icons
- cmds:
- - cmd: powershell Remove-item *.syso -ErrorAction SilentlyContinue
- platforms: [windows]
- - cmd: rm -f *.syso
- platforms: [linux, darwin]
- - task: generate:syso
- - go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
- - cmd: powershell Remove-item *.syso
- platforms: [windows]
- - cmd: rm -f *.syso
- platforms: [linux, darwin]
- vars:
- BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}'
- env:
- GOOS: windows
- CGO_ENABLED: '{{.CGO_ENABLED | default "1"}}'
- GOARCH: '{{.ARCH | default ARCH}}'
-
- build:docker:
- summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
- internal: true
- deps:
- - task: common:build:frontend
- - task: common:generate:icons
- preconditions:
- - sh: docker info > /dev/null 2>&1
- msg: "Docker is required for CGO cross-compilation. Please install Docker."
- - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
- msg: |
- Docker image '{{.CROSS_IMAGE}}' not found.
- Build it first: wails3 task setup:docker
- cmds:
- - task: generate:syso
- - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME={{.APP_NAME}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}}
- - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
- - rm -f *.syso
- vars:
- DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
- # Mount Go module cache for faster builds
- GO_CACHE_MOUNT:
- sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
- # Extract replace directives from go.mod and create -v mounts for each
- REPLACE_MOUNTS:
- sh: |
- grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
- path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
- # Convert relative paths to absolute
- if [ "${path#/}" = "$path" ]; then
- path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
- fi
- # Only mount if directory exists
- if [ -d "$path" ]; then
- echo "-v $path:$path:ro"
- fi
- done | tr '\n' ' '
-
- package:
- summary: Packages the application
- cmds:
- - task: create:nsis:installer
-
- generate:syso:
- summary: Generates Windows `.syso` file
- dir: build
- deps:
- - task: generate:icon:ico
- cmds:
- - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
- vars:
- ARCH: '{{.ARCH | default ARCH}}'
-
- generate:icon:ico:
- summary: Generates Windows `.ico` file from `.png`
- dir: build
- sources:
- - "windows/icon.png"
- generates:
- - "windows/icon.ico"
- cmds:
- - wails3 generate icons -input windows/icon.png -windowsfilename windows/icon.ico -macfilename=
-
- create:nsis:installer:
- summary: Creates an NSIS installer
- dir: build/windows
- deps:
- - task: build
- cmds:
- # Create the Microsoft WebView2 bootstrapper if it doesn't exist
- - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows"
- - |
- {{if eq OS "windows"}}
- makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\build\bin\{{.APP_NAME}}.exe" installer.nsi
- {{else}}
- makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/build/bin/{{.APP_NAME}}.exe" installer.nsi
- {{end}}
- vars:
- ARCH: '{{.ARCH | default ARCH}}'
- ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
-
- run:
- cmds:
- - '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
-
- sign:
- summary: Signs the Windows executable
- desc: |
- Signs the .exe with an Authenticode certificate.
- Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file.
- Password is retrieved from system keychain (run: wails3 setup signing)
- deps:
- - task: build
- cmds:
- - wails3 tool sign --input {{.BIN_DIR}}/{{.APP_NAME}}.exe {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}}
- preconditions:
- - sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]'
- msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml"
- build:server:
- summary: Builds the server version using Docker
- deps:
- - task: common:build:frontend
- cmds:
- - docker build -f Dockerfile.server -t mrrss-server:latest .
- - docker create --name mrrss-server-temp mrrss-server:latest
- - docker cp mrrss-server-temp:/app/mrrss-server {{.BIN_DIR}}/{{.APP_NAME}}-server.exe
- - docker rm mrrss-server-temp
- preconditions:
- - sh: docker info > /dev/null 2>&1
- msg: "Docker is required for building the server version"
-
- run:server:
- summary: Runs the server version
- deps:
- - task: build:server
- cmds:
- - '{{.BIN_DIR}}/{{.APP_NAME}}-server.exe'
diff --git a/build/windows/icon.ico b/build/windows/icon.ico
deleted file mode 100644
index 8668dcd66e3efa2614212748757c80c9acc7ecb8..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 81960
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 5E4Nw&Hs;A&j%B_|S87ZV>g%LZ3maVfszTfYv?
zVxg%}u1&Kuj)?`-zY5sqOBbpRBLLV;QaO>(22e$k$yF|4XM4M|<%0GQc8(HU3^Oo3
ztRizdTYO3)Kal|oHr!alh|ngMb@vPq0&o)ZlV0xAP44^He;0u}p%v}#{hIxPc5rt5
zV(jjG0hZI}_6$EwOLw$l{My*`CNfVt^+;}`rKrV8TrQdfsnytGd;?LEH_H{Yu9VVM
zLRrp9&AQ;@(y9`#@v@dAG&sfRQxXWoCTy=QYuPyxn?5fsusrV?oO;e*!bK1M9H?1#
z{t($jg+?f@0wj^+oGU0)q1L}F6nEFRWz*n0zx{v1Raae!+1^3UK5@JqMBuLVuS;tY
zK&e2h>-BFWPsPc*tx6BXe)NL#<8ltmARYs2;&5`Zp9y-6);7Oe#+PqX3ee~WHbK?I
zzz7P9mHQT
zemf9ieZCU0_i~GE%Y>ELcOQ1PkqF{J1pJctNlYXu<|j4#kVxIkN59sq=t!9LItq&g
z@hg1BLi?w<^VW>&y@qi%!XsHj2=>H{O^o95<-}ELd%B~VfrX+*24v=C#4d&3^cyeC543kChIk2WHFrB5AZ^rQimxSr8)@#{R>)HJ*
z)wtO&uiK#8&AOv0PhTFhSfXqLb{06G^KfSSW!O9NO7Pa@Ks^Pu+J3v=GhcTaUqt3<
z?LHDqbycSE<8q!5f+VB(%ovg!^U#fKcU{OU16~s1A{#OZyHpmOjHEdPN&73?n&g_i
zY+faTg3`EmrC0sZA&ckTCNvJG;x~-lEB^wfyPw9^-u-O^By0~!gv^N*3I$XEtGS%f
z>Qd7%E;w%o-}m?bDz>K6_7dAP)JBqOCRn44;X%&=|7O>N`_LMK(bp$d4O7h(inD2&
zcDK{kbkg3^3xa!=is~y@Y1B=;ewW?hVs`>qDVxx^IH9b?Prei}*DdUs%xh`Z0#
z^bu@LAFVdWHqLCn8oTFR3!YpCOwR9?rmAtv_!Ktbi0Hh?=s*h2X$iWD-h7tJcvYpf
zb-Z{&!g5436!}kR*@Vt2JIzsp+m%};Xa^eENJ`2lN-#JpYawWGDZqNFWC7K=>6*x-
z8WWG|>USd&ub}?8>vOdP(?vM-oNvMS(?5c$-7gUkN{=Zhoh6Vi0-=NwI7O|-2gt9U
z>%bVb&c46?x4#*$x%Oq4?;YeMJaB*p*?ljz2C3~yU=xY7`Zm@j&7K1bKQa7JU?Xn<
zm5176jxM|8D2`or3GRE~vlv-oWM~SL+(uBR)ubb`&EZS(=3`|kByr|3aTuJWwG)iu
z@Fp9a#5S;}-*(H59=z4_Nm*ezhUxlNOy}LEJSkj5y?#E&Y(l|rq7?SgFm47~8
z;PtP2HNN9pzX6Mb1HGMH28d<$eLp% *FERjPCX|$eM7Ku?
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 |