diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index e4a2bc5ad..d22ac0320 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -4,15 +4,15 @@
## Project Context
-MrRSS is a modern, privacy-focused, cross-platform desktop RSS reader built with Wails (Go + Vue.js).
+MrRSS is a privacy-focused RSS reader. On this branch the interface is a native macOS client in SwiftUI, talking to a Go backend over its HTTP API.
**Core Principles**: Privacy-first, cross-platform, modern UI, high performance, accessible
## Tech Stack
-- **Backend**: Go 1.27+ with Wails v3 (beta) framework, SQLite with `modernc.org/sqlite`
-- **Frontend**: Vue 3.5+ Composition API, Pinia, Tailwind CSS 3.3+, Vite 5+
-- **Tools**: Wails CLI v3, npm, Go modules
+- **Backend**: Go 1.27+, SQLite with `modernc.org/sqlite`, serving only `/api`
+- **Client**: SwiftUI for macOS 14+, a SwiftPM package with no third-party dependencies
+- **Tools**: Swift toolchain from Xcode, Go modules
- **Icons**: Phosphor Icons | **I18n**: vue-i18n (English/Chinese)
## Quick Patterns Reference
@@ -29,282 +29,51 @@ MrRSS is a modern, privacy-focused, cross-platform desktop RSS reader built with
ð **Full Patterns**: See [CODE_PATTERNS.md](../docs/CODE_PATTERNS.md#backend-patterns-go)
-### Frontend (Vue 3)
+### Client (SwiftUI)
-When writing Vue components, follow this pattern:
+When writing a view, follow this shape:
-```vue
-
-
-
-
-
-
-
-```
-
-ð **Full Patterns**: See [CODE_PATTERNS.md](../docs/CODE_PATTERNS.md#frontend-patterns-vue)
-
-## Internationalization
-
-Always use i18n for user-facing strings:
-
-```vue
-
-
-
-```
-
-## Settings Management (OPTIMIZED)
-
-â **The settings system has been optimized with schema-driven code generation!**
-
-### Quick Method (3 Steps)
-
-**Step 1**: Edit `internal/config/settings_schema.json`
-
-```json
-"new_setting_key": {
- "type": "bool",
- "default": false,
- "category": "general",
- "encrypted": false,
- "frontend_key": "new_setting_key"
-}
-```
-
-**Step 2**: Generate all code
-
-```bash
-go run tools/settings-generator/main.go
-```
-
-**Step 3**: Add UI (optional)
-
-```vue
-
-
-
-```
-
-### What Gets Generated Automatically
-
-- â Backend types and handlers
-- â Frontend types and composables
-- â Database initialization keys
-- â Default values
-
-### Old Method (Deprecated)
-
-The manual 8-file checklist is **no longer needed**. All new settings should use the schema-driven approach.
-
-ð **Complete Guide**: See [docs/SETTINGS.md](../docs/SETTINGS.md)
-
-## Security Best Practices
-
-### Input Validation
-
-Always validate user inputs, especially URLs and file paths:
-
-```go
-// Validate URL format and scheme
-func validateFeedURL(urlStr string) error {
- u, err := url.Parse(urlStr)
- if err != nil {
- return fmt.Errorf("invalid URL: %w", err)
- }
-
- if u.Scheme != "http" && u.Scheme != "https" {
- return errors.New("URL must use HTTP or HTTPS")
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ unreadDot
+ VStack(alignment: .leading, spacing: 4) {
+ // Every interface string comes from the catalogue.
+ Text(article.displayTitle(preferTranslation: showsTranslation))
+ .fontWeight(article.isRead ? .regular : .semibold)
+ metadata
+ }
+ }
+ .contextMenu { contextMenu }
}
- return nil
-}
+ private var unreadDot: some View { /* ... */ }
+ private var metadata: some View { /* ... */ }
-// Validate file path to prevent directory traversal
-func validateFilePath(baseDir, filePath string) error {
- cleanPath := filepath.Clean(filePath)
- if !strings.HasPrefix(cleanPath, filepath.Clean(baseDir)) {
- return errors.New("invalid file path: path traversal detected")
+ @ViewBuilder
+ private var contextMenu: some View {
+ Button {
+ viewModel.setArticleRead(article, read: !article.isRead)
+ } label: {
+ Label(t("article.action.markAsRead"), systemImage: "checkmark.circle")
+ }
}
- return nil
}
```
-### Safe Command Execution
+Behaviour belongs on `AppViewModel` or one of its extensions, not in the view:
-**NEVER** use shell command concatenation:
-
-```go
-// â BAD: Command injection vulnerability
-cmd := exec.Command("sh", "-c", "rm " + filePath)
-
-// â GOOD: Use Go standard library
-if err := os.Remove(filePath); err != nil {
- return fmt.Errorf("remove file: %w", err)
-}
-
-// â GOOD: If external command is necessary, use separate args
-cmd := exec.Command("installer.exe", "/S") // No concatenation
-```
-
-### File Operations
-
-Always clean up temporary files and use proper error handling:
-
-```go
-// Schedule cleanup with timeout
-scheduleCleanup := func(filePath string, delay time.Duration) {
- go func() {
- time.Sleep(delay)
- if err := os.Remove(filePath); err != nil {
- log.Printf("Failed to cleanup %s: %v", filePath, err)
- } else {
- log.Printf("Cleaned up temporary file: %s", filePath)
+```swift
+extension AppViewModel {
+ func toggleReadLater(_ article: Article) {
+ mutateArticle(article.id, apply: { $0.isReadLater.toggle() }) { [weak self] in
+ try await self?.api.toggleReadLater(id: article.id)
}
- }()
+ }
}
```
@@ -312,19 +81,18 @@ scheduleCleanup := func(filePath string, delay time.Duration) {
â **Don't**:
-- Use `var` declarations in Vue (use `ref` or `reactive`)
-- Hardcode user-facing strings (always use i18n `t()`)
-- Use inline styles (use Tailwind classes or scoped styles)
+- Hardcode user-facing strings (always use `t("some.key")`)
+- Write long SwiftUI bodies (type checking stalls; split into computed properties)
+- Force unwrap outside tests
- Forget error handling in async operations
-- Use `any` type without strong justification
- Commit API keys, secrets, or sensitive data
-- Use `v-html` for user content (XSS risk)
-- Make breaking changes without migration path
+- Render untrusted markup without sanitising it (see `HTMLDocument.sanitize`)
+- Make breaking changes without a migration path
- Use shell command concatenation (security risk)
-- Create multiple deep watchers when one suffices
-- Forget to clean up timers/intervals on component unmount
+- Let a superseded request overwrite newer state (check the request identifier)
+- Forget to remove time observers and event monitors when a view disappears
- Delete favorited articles during cleanup operations
-- Use synchronous operations in UI thread for long tasks
+- Block the main actor with long-running work
## Do's
@@ -354,21 +122,22 @@ scheduleCleanup := func(filePath string, delay time.Duration) {
**Build Commands**:
-- Development: `wails3 dev`
-- Production Build: `wails3 build`
-- Important: MrRSS uses HTTP API, not Wails bindings
+- Development: `./frontend/run.sh`
+- Client only: `swift build --package-path frontend`
+- Release bundle: `make build-app VERSION=1.3.28`
-**Store Access**:
+**State Access**:
-- `const store = useAppStore()`
-- `const { t } = useI18n()`
-- Theme: `store.theme` returns `'light'` or `'dark'`
-- Language: `store.i18n.locale.value` returns `'en'` or `'zh'`
+- `@ObservedObject var viewModel: AppViewModel`
+- Strings: `t("some.key")`, `t("some.key", ["count": n])`
+- Settings: `viewModel.setting("key")`, `viewModel.boolSetting("key")`
+- Theme follows the `theme` setting through `viewModel.preferredColorScheme`
**UI Helpers**:
-- Toast: `window.showToast(message, type)`
-- Confirm: `await window.showConfirm(title, message, isDanger)`
+- Confirmation: `viewModel.statusMessage = t("...")` shows a short message
+- Errors: `viewModel.errorMessage = ...` raises an alert
+- Confirmations use `.confirmationDialog`
**API Endpoints**:
diff --git a/.github/workflows/pre-release-check.yml b/.github/workflows/pre-release-check.yml
index a8a7a7567..57b99bbd2 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
+ run: go test -timeout=5m ./internal/...
- # Check architecture
- file build/bin/MrRSS
- echo "Binary built successfully for ${{ matrix.arch }}"
-
- - 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
+ 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/build-app.sh
+ frontend/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/dist/MrRSS.app/Contents/MacOS/MrRSS
+ test -x frontend/dist/MrRSS.app/Contents/Resources/mrrss-server
+ test -f frontend/dist/MrRSS.app/Contents/Info.plist
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fe544163d..41868bc81 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -146,503 +146,30 @@ jobs:
files: build/skills/MrRSS-${{ needs.create-release.outputs.version }}-skills.zip
token: ${{ secrets.GITHUB_TOKEN }}
- build-release:
- name: Build ${{ matrix.platform }}-${{ matrix.arch }}
+ build-macos-client:
+ name: Build macOS Universal Client
needs: create-release
- runs-on: ${{ matrix.os }}
+ runs-on: macos-latest
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
+ - uses: actions/checkout@v6
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
+ uses: actions/setup-go@v5
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'
+ - name: Build SwiftUI application bundle
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
+ chmod +x frontend/build-app.sh
+ frontend/build-app.sh "${{ needs.create-release.outputs.version }}"
- - 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
+ - name: Upload SwiftUI macOS release asset
+ uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.create-release.outputs.tag }}
- files: |
- build/bin/MrRSS-${{ needs.create-release.outputs.version }}-*
+ files: frontend/dist/MrRSS-${{ needs.create-release.outputs.version }}-macos.dmg
token: ${{ secrets.GITHUB_TOKEN }}
- fail_on_unmatched_files: false
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2e369f98f..48b02a079 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,6 +60,22 @@ 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-macos-client:
+ name: Test macOS Client
+ runs-on: macos-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Run Swift tests
+ working-directory: ./frontend
+ run: swift test
+
+ - name: Build release executable
+ working-directory: ./frontend
+ run: swift build -c release
+
build-check:
name: Build Check
runs-on: ubuntu-latest
@@ -122,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 20c911009..bcbf81f86 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
@@ -88,3 +77,14 @@ tmpclaude-*-cwd
i18n_usage_report.md
__pycache__/
.VSCodeCounter
+
+# Swift frontend
+.swiftpm
+.build
+DerivedData/
+Checkouts/
+*.xcodeproj
+*.xcworkspace
+
+# macOS
+.DS_Store
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a7089386f..ea83ebbca 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
language: system
- files: \.(js|ts|vue)$
+ files: ^frontend/.*\.swift$
pass_filenames: false
diff --git a/AGENTS.md b/AGENTS.md
index 385e64cb0..8aa71bc6a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,16 +10,20 @@
- **Privacy-First**: No external analytics, all data stored locally
- **Cross-Platform**: Native desktop experience on Windows, macOS, and Linux
-- **Modern Tech Stack**: Go 1.27+ + Wails v3 + Vue 3.5+ with TypeScript
+- **Modern Tech Stack**: Go 1.27+ backend with a native SwiftUI client for macOS
- **AI-Enhanced**: Local algorithms (TF-IDF + TextRank) and cloud AI integration
- **Performance-Optimized**: Concurrent processing, intelligent caching, WAL mode SQLite
### Tech Stack
-- **Backend**: Go 1.27+ with Wails v3 (beta) framework, SQLite with `modernc.org/sqlite`
-- **Frontend**: Vue 3.5+ Composition API, Pinia, Tailwind CSS 3.3+, Vite 5+, TypeScript
-- **Communication**: HTTP REST API (not Wails bindings) for data operations
-- **Icons**: Phosphor Icons | **I18n**: vue-i18n (English/Chinese)
+- **Backend**: Go 1.27+, SQLite with `modernc.org/sqlite`, serving only `/api`
+- **Client**: SwiftUI for macOS 14+, a SwiftPM package with no third-party dependencies
+- **Communication**: the HTTP REST API alone; there are no native bindings
+- **Translations**: a catalogue ported from the previous frontend (English/Chinese)
+
+> **This branch builds the macOS client.** The Vue frontend and the Wails shell
+> were removed, so the Go binary has no embedded web assets.
+
### Key Features
@@ -59,8 +63,7 @@
```plaintext
MrRSS/
-âââ main.go # Desktop application entry point
-âââ main-core.go # Headless server entry point
+âââ main.go # API server entry point
âââ internal/ # Backend Go code
â âââ ai/ # AI configuration and utilities
â âââ aiusage/ # AI usage tracking and limits
@@ -83,21 +86,24 @@ MrRSS/
â âââ summary/ # TF-IDF + TextRank + AI summarization
â âââ translation/ # Multi-service translation
â âââ utils/ # Platform utilities
-â âââ version/ # Version constant
-â âââ webview/ # Webview utilities
-âââ frontend/src/
-â âââ components/ # Vue components (article/, sidebar/, modals/, common/)
-â âââ composables/ # Reusable logic (article/, feed/, discovery/, rules/, ui/)
-â âââ stores/ # Pinia state management
-â âââ types/ # TypeScript definitions
-â âââ i18n/ # Translations (en, zh)
-â âââ utils/ # Frontend utilities
-âââ docs/ # Comprehensive documentation
-âââ build/ # Platform-specific build configurations
-âââ tools/ # Development tools (settings generator)
+â âââ version/ # Version constant
+âââ frontend/
+â âââ Sources/
+â â âââ Models/ # Codable mirrors of the API payloads
+â â âââ Services/API/ # Transport plus one extension per domain
+â â âââ Localization/ # Translation catalogue and client-only wording
+â â âââ ViewModels/ # AppViewModel and its feature extensions
+â â âââ Views/ # SwiftUI views, including the sidebar outline
+â âââ Tests/ # XCTest suite
+â âââ build-app.sh # Bundle and DMG packaging
+â âââ run.sh # Backend plus client launcher
+âââ docs/ # Documentation
+âââ build/ # Icon assets used when packaging
+âââ tools/ # Settings generators (Go and Swift)
âââ scripts/ # Automation scripts (check, pre-release)
```
+
ð **Detailed Structure**: See [ARCHITECTURE.md](docs/ARCHITECTURE.md)
## Key Technologies & Patterns
@@ -105,8 +111,7 @@ MrRSS/
### Backend Architecture (Go 1.27+)
#### Framework & Communication
-- **Wails v3**: Desktop application framework with HTTP API
-- **HTTP REST API**: Primary communication (not Wails bindings)
+- **HTTP REST API**: the only channel between the client and the backend
- **SQLite**: Pure Go implementation (`modernc.org/sqlite`) with WAL mode
#### Core Packages
@@ -172,80 +177,46 @@ MrRSS/
- **Safe File Operations**: No shell command concatenation
- **Script Sandboxing**: Restricted execution context
-### Frontend Architecture (Vue 3.5+)
-
-#### Core Technologies
-- **Vue 3.5+**: Composition API with `
+```swift
+struct FeedRow: View {
+ let feed: Feed
+ @ObservedObject var viewModel: AppViewModel
+
+ var body: some View {
+ HStack {
+ Text(feed.title)
+ Spacer()
+ Text("\(viewModel.badgeCount(for: feed.id, activity: .unread))")
+ .foregroundStyle(.secondary)
+ }
+ }
+}
```
### File Organization
- Backend: `internal/` for internal packages
-- Frontend: `frontend/src/components/` for Vue components
-- Tests: Co-locate tests with the code they test
-- Assets: `frontend/assets/` for images, icons, etc.
+- Client: `frontend/Sources/` â models, services, view models, views
+- Backend tests: alongside the code they test
+- Client tests: `frontend/Tests/`
+- Assets: `imgs/` for artwork, `build/` for the packaging icons
### Commit Messages
diff --git a/Dockerfile.server b/Dockerfile.server
index 66fb2b0a9..a87004535 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 828a1c8cd..2f540309b 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
+
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/README.md b/README.md
index 794dc1c35..65f442235 100644
--- a/README.md
+++ b/README.md
@@ -8,11 +8,16 @@
English | įŪä―äļæ
-[](https://github.com/DevXDojo/MrRSS/releases)
+> **This branch builds the macOS client.** The interface is a native SwiftUI
+> application in `frontend`, and the Go backend runs as a plain HTTP API
+> server behind it. The Vue frontend and the Wails shell are not part of this
+> branch.
+
+[](https://github.com/DevXDojo/MrRSS/releases)
[](LICENSE)
[](https://go.dev/)
-[](https://wails.io/)
-[](https://vuejs.org/)
+[](https://swift.org/)
+[](https://www.apple.com/macos/)
## âĻ Features
@@ -36,22 +41,18 @@ Download the latest installer for your platform from the [Releases](https://gith
-**Standard Installation:**
-
-- **Windows:** `MrRSS-{version}-windows-amd64-installer.exe` / `MrRSS-{version}-windows-arm64-installer.exe`
-- **macOS:** `MrRSS-{version}-darwin-universal.dmg`
-- **Linux:** `MrRSS-{version}-linux-amd64.AppImage` / `MrRSS-{version}-linux-arm64.AppImage`
-
-**Portable Version** (no installation required, all data in one folder):
+**macOS client:**
-- **Windows:** `MrRSS-{version}-windows-{arch}-portable.zip`
-- **Linux:** `MrRSS-{version}-linux-{arch}-portable.tar.gz`
-- **macOS:** `MrRSS-{version}-darwin-{arch}-portable.zip`
+- `MrRSS-{version}-macos.dmg` â universal, and carries the backend, so nothing
+ else needs installing
**AI Agent Skills:**
- **Codex:** `MrRSS-{version}-skills.zip` ([usage guide](docs/SKILLS.md))
+Releases of this branch carry the macOS client alone. Builds for Windows and
+Linux come from the project's main branch.
+
@@ -66,24 +67,11 @@ Download the latest installer for your platform from the [Releases](https://gith
### Prerequisites
-Before you begin, ensure you have the following installed:
-
-- [Go](https://go.dev/) (1.27 or higher)
-- [Node.js](https://nodejs.org/) (20 LTS or higher with npm)
-- [Wails v3](https://v3alpha.wails.io/getting-started/installation/) CLI
+- [Go](https://go.dev/) 1.27 or higher
+- macOS 14 or later
+- Xcode 15 or later (for the Swift toolchain)
-**Platform-specific requirements:**
-
-- **Linux**: GTK4, WebKitGTK 6.0, libsoup 3.0, GCC, pkg-config
-- **Windows**: MinGW-w64 (for CGO support), NSIS (for installers)
-- **macOS**: Xcode Command Line Tools
-
-For detailed installation instructions, see [Build Requirements](docs/BUILD_REQUIREMENTS.md)
-
-```bash
-# Quick setup for Linux (Ubuntu 24.04+):
-sudo apt-get install libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc pkg-config
-```
+See [Build Requirements](docs/BUILD_REQUIREMENTS.md) for details.
### Installation
@@ -94,40 +82,67 @@ sudo apt-get install libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc pkg-c
cd MrRSS
```
-2. **Install frontend dependencies**
+2. **Run the client**
```bash
- cd frontend
- npm install
- cd ..
+ ./frontend/run.sh
```
-3. **Install Wails v3 CLI**
+ The launcher builds the Go backend, starts it on `http://127.0.0.1:1234`,
+ waits for the API to answer, and then starts the client. An existing server
+ on that address is reused.
+
+3. **Or run the two halves separately**
```bash
- go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.117
+ go run . -host 127.0.0.1 -port 1234
+ swift run --package-path frontend MrRSS
```
-4. **Build the application**
+ The backend address can be changed in Settings, or before launch:
```bash
- # Using Task (recommended)
- task build
+ MRRSS_API_BASE_URL=http://127.0.0.1:8080/api swift run --package-path frontend MrRSS
+ ```
- # Or using Makefile
- make build
+4. **Build the application bundle**
- # Or directly with wails3
- wails3 build
+ ```bash
+ make build-app VERSION=1.3.28
```
- The executable will be created in the `build/bin` directory.
+ This produces `frontend/dist/MrRSS.app` and
+ `frontend/dist/MrRSS--macos.dmg` beside it. The bundle carries the backend, so it launches without a
+ separately installed server.
+
+
+
+
+
+### Server Mode
+
+
-5. **Run the application**
+Click to expand the server guide
+
+
+
+The Go binary is an HTTP API server. Run it on its own to share one library
+between machines, and point the client at it in Settings:
+
+```bash
+go build -o mrrss-server .
+./mrrss-server -host 0.0.0.0 -port 1234
+```
- - Windows: `build/bin/MrRSS.exe`
- - macOS: `build/bin/MrRSS.app`
- - Linux: `build/bin/MrRSS`
+A Docker image is available too:
+
+```bash
+docker build -f Dockerfile.server -t mrrss-server:latest .
+docker run -p 1234:1234 -v $PWD/data:/app/data mrrss-server:latest
+```
+
+The API is documented in [docs/SERVER_MODE/swagger.json](docs/SERVER_MODE/swagger.json).
-**Normal Mode** (default):
+The backend keeps its database, logs and scripts in a `data` directory. Which
+one it uses depends on how it was started, and **the copies are independent of
+each other**:
-- **Windows:** `%APPDATA%\MrRSS\` (e.g., `C:\Users\YourName\AppData\Roaming\MrRSS\`)
-- **macOS:** `~/Library/Application Support/MrRSS/`
-- **Linux:** `~/.local/share/MrRSS/`
+| How it was started | Data directory |
+| --- | --- |
+| `./frontend/run.sh` | `data/` in the repository |
+| The packaged `.app` | `~/Library/Application Support/MrRSS/data/` |
+| `go run .` or the built binary | `data/` beside the directory it was started from |
+| The Docker image | `/app/data` in the container |
-**Portable Mode** (when `portable.txt` exists):
+So the library you build up while running from source is not the one the
+installed application reads. To carry one across, quit both and copy
+`data/rss.db` (along with `rss.db-shm` and `rss.db-wal` if they are present).
-- All data stored in `data/` folder
-
-This ensures your data persists across application updates and reinstalls.
+Because the application's data lives outside the bundle, removing the
+application leaves the library in place; delete the directory above to remove it
+as well.
@@ -167,21 +189,22 @@ This ensures your data persists across application updates and reinstalls.
### Running in Development Mode
-Start the application with hot reloading:
-
```bash
-# Using Wails v3
-wails3 dev
+# Backend and client together
+make dev
-# Or using Task
-task dev
+# Backend only, with debug logging
+make serve
+
+# Client only, against a running backend
+swift run --package-path frontend MrRSS
```
### Code Quality Tools
#### Using Make
-We provide a `Makefile` for handling common development tasks (available on Linux/macOS/Windows):
+A `Makefile` covers the common development tasks:
```bash
# Show all available commands
@@ -215,19 +238,18 @@ pre-commit run --all-files
make test
```
-### Local API and Server Mode
-
-The desktop app exposes its REST API at `http://localhost:1234/api` while it is
-running. The listener is restricted to the local computer.
+### Local API
-For server deployments and API integration, use the headless server version:
+The bundled backend serves its REST API at `http://localhost:1234/api`, and the
+listener is restricted to the local computer. Point the client at another
+address in Settings to read a shared library.
```bash
-# Using Docker (recommended)
+# Using Docker
docker run -p 1234:1234 mrrss-server:latest
# Or build from source
-go build -tags server -o mrrss-server .
+go build -o mrrss-server .
./mrrss-server
```
diff --git a/README_zh.md b/README_zh.md
index 60fb90468..0a3bfe291 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -8,11 +8,14 @@
English | įŪä―äļæ
-[](https://github.com/DevXDojo/MrRSS/releases)
+> **æŽåæŊæåŧš macOS åŪĒæ·įŦŊã** įéĒäļš `frontend` äļįåį SwiftUI åšįĻïž
+> Go åįŦŊä―äļšįšŊ HTTP API æåĄčŋčĄãæŽåæŊäļå åŦ Vue åįŦŊäļ Wails åĪåĢģã
+
+[](https://github.com/DevXDojo/MrRSS/releases)
[](LICENSE)
[](https://go.dev/)
-[](https://wails.io/)
-[](https://vuejs.org/)
+[](https://swift.org/)
+[](https://www.apple.com/macos/)
## âĻ åč―įđæ§
@@ -36,22 +39,16 @@
@@ -170,11 +185,14 @@ sudo apt-get install libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc pkg-c
åŊåĻåļĶæįéč――įåšįĻïž
```bash
-# ä―ŋįĻ Wails v3
-wails3 dev
+# åæķåŊåĻåįŦŊäļåŪĒæ·įŦŊ
+make dev
+
+# äŧ åŊåĻåįŦŊïžåžåŊč°čŊæĨåŋïž
+make serve
-# æä―ŋįĻ Task
-task dev
+# äŧ åŊåĻåŪĒæ·įŦŊïžčŋæĨå·ēčŋčĄįåįŦŊ
+swift run --package-path frontend MrRSS
```
### äŧĢį čīĻéå·Ĩå ·
@@ -227,7 +245,7 @@ make test
docker run -p 1234:1234 mrrss-server:latest
# æäŧæšį æåŧš
-go build -tags server -o mrrss-server .
+go build -o mrrss-server .
./mrrss-server
```
diff --git a/Taskfile.yml b/Taskfile.yml
deleted file mode 100644
index a8557b0a4..000000000
--- 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 c91a15b01..5939ff797 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.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`, 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/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/dist/MrRSS--macos.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/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 32a8d94c1..000000000
--- 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 2b4ef5184..000000000
--- 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.27" # 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.27"
-# # 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 9f22e4ba7..000000000
--- 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 4ba250ef3..000000000
--- 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 272ecf113..000000000
--- 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 7a7dc573f..000000000
--- 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 74ad9698f..000000000
--- 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 e1ad1a216..000000000
--- 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 da7777c73..000000000
--- 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 11a1df693..000000000
--- 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 4c7e630df..000000000
--- 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 9822a655e..000000000
--- 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 bbc331be9..000000000
--- 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 9e77df629..000000000
--- 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 a2c7225da..000000000
--- 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 bbf0ba9e6..000000000
--- 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 ce2a09501..000000000
--- 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.27'
- 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 2ff5b784d..000000000
--- 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 0e33659a6..000000000
--- 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 0a39500b3..000000000
--- 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.27"
-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 f2f086e5a..000000000
--- 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 8668dcd66..000000000
Binary files a/build/windows/icon.ico and /dev/null differ
diff --git a/build/windows/icon.png b/build/windows/icon.png
deleted file mode 100644
index 3b680452a..000000000
Binary files a/build/windows/icon.png and /dev/null differ
diff --git a/build/windows/info.json b/build/windows/info.json
deleted file mode 100644
index 65db3c570..000000000
--- a/build/windows/info.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "fixed": {
- "file_version": "1.3.24"
- },
- "info": {
- "0000": {
- "ProductVersion": "1.3.24",
- "CompanyName": "Ch3nyang",
- "FileDescription": "MrRSS",
- "LegalCopyright": "Copyright ÂĐ 2026",
- "ProductName": "MrRSS",
- "Comments": "Built with Wails"
- }
- }
-}
diff --git a/build/windows/installer.nsi b/build/windows/installer.nsi
deleted file mode 100644
index 91ca4e9cf..000000000
--- a/build/windows/installer.nsi
+++ /dev/null
@@ -1,118 +0,0 @@
-; MrRSS NSIS Installer Script
-; This script creates a Windows installer for MrRSS
-;
-; IMPORTANT: This script creates a Windows installer for MrRSS
-;
-; To build:
-; makensis build/windows/installer.nsi
-;
-; All paths in this script are relative to the script directory.
-
-!define APP_NAME "MrRSS"
-!define APP_VERSION "1.3.24"
-!define APP_VERSION_NUMERIC "1.3.24.0" ; NSIS requires X.X.X.X format
-!define APP_PUBLISHER "Ch3nyang"
-!define APP_URL "https://github.com/DevXDojo/MrRSS"
-!define APP_DESCRIPTION "A Modern, Cross-Platform Desktop RSS Reader"
-!define APP_EXE "MrRSS.exe"
-
-; Include Modern UI
-!include "MUI2.nsh"
-
-; General Settings
-Name "${APP_NAME} ${APP_VERSION}"
-; Output path relative to script directory
-OutFile "..\bin\MrRSS-${APP_VERSION}-windows-amd64-installer.exe"
-InstallDir "$PROGRAMFILES64\${APP_NAME}"
-InstallDirRegKey HKLM "Software\${APP_NAME}" "Install_Dir"
-RequestExecutionLevel admin
-
-; MUI Settings
-!define MUI_ABORTWARNING
-; Use custom icons from build/windows/
-!define MUI_ICON "icon.ico"
-!define MUI_UNICON "icon.ico"
-
-; Welcome page
-!insertmacro MUI_PAGE_WELCOME
-
-; License page
-!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE"
-
-; Directory page
-!insertmacro MUI_PAGE_DIRECTORY
-
-; Instfiles page
-!insertmacro MUI_PAGE_INSTFILES
-
-; Finish page
-!define MUI_FINISHPAGE_RUN "$INSTDIR\${APP_EXE}"
-!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APP_NAME}"
-!insertmacro MUI_PAGE_FINISH
-
-; Uninstaller pages
-!insertmacro MUI_UNPAGE_CONFIRM
-!insertmacro MUI_UNPAGE_INSTFILES
-
-; Language
-!insertmacro MUI_LANGUAGE "English"
-
-; Version Information
-VIProductVersion "${APP_VERSION_NUMERIC}"
-VIAddVersionKey "ProductName" "${APP_NAME}"
-VIAddVersionKey "FileDescription" "${APP_DESCRIPTION}"
-VIAddVersionKey "FileVersion" "${APP_VERSION}"
-VIAddVersionKey "ProductVersion" "${APP_VERSION}"
-VIAddVersionKey "CompanyName" "${APP_PUBLISHER}"
-VIAddVersionKey "LegalCopyright" "Copyright (C) ${APP_PUBLISHER}"
-
-; Installer Sections
-Section "MainSection" SEC01
- SetOutPath "$INSTDIR"
-
- ; Copy the executable
- File "..\bin\${APP_EXE}"
-
- ; Create shortcuts
- CreateDirectory "$SMPROGRAMS\${APP_NAME}"
- CreateShortcut "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}"
- CreateShortcut "$SMPROGRAMS\${APP_NAME}\Uninstall.lnk" "$INSTDIR\Uninstall.exe"
- CreateShortcut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}"
-
- ; Write registry keys
- WriteRegStr HKLM "Software\${APP_NAME}" "Install_Dir" "$INSTDIR"
- WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "DisplayName" "${APP_NAME}"
- WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "DisplayVersion" "${APP_VERSION}"
- WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "Publisher" "${APP_PUBLISHER}"
- WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "URLInfoAbout" "${APP_URL}"
- WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "DisplayIcon" "$INSTDIR\${APP_EXE}"
- WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "UninstallString" "$INSTDIR\Uninstall.exe"
- WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "NoModify" 1
- WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}" "NoRepair" 1
-
- ; Create uninstaller
- WriteUninstaller "$INSTDIR\Uninstall.exe"
-SectionEnd
-
-; Uninstaller Section
-Section "Uninstall"
- ; Remove files
- Delete "$INSTDIR\${APP_EXE}"
- Delete "$INSTDIR\Uninstall.exe"
-
- ; Remove shortcuts
- Delete "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk"
- Delete "$SMPROGRAMS\${APP_NAME}\Uninstall.lnk"
- Delete "$DESKTOP\${APP_NAME}.lnk"
- RMDir "$SMPROGRAMS\${APP_NAME}"
-
- ; Remove installation directory
- RMDir "$INSTDIR"
-
- ; Remove registry keys
- DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}"
- DeleteRegKey HKLM "Software\${APP_NAME}"
-
- ; Note: User data directory is NOT removed to preserve user data
- ; Data location: %APPDATA%\MrRSS
-SectionEnd
diff --git a/build/windows/nsis/wails_tools.nsh b/build/windows/nsis/wails_tools.nsh
deleted file mode 100644
index 55998fe87..000000000
--- a/build/windows/nsis/wails_tools.nsh
+++ /dev/null
@@ -1,240 +0,0 @@
-# DO NOT EDIT - Generated automatically by `wails build`
-
-!include "x64.nsh"
-!include "WinVer.nsh"
-!include "FileFunc.nsh"
-
-!ifndef INFO_PROJECTNAME
- !define INFO_PROJECTNAME "MrRSS"
-!endif
-!ifndef INFO_COMPANYNAME
- !define INFO_COMPANYNAME "Ch3nyang"
-!endif
-!ifndef INFO_PRODUCTNAME
- !define INFO_PRODUCTNAME "MrRSS"
-!endif
-!ifndef INFO_PRODUCTVERSION
- !define INFO_PRODUCTVERSION "1.3.24"
-!endif
-!ifndef INFO_COPYRIGHT
- !define INFO_COPYRIGHT "Copyright ÂĐ 2026"
-!endif
-!ifndef PRODUCT_EXECUTABLE
- !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
-!endif
-!ifndef UNINST_KEY_NAME
- !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
-!endif
-!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
-
-!ifndef REQUEST_EXECUTION_LEVEL
- !define REQUEST_EXECUTION_LEVEL "admin"
-!endif
-
-RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
-
-!ifndef ARG_WAILS_AMD64_BINARY
- !define ARG_WAILS_AMD64_BINARY "MrRSS.exe"
-!endif
-
-!ifdef ARG_WAILS_AMD64_BINARY
- !define SUPPORTS_AMD64
-!endif
-
-!ifdef ARG_WAILS_ARM64_BINARY
- !define SUPPORTS_ARM64
-!endif
-
-!ifdef SUPPORTS_AMD64
- !ifdef SUPPORTS_ARM64
- !define ARCH "amd64_arm64"
- !else
- !define ARCH "amd64"
- !endif
-!else
- !ifdef SUPPORTS_ARM64
- !define ARCH "arm64"
- !else
- !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
- !endif
-!endif
-
-!macro wails.checkArchitecture
- !ifndef WAILS_WIN10_REQUIRED
- !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
- !endif
-
- !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
- !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
- !endif
-
- ${If} ${AtLeastWin10}
- !ifdef SUPPORTS_AMD64
- ${if} ${IsNativeAMD64}
- Goto ok
- ${EndIf}
- !endif
-
- !ifdef SUPPORTS_ARM64
- ${if} ${IsNativeARM64}
- Goto ok
- ${EndIf}
- !endif
-
- IfSilent silentArch notSilentArch
- silentArch:
- SetErrorLevel 65
- Abort
- notSilentArch:
- MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
- Quit
- ${else}
- IfSilent silentWin notSilentWin
- silentWin:
- SetErrorLevel 64
- Abort
- notSilentWin:
- MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
- Quit
- ${EndIf}
-
- ok:
-!macroend
-
-!macro wails.files
- !ifdef SUPPORTS_AMD64
- ${if} ${IsNativeAMD64}
- File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
- ${EndIf}
- !endif
-
- !ifdef SUPPORTS_ARM64
- ${if} ${IsNativeARM64}
- File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
- ${EndIf}
- !endif
-!macroend
-
-!macro wails.writeUninstaller
- WriteUninstaller "$INSTDIR\uninstall.exe"
-
- SetRegView 64
- WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
- WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
- WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
- WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
- WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
- WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
-
- ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
- IntFmt $0 "0x%08X" $0
- WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
-!macroend
-
-!macro wails.deleteUninstaller
- Delete "$INSTDIR\uninstall.exe"
-
- SetRegView 64
- DeleteRegKey HKLM "${UNINST_KEY}"
-!macroend
-
-!macro wails.setShellContext
- ${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
- SetShellVarContext all
- ${else}
- SetShellVarContext current
- ${EndIf}
-!macroend
-
-# Install webview2 by launching the bootstrapper
-# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
-!macro wails.webview2runtime
- !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
- !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
- !endif
-
- SetRegView 64
- # If the admin key exists and is not empty then webview2 is already installed
- ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
- ${If} $0 != ""
- Goto ok
- ${EndIf}
-
- ${If} ${REQUEST_EXECUTION_LEVEL} == "user"
- # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
- ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
- ${If} $0 != ""
- Goto ok
- ${EndIf}
- ${EndIf}
-
- SetDetailsPrint both
- DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
- SetDetailsPrint listonly
-
- InitPluginsDir
- CreateDirectory "$pluginsdir\webview2bootstrapper"
- SetOutPath "$pluginsdir\webview2bootstrapper"
- File "MicrosoftEdgeWebview2Setup.exe"
- ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
-
- SetDetailsPrint both
- ok:
-!macroend
-
-# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
-!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
- ; Backup the previously associated file class
- ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
- WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
-
- WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
-
- WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
- WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
- WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
- WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
- WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
-!macroend
-
-!macro APP_UNASSOCIATE EXT FILECLASS
- ; Backup the previously associated file class
- ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
- WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
-
- DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
-!macroend
-
-!macro wails.associateFiles
- ; Create file associations
-
-!macroend
-
-!macro wails.unassociateFiles
- ; Delete app associations
-
-!macroend
-
-!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
- DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
-!macroend
-
-!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
- DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
-!macroend
-
-!macro wails.associateCustomProtocols
- ; Create custom protocols associations
-
-!macroend
-
-!macro wails.unassociateCustomProtocols
- ; Delete app custom protocol associations
-
-!macroend
diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest
deleted file mode 100644
index a58ecba46..000000000
--- a/build/windows/wails.exe.manifest
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- true/pm
- permonitorv2,permonitor
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 2fef9d979..23759d0d7 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -15,10 +15,10 @@
MrRSS is built with a modern, modular architecture using:
-- **Backend**: Go 1.27+ with Wails v3 (beta) framework
-- **Frontend**: Vue 3.5+ Composition API with TypeScript
+- **Backend**: Go 1.27+ serving an HTTP API
+- **Client**: SwiftUI for macOS 14+, a SwiftPM package
- **Database**: SQLite with pure Go implementation (`modernc.org/sqlite`)
-- **Communication**: HTTP REST API (not Wails bindings)
+- **Communication**: the HTTP REST API alone
### Key Design Principles
@@ -26,7 +26,7 @@ MrRSS is built with a modern, modular architecture using:
2. **Performance-Optimized**: Concurrent processing, intelligent caching, WAL mode SQLite
3. **Modular Architecture**: Feature-based organization, clear separation of concerns
4. **Schema-Driven Configuration**: JSON schema-driven settings system with code generation
-5. **Hybrid Communication**: HTTP API for data, Wails bindings for system integration
+5. **One Channel**: everything goes over the HTTP API; system integration is native
## Backend Architecture
@@ -142,122 +142,84 @@ handlers/
- `ai.go` - AI-based translation integration
- `dynamic.go` - Dynamic translation service selection
-## Frontend Architecture
+## Client Architecture
-### Component Organization
+The macOS client lives in `frontend` and is a SwiftPM package with no
+third-party dependencies.
-Components are organized by feature in `frontend/src/components/`:
+### Organisation
```plaintext
-components/
-âââ article/ # Article display and rendering
-â âââ ArticleList.vue
-â âââ ArticleItem.vue
-â âââ ArticleDetail.vue
-â âââ ArticleContent.vue
-â âââ ArticleDetailToolbar.vue
-â âââ ArticleToolbar.vue
-â âââ parts/ # Content rendering parts
-â âââ ArticleTitle.vue
-â âââ ArticleSummary.vue
-â âââ ArticleBody.vue
-â âââ ArticleLoading.vue
-â âââ AudioPlayer.vue
-â âââ VideoPlayer.vue
-âââ sidebar/ # Feed list sidebar
-â âââ Sidebar.vue
-â âââ SidebarFeed.vue
-â âââ SidebarCategory.vue
-â âââ SidebarNavItem.vue
-âââ common/ # Reusable components
-â âââ Toast.vue
-â âââ ContextMenu.vue
-â âââ ImageViewer.vue
-âââ modals/ # Modal dialogs
- âââ SettingsModal.vue
- âââ settings/ # Settings tabs
- âââ feed/ # Feed modals
- âââ filter/ # Filter modals
- âââ rules/ # Rules editor
- âââ discovery/ # Discovery modal
- âââ common/ # Common modals
+frontend/Sources/
+âââ MrRSSApp.swift # Scene, menu commands, settings window
+âââ Localization/
+â âââ LocalizationTables.swift # The catalogue ported from the previous frontend
+â âââ ClientStrings.swift # Wording only this client needs
+â âââ Localization.swift # Lookup, fallback, placeholder substitution
+âââ Models/ # Codable mirrors of the API payloads
+â âââ Feed.swift, Article.swift, Organization.swift, AI.swift, System.swift
+â âââ FilterFields.swift # The fields and operators saved filters can use
+â âââ SettingsCatalog*.swift # Generated from the backend schema
+âââ Services/
+â âââ API/ # Transport plus one extension per domain
+â âââ KeyboardShortcuts.swift # Bindings, read from the stored settings
+â âââ AppDelegate.swift # Starts the bundled backend when one is packaged
+âââ ViewModels/
+â âââ AppViewModel.swift # Connection, feeds, folders, selection, settings
+â âââ AppViewModel+Articles.swift # List state, article actions, AI search
+â âââ AppViewModel+Feeds.swift # Feed, tag, saved-filter and OPML actions
+â âââ AppViewModel+Shortcuts.swift # What each key press does
+âââ Views/
+ âââ Sidebar/ # An NSOutlineView, so dragging behaves natively
+ âââ ArticleListView.swift, ArticleRowView.swift
+ âââ ArticleDetailView.swift, WebView.swift, ImageGalleryView.swift
+ âââ FeedEditorView.swift, SavedFilterEditorView.swift, DiscoveryView.swift
+ âââ ArticleChatView.swift
+ âââ Settings/ # The panes that need more than a generated list
```
-### Composables Organization
+### The sidebar is an outline view
-Composables provide reusable logic in `frontend/src/composables/`:
+`List` cannot reproduce what source lists do while something is dragged over
+them: the gap that opens between rows, the way a long list follows the pointer
+past its edges, and the folder that lights up underneath. `SidebarOutline`
+wraps `NSOutlineView` so all of that comes for free.
-```plaintext
-composables/
-âââ article/ # Article-related logic
-â âââ useArticleDetail.ts
-â âââ useArticleList.ts
-â âââ useArticleContent.ts
-â âââ useArticleSummary.ts
-âââ feed/ # Feed management
-â âââ useFeedManagement.ts
-â âââ useFeedRefresh.ts
-âââ discovery/ # Feed discovery
-â âââ useFeedDiscovery.ts
-âââ filter/ # Article filtering
-â âââ useArticleFilter.ts
-âââ rules/ # Filtering rules
-â âââ useRules.ts
-âââ ui/ # UI utilities
-â âââ useContextMenu.ts
-â âââ useKeyboardShortcuts.ts
-â âââ useToast.ts
-âââ core/ # Core utilities
- âââ useSettings.ts
-```
-
-### State Management
-
-Pinia store (`frontend/src/stores/app.ts`) manages global state:
-
-- Articles list and selection
-- Feeds and categories
-- Filter states
-- Theme and locale
-- Refresh progress
-- Unread counts
-
-### Multimedia Support
+### Reading
-Enhanced content rendering (`ArticleContent.vue` + `ArticleContent.css`):
+`WebView` renders either the article text the backend supplied or the original
+page, loaded live. Rendered text is stripped of scripts, frames and inline
+handlers, and is typeset from the reading settings; a live page keeps its own
+scripts because it needs them to display.
-- **Images**: Clickable for viewer, right-click context menu, download support
-- **Audio**: Full-width player with podcast container styling (`AudioPlayer.vue`)
-- **Video**: Responsive player with proper aspect ratio (`VideoPlayer.vue`)
-- **Iframes**: 16:9 aspect ratio for YouTube/Vimeo embeds
-- **Rich Text**: Tables, blockquotes, code blocks, definition lists
+### Settings
-### Translation Integration
+`SettingsCatalog.generated.swift` is produced from
+`internal/config/settings_schema.json` by `tools/settings-swift/generate.py`,
+and paired with the wording the previous frontend used. Panes that need more
+than a list of controls add their own section.
-Auto-translation features:
+### Translations
-- Title translation (on-demand)
-- Content paragraph translation (inline display)
-- Summary translation
-- Supports Google Translate, DeepL, Baidu Translation, and AI-based translation
+Every interface string is looked up by key through `t(_:)`. The language
+follows the `language` setting the backend stores, so a reader who switches it
+on one machine sees the same language on another. Dates are formatted with the
+matching locale.
## Communication Flow
### HTTP API Pattern
-Frontend uses direct HTTP fetch (not Wails bindings):
+The client talks to the backend through `APIService`:
-```javascript
+```swift
// GET request
-const response = await fetch('/api/articles');
-const articles = await response.json();
+let articles: [Article] = try await get("articles", queryItems: [
+ URLQueryItem(name: "filter", value: "unread")
+])
// POST request
-await fetch('/api/settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
-});
+try await sendJSONReturningData("settings", body: settings)
```
### Backend Handler Pattern
@@ -350,12 +312,12 @@ func (h *Handler) GetArticles(w http.ResponseWriter, r *http.Request) {
- Prepared statement caching
- Periodic VACUUM for space reclamation
-### Frontend
+### Client
-- Virtual scrolling for large article lists
-- Debounced operations (search, auto-save)
-- Lazy loading of article content
-- Efficient state updates with Pinia
+- `LazyVStack` and list reuse for long article lists
+- Paged loading, with the next page requested as the last row appears
+- Thumbnails decoded at the size they are drawn and cached in memory
+- Ordering applied on this Mac, so switching it does not refetch
### Concurrency
@@ -544,59 +506,30 @@ CREATE INDEX idx_articles_hidden ON articles(is_hidden);
- **Age-Based Cleanup**: Remove articles older than X days
- **Automatic VACUUM**: Reclaim disk space
-## Frontend Architecture Details
+## Client Details
-### Component Communication Patterns
+### How state reaches the views
-#### Props vs Events
+`AppViewModel` is the single observable object. Views bind to it directly, and
+the feature extensions beside it hold the behaviour, so no view owns state that
+another view needs.
-**Best Practice**: Use props for data down, events for actions up
-
-```vue
-
-
-
-
-
-```
-
-#### Store Communication
-
-**For Cross-Component State**: Use Pinia store
-
-```typescript
-// Access store
-const store = useAppStore()
-
-// Read state
-const articles = computed(() => store.articles)
-
-// Update state
-store.loadArticles()
-```
+For state that belongs to one screen â a sheet's draft, a search field, an
+expanded disclosure â use `@State` in that view. Anything the rest of the
+interface reacts to belongs on the view model.
### Multimedia Support
#### Image Handling
-- **Lazy Loading**: Images load as needed
-- **Click to View**: Opens in image viewer
-- **Context Menu**: Right-click for options
-- **Download Support**: Save images locally
-- **Proxy Caching**: Cached media proxy
+Images in rendered articles are drawn by the web view, which keeps the original
+layout. Every image the backend can extract is also available in a gallery
+sheet, with a larger preview and links to open or copy the original.
-#### Audio/Video Support
+#### Audio and Video
-- **HTML5 Players**: Native browser support
-- **Responsive Sizing**: Adapts to content
-- **Custom Styling**: Branded player appearance
-- **Keyboard Controls**: Space to play/pause
+An article carrying audio or video shows a row above the body offering it in the
+system player, so playback uses the machine's own controls and output device.
#### Math Rendering
diff --git a/docs/BUILD_REQUIREMENTS.md b/docs/BUILD_REQUIREMENTS.md
index 958a56679..e1a729481 100644
--- a/docs/BUILD_REQUIREMENTS.md
+++ b/docs/BUILD_REQUIREMENTS.md
@@ -1,289 +1,118 @@
# Build Requirements
-This document describes the system-level dependencies required for building MrRSS on different platforms.
+This branch builds the macOS client and the Go backend behind it. There is no
+web frontend and no desktop shell to compile.
-## Overview
+## Requirements
-MrRSS uses Wails v3 (alpha) framework which requires CGO (C bindings for Go):
+- **macOS 14** or later
+- **Xcode 15** or later, for the Swift toolchain
+- **Go 1.27** or later
-- **Wails v3**: For the desktop application framework with built-in system tray
-- **SQLite**: Pure Go implementation (`modernc.org/sqlite`), no C dependencies
-
-## Important: CGO Requirement
-
-â ïļ **CRITICAL**: Wails v3 requires CGO to be enabled. You must set:
-
-```bash
-export CGO_ENABLED=1
-```
-
-Or when building:
+Check what you have:
```bash
-CGO_ENABLED=1 wails3 build
+sw_vers -productVersion
+swift --version
+go version
```
-## Platform-Specific Requirements
-
-### Linux
-
-#### Development Dependencies
+If `swift` is missing, install the command line tools:
```bash
-sudo apt-get update
-sudo apt-get install -y \
- gcc \
- pkg-config \
- libgtk-4-dev \
- libwebkitgtk-6.0-dev \
- libsoup-3.0-dev
-```
-
-**Dependency Breakdown**:
-
-- `gcc`: C compiler (required for CGO)
-- `pkg-config`: Build tool for finding libraries
-- `libgtk-4-dev`: GTK4 development headers (for Wails UI)
-- `libwebkitgtk-6.0-dev`: WebKitGTK 6.0 development headers (for Wails webview, **required for current Wails v3**)
-- `libsoup-3.0-dev`: HTTP library 3.0 (required for Wails v3)
-
-**Important**: Current Wails v3 requires GTK4, WebKitGTK 6.0, and libsoup 3.0. Older WebKitGTK 4.x and GTK3 packages are not sufficient.
-
-**Note for Linux Mint**: Also install `libxapp-dev`
-
-#### Runtime Dependencies
-
-End users running the compiled binary will need:
-
-- `libgtk-4-1`
-- `libwebkitgtk-6.0-4`
-- `libsoup-3.0-0`
-
-### Windows
-
-#### Development Dependencies
-
-Install via Chocolatey:
-
-```powershell
-choco install mingw nsis -y
+xcode-select --install
```
-**Dependency Breakdown**:
-
-- `mingw`: MinGW-w64 GCC compiler (required for CGO)
-- `nsis`: Nullsoft Scriptable Install System (for creating installers)
+## CGO
-#### Alternative: Manual Installation
+The backend uses `modernc.org/sqlite`, a pure Go implementation, so CGO is not
+required. The packaging script builds the backend with `CGO_ENABLED=0` for both
+architectures and merges them with `lipo`.
-If not using Chocolatey:
+Running the backend tests does use CGO in continuous integration, which is why
+the workflow sets `CGO_ENABLED=1` there.
-1. Install [MinGW-w64](https://www.mingw-w64.org/)
-2. Install [NSIS](https://nsis.sourceforge.io/) (optional, for installers)
-3. Add MinGW `bin` directory to PATH
+## Building
-#### Build Flags
-
-To avoid opening a console at application startup:
+### The client alone
```bash
-go build -ldflags "-H=windowsgui"
+swift build --package-path frontend
+swift test --package-path frontend
```
-Or with Wails:
+### The backend alone
```bash
-wails3 build -ldflags "-H=windowsgui"
+go build -o bin/mrrss-server .
+go test ./internal/...
```
-#### Runtime Dependencies
-
-Windows binaries are statically linked and don't require additional runtime dependencies.
-
-### macOS
-
-#### Development Dependencies
-
-Install Xcode Command Line Tools (if not already installed):
+### Both, and the application bundle
```bash
-xcode-select --install
+make build # backend binary and client executable
+make build-app VERSION=1.3.28 # signed .app bundle and universal DMG
```
-**Note**: macOS has native support for systray through AppKit, so no additional libraries are needed.
-
-#### Application Bundle
-
-macOS requires an application bundle structure:
-
-```plaintext
-MrRSS.app/
- Contents/
- Info.plist
- MacOS/
- MrRSS
- Resources/
- MrRSS.icns
-```
-
-Wails automatically creates this structure during build.
-
-#### Info.plist Settings
-
-Add these keys for better macOS integration:
-
-```xml
-
-NSHighResolutionCapable
-True
-
-
-LSUIElement
-1
-```
+The packaging script builds a universal SwiftUI executable, builds the backend
+for `arm64` and `x86_64`, merges them, copies the icon and `Info.plist`, signs
+the bundle and produces
+`frontend/dist/MrRSS--macos.dmg`.
-#### Runtime Dependencies
-
-macOS binaries are self-contained and don't require additional runtime dependencies.
-
-## Building with Wails
-
-### Standard Build
+To build for one architecture while developing:
```bash
-# Development build with hot reload
-wails3 dev
-
-# Production build (recommended: use Task)
-task build
-
-# Or directly with wails3
-wails3 build
-
-# Platform-specific build with Task
-task linux:build
-task windows:build
-task darwin:build
+MRRSS_BUILD_ARCHS=arm64 ./frontend/build-app.sh dev
```
-### Build Configuration
-
-Wails v3 uses `build/config.yml` for build configuration and Taskfile for platform-specific builds:
-
-- **Frontend**: Automatically built via `frontend/package.json` scripts
-- **Backend**: CGO-enabled Go build with platform-specific flags
-- **Installers**: Created via platform-specific scripts (NSIS, create-dmg.sh, create-appimage.sh)
-
-### Cross-Compilation
-
-**Note**: Cross-compilation with CGO is complex. For best results:
-
-- Build Linux binaries on Linux
-- Build Windows binaries on Windows
-- Build macOS binaries on macOS
-
-GitHub Actions handles this automatically using platform-specific runners.
-
-## GitHub Actions
-
-Our CI/CD pipeline automatically installs all required dependencies:
-
-### Test Workflow
-
-- Installs Linux dependencies for backend tests
-- Sets `CGO_ENABLED=1`
-
-### Release Workflow
-
-- Platform-specific dependency installation
-- Cross-platform builds using native runners
-- Artifact creation (installers, AppImages, DMGs)
-
-## Troubleshooting
-
-### "CGO is disabled" Error
-
-**Solution**: Enable CGO before building:
+## Running during development
```bash
-export CGO_ENABLED=1
-wails3 build
+./frontend/run.sh
```
-### Linux: "Package webkitgtk-6.0 was not found"
+The launcher builds the backend, starts it on `http://127.0.0.1:1234`, waits for
+the API to answer and then starts the client. An existing server on that address
+is reused.
-**Solution**: Install WebKitGTK 6.0 development headers:
+To run the halves separately:
```bash
-sudo apt-get install libwebkitgtk-6.0-dev
+go run . -host 127.0.0.1 -port 1234
+MRRSS_API_BASE_URL=http://127.0.0.1:1234/api swift run --package-path frontend MrRSS
```
-### Linux: "Package ayatana-appindicator3-0.1 was not found"
-
-This error is from older versions. Wails v3 uses its own system tray implementation.
-
-### Linux: "Package libsoup-3.0 was not found"
+## The server on other platforms
-**Solution**: Install libsoup3 development headers:
+The backend itself is portable. Building it for Linux needs nothing beyond Go:
```bash
-sudo apt-get install libsoup-3.0-dev
-```
-
-### Windows: "gcc: command not found"
-
-**Solution**: Install MinGW:
-
-```powershell
-choco install mingw -y
+GOOS=linux GOARCH=amd64 go build -o mrrss-server .
```
-Or download from [mingw-w64.org](https://www.mingw-w64.org/) and add to PATH.
-
-### macOS: Missing Xcode Command Line Tools
-
-**Solution**: Install Xcode Command Line Tools:
+Or use the Docker image:
```bash
-xcode-select --install
+docker build -f Dockerfile.server -t mrrss-server:latest .
```
-## Development Environment Setup
+## Troubleshooting
-### Quick Setup Scripts
+**`swift build` cannot find the toolchain**
-**Linux/macOS**:
+Point `xcode-select` at a full Xcode installation:
```bash
-# Install Go dependencies
-go mod download
-
-# Install frontend dependencies
-cd frontend
-npm install
-cd ..
-
-# Run development server
-wails3 dev
+sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
```
-**Windows (PowerShell)**:
-
-```powershell
-# Install Go dependencies
-go mod download
+**The build stalls while compiling a view**
-# Install frontend dependencies
-cd frontend
-npm install
-cd ..
-
-# Run development server
-wails3 dev
-```
+SwiftUI bodies are type-checked as a whole, and a long expression can take a very
+long time. Split the body into smaller computed properties.
-## Related Documentation
+**`codesign` fails during packaging**
-- [Architecture Overview](ARCHITECTURE.md)
-- [Code Patterns](CODE_PATTERNS.md)
-- [Testing Guide](TESTING.md)
+The script signs ad hoc, which needs no certificate. If signing still fails,
+check that the bundle is not on a volume that strips extended attributes.
diff --git a/docs/CODE_PATTERNS.md b/docs/CODE_PATTERNS.md
index 3468f0e1a..eaf896590 100644
--- a/docs/CODE_PATTERNS.md
+++ b/docs/CODE_PATTERNS.md
@@ -7,7 +7,7 @@ This document provides common coding patterns and best practices for the MrRSS p
- [Code Organization Guidelines](#code-organization-guidelines)
- [Settings Management](#settings-management)
- [Backend Patterns (Go)](#backend-patterns-go)
-- [Frontend Patterns (Vue)](#frontend-patterns-vue)
+- [Client Patterns (SwiftUI)](#client-patterns-swiftui)
- [Styling Patterns](#styling-patterns)
- [API Communication](#api-communication)
@@ -18,7 +18,7 @@ This document provides common coding patterns and best practices for the MrRSS p
When a file becomes too long (typically over 300-400 lines), consider refactoring:
- **Go**: Extract related functions into separate files within the same package
-- **Vue**: Split into smaller components or extract logic into composables
+- **SwiftUI**: Split the body into computed properties, or move behaviour onto the view model
- **TypeScript**: Extract utilities into separate modules
### Folder Organization
@@ -31,14 +31,14 @@ When a folder contains too many files (typically over 10-15 files), create subfo
### Build Verification
-Before completing any significant change, verify the build:
+Before completing any significant change, verify both halves build and their
+tests pass:
```bash
-wails3 build
+make build
+make test
```
-This ensures the application can be properly packaged and distributed.
-
## Settings Management
**â UPDATED**: The settings system has been optimized with schema-driven code generation!
@@ -101,14 +101,13 @@ See **[docs/SETTINGS.md](SETTINGS.md)** for:
After running the generator, verify:
```bash
-# Build backend
-go build
-
-# Build frontend
-cd frontend && npm run build
-
-# Run tests
+# Backend
+go build ./...
go test ./internal/config
+
+# Client, after running python3 tools/settings-swift/generate.py
+swift build --package-path frontend
+swift test --package-path frontend --filter SettingsCatalogTests
```
### Legacy Method (Deprecated)
@@ -488,593 +487,150 @@ func (h *Handler) HandleGetArticles(w http.ResponseWriter, r *http.Request) {
- Log errors (don't expose to client)
- Use `http.Error` for error responses
-## Frontend Patterns (Vue)
-
-### Vue Component Structure
-
-#### Basic Component Pattern
-
-```vue
-
-
-
-
-
-
- {{ t('loading') }}
-
-
-
-
- {{ t('noItems') }}
-
-
-
-
-
- {{ item.title }}
-
-
-
-
-
-
-```
-
-**Key Points**:
-
-- Use `
-```
-
-**Key Points**:
-
-- 500ms debounce delay
-- Deep watch for nested objects
-- Clear timeout on unmount to prevent memory leaks
-- Apply settings immediately for better UX
-
-### Settings Component Pattern
-
-**â ïļ CRITICAL PATTERN**: When creating settings components that receive props and emit updates, follow this pattern to avoid reactivity issues.
-
-#### â **WRONG Pattern** (DO NOT USE)
-
-```vue
-
-
-
-
-
-
-
-
-
-
-
-```
-
-**Problems with this approach**:
-
-1. `localSettings` is a shallow copy that doesn't sync when `props.settings` changes
-2. User modifies localSettings â emits to parent â parent updates â **but localSettings doesn't update**
-3. v-if conditions checking different data source than v-model causes UI inconsistencies
-4. Closing and reopening settings shows stale values
-
-#### â **CORRECT Pattern** (USE THIS)
-
-```vue
-
-
-
-
-
-
-
- emit('update:settings', {
- ...props.settings,
- some_enabled: (e.target as HTMLInputElement).checked,
- })
- "
- />
-
-
-
- emit('update:settings', {
- ...props.settings,
- some_field: (e.target as HTMLInputElement).value,
- })
- "
- />
-
-
-
- emit('update:settings', {
- ...props.settings,
- some_number: parseInt((e.target as HTMLInputElement).value) || 0,
- })
- "
- />
-
-
-
-
-
-
```
-**Benefits of this approach**:
-
-1. â Single source of truth (`props.settings`)
-2. â Real-time reactivity - changes immediately reflected
-3. â v-if conditions and bindings use same data source
-4. â No synchronization issues
-5. â Settings persist correctly when closing and reopening
-
-**Reference Components**:
-
-- â `DatabaseSettings.vue` - Correct pattern
-- â `AppearanceSettings.vue` - Correct pattern
-- â `TranslationSettings.vue` - Fixed (was broken)
-- â `UpdateSettings.vue` - Fixed (was broken)
-- â `SummarySettings.vue` - Fixed (was broken)
-- â `ProxySettings.vue` - Fixed (was broken)
-
-**Common Mistakes to Avoid**:
-
-- â Don't create `localSettings` ref as a copy of props
-- â Don't use `v-model` on props-based data (use `:value` + `@input`)
-- â Don't mix `v-if="props.settings.x"` with `v-model="localSettings.x"`
-- â Don't forget to spread `...props.settings` when emitting updates
-- â Don't use `watch()` to sync localSettings with props (just don't use localSettings at all)
+### Where state lives
-## Styling Patterns
+`AppViewModel` is the one observable object. State the rest of the interface
+reacts to belongs there; state a single screen owns belongs in `@State`.
-### Semantic Color Classes
+```swift
+// On the view model: the list and the reading pane both read this
+@Published var selectedArticleID: Int?
-Use these semantic class combinations for consistent theming:
-
-#### Buttons
-
-```html
-
-
-
-
-
-
-
-
+// In the view: only this sheet cares
+@State private var isConfirmingClearReadLater = false
```
-#### Form Elements
+The view model is split by feature across files:
-```html
-
-
+- `AppViewModel.swift` â connection, feeds, folders, selection, settings
+- `AppViewModel+Articles.swift` â list state, article actions, AI search
+- `AppViewModel+Feeds.swift` â feed, tag, saved-filter and OPML actions
+- `AppViewModel+Shortcuts.swift` â what each key press does
-
-
+Properties an extension writes to are declared without `private(set)`, because
+Swift scopes that to the file.
-
-
-```
-
-#### Cards and Containers
+### Optimistic updates
-```html
-
-
{{ t('title') }}
-
{{ t('description') }}
-
-```
+Apply the change locally, then roll it back if the request fails:
-### CSS Variables
+```swift
+private func mutateArticle(
+ _ id: Int,
+ apply change: (inout Article) -> Void,
+ request: @escaping () async throws -> Void
+) {
+ guard let index = articles.firstIndex(where: { $0.id == id }) else { return }
+ let previous = articles[index]
+ change(&articles[index])
-Theme-aware colors using CSS variables:
-
-```css
-:root {
- --color-bg-primary: #ffffff;
- --color-bg-secondary: #f8fafc;
- --color-text-primary: #1e293b;
- --color-text-secondary: #64748b;
- --color-border: #e2e8f0;
- --color-accent: #3b82f6;
-}
-
-.dark-mode {
- --color-bg-primary: #0f172a;
- --color-bg-secondary: #1e293b;
- --color-text-primary: #f1f5f9;
- --color-text-secondary: #94a3b8;
- --color-border: #334155;
- --color-accent: #60a5fa;
+ Task { [weak self] in
+ guard let self else { return }
+ do {
+ try await request()
+ } catch {
+ if let currentIndex = articles.firstIndex(where: { $0.id == id }) {
+ articles[currentIndex] = previous
+ }
+ errorMessage = error.localizedDescription
+ }
+ }
}
```
-### Component Styles
-
-#### Button Styles
-
-```css
-.btn-primary {
- @apply px-4 py-2 bg-accent text-white rounded-lg font-medium transition-colors;
-}
-
-.btn-primary:hover {
- @apply brightness-110;
-}
-
-.btn-primary:disabled {
- @apply opacity-50 cursor-not-allowed;
-}
-```
+### Cancelling superseded work
-#### Input Styles
+A request whose result is no longer wanted must not overwrite newer state. Each
+load carries an identifier that is checked before anything is assigned:
-```css
-.input-field {
- @apply w-full px-3 py-2 border border-border rounded-lg bg-bg-primary text-text-primary;
-}
+```swift
+articleTask?.cancel()
+articleRequestID = UUID()
+let requestID = articleRequestID
-.input-field:focus {
- @apply outline-none ring-2 ring-accent;
+articleTask = Task { [weak self] in
+ let loaded = try await load(query: query, page: page)
+ try Task.checkCancellation()
+ guard requestID == self?.articleRequestID else { return }
+ self?.articles = loaded
}
```
-### Multimedia Styling
+### Interface strings
-#### Images
-
-```css
-.prose :deep(img) {
- max-width: 100%;
- height: auto;
- border-radius: 0.5rem;
- margin: 1.5em 0;
- cursor: pointer;
- transition: opacity 0.2s;
-}
+Every string goes through `t(_:)`, never a literal:
-.prose :deep(img:hover) {
- opacity: 0.9;
-}
+```swift
+Label(t("article.action.markAllRead"), systemImage: "checkmark.circle")
+Text(t("article.action.markedNArticlesAsRead", ["count": affected]))
```
-#### Audio Players
+Keys ported from the previous frontend live in `LocalizationTables.swift`;
+wording only this client needs lives in `ClientStrings.swift` under a `client.`
+prefix. The language follows the `language` setting the backend stores.
-```css
-.prose :deep(audio) {
- width: 100%;
- margin: 1.5em 0;
- border-radius: 0.75rem;
- background-color: var(--bg-secondary);
- border: 1px solid var(--border-color);
-}
-```
+### Decoding
-#### Video Players
+Every model decodes leniently, because an older backend omits fields a newer one
+sends:
-```css
-.prose :deep(video) {
- width: 100%;
- height: auto;
- margin: 1.5em 0;
- border-radius: 0.75rem;
- background-color: #000;
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+```swift
+init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(Int.self, forKey: .id)
+ title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
+ isRead = try container.decodeIfPresent(Bool.self, forKey: .isRead) ?? false
}
```
-#### Embedded Content (iframes)
+Several endpoints answer `null` instead of `[]`, which `APIService.decode`
+turns into an empty collection.
-```css
-.prose :deep(iframe) {
- width: 100%;
- aspect-ratio: 16 / 9;
- margin: 1.5em 0;
- border-radius: 0.75rem;
- border: none;
-}
-```
+### When AppKit is the right answer
-### Dark Mode Support
+Reach for AppKit where SwiftUI cannot reproduce platform behaviour:
-Use `:global(.dark-mode)` for dark mode styles:
+- **The sidebar** is an `NSOutlineView`, because `List` cannot show the gap that
+ opens between rows during a drag, the edge scrolling, or the folder lighting
+ up underneath.
+- **Article content** is a `WKWebView`, so the original markup renders.
+- **File import and export** use `NSOpenPanel` and `NSSavePanel`.
+- **Links** open through `NSWorkspace`.
-```vue
-
-```
-
-### Responsive Design
-
-Use Tailwind responsive prefixes:
-
-```html
-
-
{{ t('title') }}
-
-```
+Wrap these in `NSViewRepresentable` and keep the SwiftUI surface small.
## API Communication
@@ -1082,83 +638,72 @@ Use Tailwind responsive prefixes:
MrRSS uses direct HTTP fetch (not Wails bindings) for better control.
-#### GET Request
-
-```javascript
-// Simple GET
-const response = await fetch('/api/articles');
-const articles = await response.json();
-
-// GET with query parameters
-const params = new URLSearchParams({
- feed_id: '123',
- is_read: 'false',
- limit: '50'
-});
-const response = await fetch(`/api/articles?${params}`);
-const articles = await response.json();
-```
-
-#### POST Request
+#### GET request
-```javascript
-// POST with JSON body
-await fetch('/api/settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(settingsObject)
-});
+```swift
+// Simple
+let feeds: [Feed] = try await get("feeds")
-// POST without body
-await fetch(`/api/articles/${id}/read`, {
- method: 'POST'
-});
+// With query parameters
+let articles: [Article] = try await get("articles", queryItems: [
+ URLQueryItem(name: "feed_id", value: "123"),
+ URLQueryItem(name: "filter", value: "unread"),
+ URLQueryItem(name: "limit", value: "50")
+])
```
-#### Error Handling
+#### POST request
-```javascript
-try {
- const response = await fetch('/api/feeds');
+```swift
+// With a JSON body built from a dictionary
+try await post("feeds/add", jsonBody: draft.jsonBody)
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
+// With an Encodable body and a decoded response
+let result: SummaryResult = try await postJSON(
+ "articles/summarize",
+ body: Request(articleID: id, length: "medium", content: nil)
+)
- const feeds = await response.json();
- // Process feeds...
+// With query parameters only
+try await post("articles/favorite", queryItems: [
+ URLQueryItem(name: "id", value: String(id))
+])
+```
-} catch (error) {
- console.error('API call failed:', error);
- window.showToast(t('apiError'), 'error');
+#### Error handling
+
+`APIService.data(for:)` turns a non-2xx response into `APIError.server`,
+preferring the `error` field of a JSON body over its raw text. Callers surface
+the message and, where the change was optimistic, roll it back:
+
+```swift
+do {
+ try await api.refreshFeed(id: feed.id)
+ statusMessage = t("modal.feed.feedRefreshStarted")
+} catch {
+ errorMessage = error.localizedDescription
}
```
### Progress Tracking
-For long-running operations (e.g., feed refresh):
-
-```javascript
-// Start operation
-await fetch('/api/refresh', { method: 'POST' });
+Long-running operations report progress by polling, so a run started elsewhere
+is still followed:
-// Poll for progress
-const pollInterval = setInterval(async () => {
- const response = await fetch('/api/progress');
- const data = await response.json();
+```swift
+try await api.refreshAllFeeds()
- // Update progress
- progress.value = Math.round((data.current / data.total) * 100);
-
- // Check if complete
- if (!data.is_running) {
- clearInterval(pollInterval);
- // Refresh UI data
- await loadArticles();
- }
-}, 500); // Poll every 500ms
+for _ in 0..<180 {
+ try Task.checkCancellation()
+ let progress = try await api.fetchRefreshProgress()
+ refreshProgress = progress
+ if !progress.isRunning { break }
+ try await Task.sleep(for: .seconds(1))
+}
```
+Discovery works the same way, through `fetchDiscoveryProgress()`.
+
### Backend HTTP Handlers
Standard pattern for HTTP handlers:
diff --git a/docs/SERVER_MODE/swagger.json b/docs/SERVER_MODE/swagger.json
index d7bbfd6a0..7a8de4e64 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/docs/SETTINGS.md b/docs/SETTINGS.md
index e0a0bc355..9da8289f0 100644
--- a/docs/SETTINGS.md
+++ b/docs/SETTINGS.md
@@ -34,19 +34,16 @@ Edit `internal/config/settings_schema.json`:
go run tools/settings-generator/main.go
```
-### 3. Add UI (Optional)
+### 3. Generate the client catalogue
-Add to your settings component:
-
-```vue
-
-
-
+```bash
+python3 tools/settings-swift/generate.py
```
+The settings window builds itself from the catalogue, so the new setting appears
+without any view being edited. Add its wording to the translation catalogue so
+it reads properly.
+
That's it! See [Complete Example](#complete-example) for a detailed walkthrough.
---
@@ -80,26 +77,19 @@ After running the generator, these files are **automatically created/updated**:
- â `internal/config/config.go` - Go struct and `GetString()` function
- â `internal/config/settings_keys.go` - Settings keys array for DB init
- â `internal/handlers/settings/settings_handlers.go` - GET/POST API handlers
-- â `frontend/src/types/settings.generated.ts` - TypeScript interface (snake_case)
-- â `frontend/src/composables/core/useSettings.generated.ts` - Helper functions
-- â `config/defaults.json` - Frontend defaults (snake_case)
+- â `config/defaults.json` - Shipped defaults (snake_case)
- â `internal/config/defaults.json` - Backend defaults (snake_case)
**Important:** All generated files are sorted alphabetically to minimize diff changes when adding new settings.
### Naming Convention
-**Frontend uses snake_case everywhere** (NOT camelCase):
+**Settings are addressed by their snake_case schema key everywhere**:
-- â `settings.ai_api_key` (correct)
-- â `settings.aiAPIKey` (incorrect)
+- â `viewModel.setting("ai_api_key")` (correct)
+- â `viewModel.setting("aiAPIKey")` (incorrect)
-This convention is used consistently across:
-
-- TypeScript interfaces (`SettingsData`)
-- Vue components
-- API communication
-- Event names
+This holds across the Go configuration, the API payloads and the macOS client.
---
@@ -147,138 +137,94 @@ go run tools/settings-generator/main.go
â Generated internal/config/config.go
â Generated internal/config/settings_keys.go
â Generated internal/handlers/settings/settings_handlers.go
-â Generated frontend/src/types/settings.generated.ts
-â Generated frontend/src/composables/core/useSettings.generated.ts
âĻ All files generated successfully!
```
-This automatically generates all the boilerplate code for both backend and frontend.
-
-### Step 3: Add Translations (Recommended)
+Then generate the client's settings catalogue:
-#### English (`frontend/src/i18n/locales/en.ts`)
-
-Find the appropriate section and add:
-
-```typescript
-yourNewSetting: 'Your New Setting',
-yourNewSettingDesc: 'Description of what this setting does',
+```bash
+python3 tools/settings-swift/generate.py
```
-#### Chinese (`frontend/src/i18n/locales/zh.ts`)
+**Output:**
-```typescript
-yourNewSetting: 'æĻįæ°čŪūį―Ū',
-yourNewSettingDesc: 'æĪčŪūį―Ūåč―įæčŋ°',
+```plaintext
+wrote frontend/Sources/Models/SettingsCatalog.generated.swift with 99 settings
```
-### Step 4: Add UI (Optional)
+The catalogue carries the key, the pane, the control, the default and the
+translation keys, so the settings window picks the new setting up on its own.
-Add the setting UI to the appropriate settings component.
+### Step 3: Add Translations (Recommended)
-**Example** - `frontend/src/components/modals/settings/general/GeneralSettings.vue`:
+The generator looks for wording in the client's catalogue,
+`frontend/Sources/Localization/LocalizationTables.swift`, using the same
+keys the previous frontend used. Add the label and, optionally, a description:
-```vue
-
-
-
+```json
+"setting.general.yourNewSetting": "Your New Setting",
+"setting.general.yourNewSettingDesc": "What this setting does"
```
-**UI Component Examples:**
-
-```vue
-
-
-
-
-
-
-
-
-
-
-
-```
+Add the Chinese wording to the `chineseSimplified` table in the same file. If no
+match is found the generator falls back to a readable form of the key, which is
+visible but not translated, so it is worth adding.
-### Step 5: Implement Feature Logic (Optional)
+For wording only the client needs, use `ClientStrings.swift` and a `client.`
+prefix instead.
-If the setting affects app behavior, implement the logic.
+### Step 4: Adjust the Generator (Only If Needed)
-#### Option A: Listen to Settings Event
+Most settings need nothing further. Reach for
+`tools/settings-swift/generate.py` when:
-```vue
-
-```
+- **The translation key cannot be derived from the name**: add it to
+ `LABEL_OVERRIDES`
+- **The setting belongs on a different pane than its schema category**: add it
+ to `PANE_OVERRIDES`
+- **The value comes from a fixed list**: add the options to `CHOICES`, each with
+ the stored value and its translation key
+- **The setting is internal**: add it to `HIDDEN` so it stays out of the window
-#### Option B: Use Composable
+Then regenerate.
-Create `frontend/src/composables/core/useYourFeature.ts`:
+### Step 5: Implement Feature Logic (Optional)
-```typescript
-import { computed } from 'vue'
-import { useSettings } from './useSettings'
+If the setting changes how the client behaves, read it where the behaviour
+lives. Settings are strings on the wire, so use the typed accessors:
-export function useYourFeature() {
- const { settings } = useSettings()
+```swift
+// Boolean
+if viewModel.boolSetting("your_new_setting", default: true) {
+ // ...
+}
- const featureEnabled = computed(() => settings.value.your_new_setting)
+// String, with the schema default as the fallback
+let mode = viewModel.setting("default_view_mode", default: "rendered")
- return {
- featureEnabled
- }
-}
+// Number
+let size = Int(viewModel.setting("content_font_size", default: "16")) ?? 16
```
+Nothing needs to listen for a change: `AppViewModel.settings` is published, so a
+view reading it redraws when the value is saved. A setting that changes
+something outside SwiftUI â the language, for instance â is applied in
+`loadSettings()`.
+
### Step 6: Test
```bash
# Backend
-go build
+go build ./...
+go test ./internal/config/...
-# Frontend
-cd frontend
-npm run build
+# Client
+swift build --package-path frontend
+swift test --package-path frontend --filter SettingsCatalogTests
-# Or run full dev mode
-cd ..
-wails3 dev
+# Or run both and check the settings window
+./frontend/run.sh
```
---
@@ -334,86 +280,47 @@ go run tools/settings-generator/main.go
- Added POST field: `AutoCollapseSidebar string \`json:"auto_collapse_sidebar"\``
- Added save logic: `if req.AutoCollapseSidebar != "" { h.DB.SetSetting(...) }`
-4. **`frontend/src/types/settings.generated.ts`**
- - Added: `auto_collapse_sidebar: boolean;`
-
-5. **`frontend/src/composables/core/useSettings.generated.ts`**
- - Added: `auto_collapse_sidebar: false,` to defaults
- - Added fetch: `auto_collapse_sidebar: data.auto_collapse_sidebar === 'true',`
- - Added save: `auto_collapse_sidebar: (settingsRef.value.auto_collapse_sidebar ?? settingsDefaults.auto_collapse_sidebar).toString(),`
- - Added event: `window.dispatchEvent(new CustomEvent('auto-collapse-sidebar-changed', ...))`
+4. **`frontend/Sources/Models/SettingsCatalog.generated.swift`** (after
+ running the Swift generator)
+ - Added a `SettingDefinition` with the key, pane, control and default
6. **`config/defaults.json` & `internal/config/defaults.json`**
- Added: `"auto_collapse_sidebar": false`
### Step 3: Add Translations
-**English** (`frontend/src/i18n/locales/en.ts`):
+In `frontend/Sources/Localization/LocalizationTables.swift`, add to the
+English table:
-```typescript
-autoCollapseSidebar: 'Auto Collapse Sidebar',
-autoCollapseSidebarDesc: 'Automatically collapse the sidebar when the app starts',
+```json
+"setting.general.autoCollapseSidebar": "Auto Collapse Sidebar",
+"setting.general.autoCollapseSidebarDesc": "Automatically collapse the sidebar when the app starts"
```
-**Chinese** (`frontend/src/i18n/locales/zh.ts`):
+And to the Chinese table:
-```typescript
-autoCollapseSidebar: 'čŠåĻæå äū§čūđæ ',
-autoCollapseSidebarDesc: 'åšįĻåŊåĻæķčŠåĻæå äū§čūđæ ',
+```json
+"setting.general.autoCollapseSidebar": "čŠåĻæå äū§čūđæ ",
+"setting.general.autoCollapseSidebarDesc": "åšįĻåŊåĻæķčŠåĻæå äū§čūđæ "
```
-### Step 4: Add UI
+### Step 4: Regenerate the Catalogue
-Add to `frontend/src/components/modals/settings/general/GeneralSettings.vue`:
-
-```vue
-
-
-
+```bash
+python3 tools/settings-swift/generate.py
```
-Place it near related settings (like theme, startup on boot).
+The setting now appears on the General pane as a switch, with its description
+underneath. Nothing else needs editing.
### Step 5: Implement Feature Logic
-In your sidebar component:
-
-```vue
-
-
-
-
-
-
-
```
### Step 6: Test
@@ -491,11 +398,15 @@ Should return `200 OK`.
### Type Mapping
-| Schema Type | Go Type | TypeScript Type | Example |
-| ----------- | ------- | --------------- | ------- |
-| `"bool"` | `bool` | `boolean` | `true`, `false` |
-| `"int"` | `int` | `number` | `30`, `500` |
-| `"string"` | `string` | `string` | `"en"`, `"openai"` |
+| Schema Type | Go Type | Client Control | Example |
+| ----------- | ------- | -------------- | ------- |
+| `"bool"` | `bool` | Switch | `true`, `false` |
+| `"int"` | `int` | Number field | `30`, `500` |
+| `"string"` | `string` | Text field, secure field, or picker | `"en"`, `"openai"` |
+
+A string setting becomes a picker when the generator's `CHOICES` table lists its
+options, and a secure field when the schema marks it encrypted or the key ends
+in `_key`, `_password` or `_secret`.
### Categories
@@ -533,19 +444,19 @@ Encrypted settings are automatically:
- Fetched using `GetEncryptedSetting()` instead of `GetSetting()`
- Saved using `SetEncryptedSetting()` instead of `SetSetting()`
-### Frontend Key Convention
+### Key Convention
-**Important:** Frontend uses **snake_case** everywhere (not camelCase).
+**Important:** settings are addressed by their snake_case schema key everywhere.
-| Backend Key (JSON) | Frontend Property (TypeScript) |
-| ------------------ | ------------------------------ |
-| `update_interval` | `settings.update_interval` â |
-| `startup_on_boot` | `settings.startup_on_boot` â |
-| `deepl_api_key` | `settings.deepl_api_key` â |
-| `ai_endpoint` | `settings.ai_endpoint` â |
-| `ai_chat_enabled` | `settings.ai_chat_enabled` â |
+| Schema Key | Read in the client as |
+| ---------- | --------------------- |
+| `update_interval` | `viewModel.setting("update_interval")` |
+| `startup_on_boot` | `viewModel.boolSetting("startup_on_boot")` |
+| `deepl_api_key` | `viewModel.setting("deepl_api_key")` |
+| `ai_chat_enabled` | `viewModel.boolSetting("ai_chat_enabled")` |
-The `frontend_key` in the schema is for reference and should match the key in snake_case.
+The `frontend_key` in the schema is a hint for the generators when the
+translation key cannot be derived from the setting name.
### Quick Examples
@@ -561,13 +472,12 @@ The `frontend_key` in the schema is for reference and should match the key in sn
}
```
-Usage in Vue:
+Read in the client:
-```vue
-
+```swift
+if viewModel.boolSetting("enable_feature", default: true) {
+ // ...
+}
```
**Integer Setting:**
@@ -658,14 +568,14 @@ Examples:
#### Frontend Errors
-**Problem:** `Property 'my_setting' does not exist`
+**Problem:** the setting is missing from the client
**Solution:**
-1. Make sure you ran the generator
-2. Check `frontend/src/types/settings.generated.ts` exists and has your setting
-3. Try `npm run build` in frontend directory
-4. Restart TypeScript server in VSCode
+1. Make sure you ran both generators
+2. Check that `SettingsCatalog.generated.swift` contains the key
+3. Rebuild with `swift build --package-path frontend`
+4. If the label reads as a raw key, add its wording to the translation catalogue
#### Setting Not Appearing in UI
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 776cd617d..bab5a3313 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -97,85 +97,83 @@ func TestValidateURL(t *testing.T) {
}
```
-## Frontend Testing (Vitest)
-
-### Component Test Pattern
-
-```javascript
-import { describe, it, expect, vi } from 'vitest';
-import { mount } from '@vue/test-utils';
-import ArticleItem from './ArticleItem.vue';
-
-describe('ArticleItem', () => {
- it('renders article title', () => {
- const article = {
- id: 1,
- title: 'Test Article',
- isRead: false
- };
-
- const wrapper = mount(ArticleItem, {
- props: { article }
- });
-
- expect(wrapper.text()).toContain('Test Article');
- });
-
- it('emits mark-read event when clicked', async () => {
- const article = {
- id: 1,
- title: 'Test Article',
- isRead: false
- };
-
- const wrapper = mount(ArticleItem, {
- props: { article }
- });
-
- await wrapper.trigger('click');
-
- expect(wrapper.emitted('mark-read')).toBeTruthy();
- expect(wrapper.emitted('mark-read')[0]).toEqual([article.id]);
- });
-
- it('shows unread indicator for unread articles', () => {
- const article = {
- id: 1,
- title: 'Test',
- isRead: false
- };
-
- const wrapper = mount(ArticleItem, {
- props: { article }
- });
-
- expect(wrapper.find('.unread-indicator').exists()).toBe(true);
- });
-});
+## Client Testing (XCTest)
+
+### The stub client
+
+Tests drive the view model through `StubAPIClient`, which fails any call the
+test did not prepare. An unexpected request is therefore reported rather than
+quietly returning nothing.
+
+```swift
+final class ListAPIClient: StubAPIClient {
+ var articles: [Article] = []
+
+ override func fetchArticles(
+ feedID: Int?,
+ category: String?,
+ filter: String,
+ page: Int,
+ limit: Int
+ ) async throws -> [Article] { articles }
+}
+```
+
+### View model tests
+
+```swift
+@MainActor
+final class ArticleListStateTests: XCTestCase {
+ override func setUp() {
+ super.setUp()
+ // Titles are translated, so pin the language the assertions expect.
+ Localization.shared.setLanguage(.english)
+ }
+
+ func testUnreadFirstKeepsUnreadAtTheTop() {
+ let viewModel = AppViewModel(api: ListAPIClient(), autoLoad: false)
+ viewModel.articles = [read, unread]
+
+ viewModel.sortOrder = .unreadFirst
+
+ XCTAssertEqual(viewModel.displayedArticles.map(\.id), [unread.id, read.id])
+ }
+}
+```
+
+### Waiting for asynchronous work
+
+A fixed sleep has to guess how long the work takes, and a guess that holds on a
+developer's machine fails on a loaded runner. Poll instead:
+
+```swift
+try await waitUntil("the multimedia listing to drop read items") {
+ viewModel.articles.map(\.id) == [2]
+}
```
-### Composable Testing
+### Request tests
+
+`MockURLProtocol` serves canned responses, so the API tests never touch the
+network and can assert on the request that was built:
-```javascript
-import { describe, it, expect } from 'vitest';
-import { useArticleDetail } from './useArticleDetail';
+```swift
+respond("{}") { request, components, query in
+ XCTAssertEqual(components.path, "/api/articles/mark-all-read")
+ XCTAssertEqual(query["category"], "Tech")
+}
-describe('useArticleDetail', () => {
- it('loads article correctly', async () => {
- const { article, loadArticle, isLoading } = useArticleDetail();
+try await service.markAllRead(feedID: nil, category: "Tech")
+```
- // Initially null
- expect(article.value).toBeNull();
+### Layout tests
- // Load article
- await loadArticle(123);
+Views that have to lay out correctly are hosted in an `NSHostingView` and
+measured, rather than compared against a stored image:
- // Check result
- expect(isLoading.value).toBe(false);
- expect(article.value).not.toBeNull();
- expect(article.value.id).toBe(123);
- });
-});
+```swift
+let hosting = NSHostingView(rootView: FeedEditorView(mode: .add, viewModel: viewModel))
+hosting.frame = NSRect(x: 0, y: 0, width: 620, height: 640)
```
## Running Tests
@@ -196,22 +194,17 @@ go test -run TestDatabaseOperations ./internal/database
go test -v ./...
```
-### Frontend Tests
+### Client Tests
```bash
-cd frontend
-
# Run all tests
-npm test
+swift test --package-path frontend
# Run with coverage
-npm run test:coverage
-
-# Watch mode
-npm run test:watch
+swift test --package-path frontend --enable-code-coverage
-# Run specific test file
-npm test ArticleItem.test.ts
+# Run one suite
+swift test --package-path frontend --filter ArticleListStateTests
```
## Test Coverage
@@ -223,11 +216,12 @@ npm test ArticleItem.test.ts
- Business logic: 80%+
- Utility functions: 90%+
-### Frontend Coverage Goals
+### Client Coverage Goals
-- Components: 70%+
-- Composables: 80%+
-- Utilities: 90%+
+- Models and decoding: 90%+
+- API request building: 80%+
+- View model behaviour: 80%+
+- Views: layout and structure where it matters, not pixel comparisons
## Continue Reading
diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit
deleted file mode 100644
index d24fdfc60..000000000
--- a/frontend/.husky/pre-commit
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/usr/bin/env sh
-. "$(dirname -- "$0")/_/husky.sh"
-
-npx lint-staged
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
deleted file mode 100644
index 6ae27b0fa..000000000
--- a/frontend/.prettierignore
+++ /dev/null
@@ -1,8 +0,0 @@
-node_modules/
-dist/
-build/
-.vite/
-*.min.js
-*.config.js
-*.config.ts
-wailsjs/
diff --git a/frontend/.prettierrc b/frontend/.prettierrc
deleted file mode 100644
index 29b9d1f86..000000000
--- a/frontend/.prettierrc
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "semi": true,
- "trailingComma": "es5",
- "singleQuote": true,
- "printWidth": 100,
- "tabWidth": 2,
- "useTabs": false
-}
diff --git a/frontend/Package.swift b/frontend/Package.swift
new file mode 100644
index 000000000..bd3b74479
--- /dev/null
+++ b/frontend/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/README.md b/frontend/README.md
new file mode 100644
index 000000000..c80ab3dfe
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,58 @@
+# MrRSS SwiftUI Frontend
+
+This directory contains the native macOS SwiftUI client for MrRSS. It is the only frontend on this branch: it talks to the Go backend over the existing HTTP API.
+
+## Run directly
+
+From the repository root:
+
+```bash
+./frontend/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.27 or later
+
+## Run components separately
+
+Start the backend from the repository root:
+
+```bash
+go run . -host 127.0.0.1 -port 1234
+```
+
+In another terminal, start the macOS frontend:
+
+```bash
+swift run --package-path frontend 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 MrRSS
+```
+
+## Build and test
+
+```bash
+swift build --package-path frontend
+swift test --package-path frontend
+```
+
+To create the universal `.app` bundle and DMG used by GitHub Releases:
+
+```bash
+./frontend/build-app.sh 1.3.28
+```
+
+The release application bundles both the SwiftUI client and the Go backend, so it launches without a separately installed server.
+
+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 stored on the server rather than on this Mac. 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. The sidebar is an `NSOutlineView` rather than a SwiftUI `List`, so dragging behaves the way the system's own source lists do: a gap opens between the rows where the subscription would land, a folder lights up when the pointer rests on it, and a long list follows the pointer past its top and bottom edges. The resulting order is the `position` the server records, so it survives a reinstall and is seen by any other client reading the same backend. 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/Sources/Localization/ClientStrings.swift b/frontend/Sources/Localization/ClientStrings.swift
new file mode 100644
index 000000000..49152acfa
--- /dev/null
+++ b/frontend/Sources/Localization/ClientStrings.swift
@@ -0,0 +1,161 @@
+import Foundation
+
+/// Wording that only the native client needs, kept apart from the catalogue
+/// ported from the previous frontend so the two stay easy to tell apart.
+enum ClientStrings {
+ static let english: [String: String] = [
+ "client.error.invalidServerAddress": "The server address is invalid.",
+ "client.error.invalidResponse": "The server returned an invalid response.",
+ "client.error.httpStatus": "The server returned HTTP {status}.",
+ "client.error.unreadableResponse": "The server response could not be read",
+ "client.connection.connecting": "Connecting",
+ "client.connection.connected": "Connected",
+ "client.connection.offline": "Offline",
+ "client.connection.serverAddress": "Server address",
+ "client.connection.serverAddressHelp":
+ "The address of the MrRSS backend, for example http://127.0.0.1:1234.",
+ "client.connection.apply": "Apply",
+ "client.connection.saveAndReconnect": "Save and reconnect",
+ "client.connection.saved": "Saved",
+ "client.connection.backendServer": "Backend server",
+ "client.folder.namePlaceholder": "Technology",
+ "client.folder.nameLabel": "Name",
+ "client.article.chooseArticle": "Choose an article",
+ "client.article.chooseArticleDetail": "Select an article from the list to read it here.",
+ "client.article.noArticles": "No articles",
+ "client.article.noArticlesDetail": "Nothing matches the current selection.",
+ "client.article.loadingContent": "Loading content",
+ "client.sidebar.uncategorised": "Uncategorised",
+ "client.sidebar.newFolder": "New Folder",
+ "client.settings.allSettings": "All Settings",
+ "client.sidebar.library": "Library",
+ "client.article.showArticle": "Show Article",
+ "client.opml.importSuccess": "Subscriptions imported.",
+ "client.freshrss.pendingChanges": "Pending changes",
+ "client.refresh.outstanding": "{count} remaining",
+ "client.rule.summary": "{conditions} conditions · {actions} actions",
+ "client.rule.enabled": "Enabled",
+ "client.folder.deleteTitle": "Delete this folder?",
+ "client.folder.deleteMessage":
+ "The feeds it holds stay subscribed and move back out of any folder.",
+ "client.folder.newTitle": "New Folder",
+ "client.folder.renameTitle": "Rename Folder",
+ "client.folder.newMessage": "Folders group your subscriptions in the sidebar.",
+ "client.folder.moveMessage": "{name} will move into the new folder.",
+ "client.folder.renameMessage": "Every feed in {name} moves to the new name.",
+ "client.folder.create": "Create",
+ "client.folder.rename": "Rename",
+ "client.feed.imapPort": "IMAP port",
+ "client.article.translateTitle": "Translate Title",
+ "client.article.translateContent": "Translate Content",
+ "client.article.more": "More",
+ "client.article.noImages": "This article has no images.",
+ "client.settings.searchSettings": "Search settings",
+ "client.settings.connection": "Connection",
+ "client.settings.noResults": "No settings match the search.",
+ "client.action.retry": "Retry",
+ "client.action.dismiss": "Dismiss",
+ "client.action.showInFinder": "Show in Finder",
+ "client.help.sortAndLayout": "Sort order and row layout",
+ "client.help.languageTools": "Translate and summarise",
+ "client.help.moreActions": "Reload, images, export and find",
+ "client.help.viewMode": "Show the rendered article or the original page",
+ "client.feed.invalidURL": "Enter a valid HTTP or HTTPS feed address.",
+ "client.sort.oldestFirst": "Oldest first",
+ "client.sort.unreadFirst": "Unread first",
+ "client.sort.title": "Sort order",
+ "client.layout.compact": "Compact",
+ "client.layout.comfortable": "Comfortable",
+ "client.layout.cards": "Cards",
+ "client.layout.title": "Layout",
+ "client.folder.nameRequired": "Enter a folder name.",
+ "client.folder.alreadyExists": "A folder named {name} already exists.",
+ "client.server.invalidAddress": "Enter a valid HTTP or HTTPS server address.",
+ "client.settings.saved": "Settings saved.",
+ "client.rule.applied": "The rule was applied to {count} articles.",
+ "client.ai.limitReachedFallback":
+ "The AI usage limit was reached, so a fallback provider was used.",
+ "client.ai.usageReset": "AI usage was reset.",
+ "client.maintenance.cleared": "Cached translations and summaries were cleared."
+ ]
+
+ static let chineseSimplified: [String: String] = [
+ "client.error.invalidServerAddress": "æåĄåĻå°åæ æã",
+ "client.error.invalidResponse": "æåĄåĻčŋåäšæ æįååšã",
+ "client.error.httpStatus": "æåĄåĻčŋå HTTP {status}ã",
+ "client.error.unreadableResponse": "æ æģčŊŧåæåĄåĻååš",
+ "client.connection.connecting": "æĢåĻčŋæĨ",
+ "client.connection.connected": "å·ēčŋæĨ",
+ "client.connection.offline": "įĶŧįšŋ",
+ "client.connection.serverAddress": "æåĄåĻå°å",
+ "client.connection.serverAddressHelp": "MrRSS åįŦŊįå°åïžäūåĶ http://127.0.0.1:1234ã",
+ "client.connection.apply": "åšįĻ",
+ "client.connection.saveAndReconnect": "äŋååđķéæ°čŋæĨ",
+ "client.connection.saved": "å·ēäŋå",
+ "client.connection.backendServer": "åįŦŊæåĄåĻ",
+ "client.folder.namePlaceholder": "ææŊ",
+ "client.folder.nameLabel": "åį§°",
+ "client.article.chooseArticle": "éæĐäļįŊæįŦ ",
+ "client.article.chooseArticleDetail": "äŧåčĄĻäļéæĐäļįŊæįŦ åģåŊåĻæĪé čŊŧã",
+ "client.article.noArticles": "æēĄææįŦ ",
+ "client.article.noArticlesDetail": "æēĄæįŽĶåå―åéæĐįå åŪđã",
+ "client.article.loadingContent": "æĢåĻč――å Ĩå åŪđ",
+ "client.sidebar.uncategorised": "æŠåįąŧ",
+ "client.sidebar.newFolder": "æ°åŧšæäŧķåĪđ",
+ "client.settings.allSettings": "å ĻéĻčŪūį―Ū",
+ "client.sidebar.library": "čĩæåš",
+ "client.article.showArticle": "æūįĪšæįŦ ",
+ "client.opml.importSuccess": "čŪĒé æšå·ēåŊžå Ĩã",
+ "client.freshrss.pendingChanges": "åū åæĨįæīæđ",
+ "client.refresh.outstanding": "åĐä― {count} éĄđ",
+ "client.rule.summary": "{conditions} äļŠæĄäŧķ · {actions} äļŠåĻä―",
+ "client.rule.enabled": "åŊįĻ",
+ "client.folder.deleteTitle": "å éĪčŊĨæäŧķåĪđïž",
+ "client.folder.deleteMessage": "å ķäļįčŪĒé æšäŧäžäŋįïžåđķį§ŧåščŊĨæäŧķåĪđã",
+ "client.folder.newTitle": "æ°åŧšæäŧķåĪđ",
+ "client.folder.renameTitle": "éå―åæäŧķåĪđ",
+ "client.folder.newMessage": "æäŧķåĪđįĻäšåĻäū§čūđæ äļæīįčŪĒé æšã",
+ "client.folder.moveMessage": "{name} å°į§ŧå Ĩæ°åŧšįæäŧķåĪđã",
+ "client.folder.renameMessage": "{name} äļįææčŪĒé æšé―äžä―ŋįĻæ°åį§°ã",
+ "client.folder.create": "ååŧš",
+ "client.folder.rename": "éå―å",
+ "client.feed.imapPort": "IMAP įŦŊåĢ",
+ "client.article.translateTitle": "įŋŧčŊæ éĒ",
+ "client.article.translateContent": "įŋŧčŊæĢæ",
+ "client.article.more": "æīåĪ",
+ "client.article.noImages": "čŋįŊæįŦ æēĄæåūįã",
+ "client.settings.searchSettings": "æįīĒčŪūį―Ū",
+ "client.settings.connection": "čŋæĨ",
+ "client.settings.noResults": "æēĄæįŽĶåæįīĒæĄäŧķįčŪūį―Ūã",
+ "client.action.retry": "éčŊ",
+ "client.action.dismiss": "å ģé",
+ "client.action.showInFinder": "åĻčŪŋčūūäļæūįĪš",
+ "client.help.sortAndLayout": "æåšæđåžäļåčĄĻįåž",
+ "client.help.languageTools": "įŋŧčŊäļæčĶ",
+ "client.help.moreActions": "éæ°č――å ĨãåūįãåŊžåšäļæĨæū",
+ "client.help.viewMode": "æūįĪšæļ翿̿æåå§į―éĄĩ",
+ "client.feed.invalidURL": "čŊ·čūå Ĩææį HTTP æ HTTPS čŪĒé å°åã",
+ "client.sort.oldestFirst": "įąæ§å°æ°",
+ "client.sort.unreadFirst": "æŠčŊŧäžå ",
+ "client.sort.title": "æåšæđåž",
+ "client.layout.compact": "įī§å",
+ "client.layout.comfortable": "æ å",
+ "client.layout.cards": "åĄį",
+ "client.layout.title": "åļåą",
+ "client.folder.nameRequired": "čŊ·čūå ĨæäŧķåĪđåį§°ã",
+ "client.folder.alreadyExists": "åäļš {name} įæäŧķåĪđå·ēååĻã",
+ "client.server.invalidAddress": "čŊ·čūå Ĩææį HTTP æ HTTPS æåĄåĻå°åã",
+ "client.settings.saved": "čŪūį―Ūå·ēäŋåã",
+ "client.rule.applied": "č§åå·ēåšįĻäš {count} įŊæįŦ ã",
+ "client.ai.limitReachedFallback": "å·ēčūūå° AI ä―ŋįĻäļéïžå æĪä―ŋįĻäšåĪįĻæåĄã",
+ "client.ai.usageReset": "AI įĻéå·ēéį―Ūã",
+ "client.maintenance.cleared": "å·ēæļ éĪįžåįįŋŧčŊåæčĶã"
+ ]
+
+ static func table(for language: AppLanguage) -> [String: String] {
+ switch language {
+ case .english: english
+ case .chineseSimplified: chineseSimplified
+ }
+ }
+}
diff --git a/frontend/Sources/Localization/Localization.swift b/frontend/Sources/Localization/Localization.swift
new file mode 100644
index 000000000..47db1be7b
--- /dev/null
+++ b/frontend/Sources/Localization/Localization.swift
@@ -0,0 +1,96 @@
+import Foundation
+
+/// The languages the interface can be shown in. The identifiers match the
+/// values the backend stores under the `language` setting.
+enum AppLanguage: String, CaseIterable, Identifiable {
+ case english = "en-US"
+ case chineseSimplified = "zh-CN"
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .english: "English"
+ case .chineseSimplified: "įŪä―äļæ"
+ }
+ }
+
+ /// The value the backend ships with, used to tell an untouched setting from
+ /// a deliberate choice of English.
+ static let schemaDefault = AppLanguage.english
+
+ /// The language to use when the stored setting is missing or unknown.
+ static var systemDefault: AppLanguage {
+ let preferred = Locale.preferredLanguages.first ?? "en"
+ return preferred.hasPrefix("zh") ? .chineseSimplified : .english
+ }
+
+ static func from(settingValue: String?) -> AppLanguage {
+ guard let settingValue, !settingValue.isEmpty else { return .systemDefault }
+ if let exact = AppLanguage(rawValue: settingValue) { return exact }
+ return settingValue.hasPrefix("zh") ? .chineseSimplified : .english
+ }
+}
+
+/// Looks up interface strings by dotted key, mirroring the keys the previous
+/// frontend used so the wording stays identical.
+final class Localization: ObservableObject {
+ static let shared = Localization()
+
+ @Published private(set) var language: AppLanguage
+
+ private var tables: [AppLanguage: [String: String]] = [:]
+
+ init(language: AppLanguage = .systemDefault) {
+ self.language = language
+ }
+
+ func setLanguage(_ language: AppLanguage) {
+ guard language != self.language else { return }
+ self.language = language
+ }
+
+ /// Returns the string for `key`, falling back to English and then to the
+ /// key itself so a missing entry is visible rather than blank.
+ func string(_ key: String) -> String {
+ if let value = table(for: language)[key] {
+ return value
+ }
+ if language != .english, let value = table(for: .english)[key] {
+ return value
+ }
+ return key
+ }
+
+ /// Returns the string for `key` with `{name}` placeholders replaced.
+ func string(_ key: String, _ arguments: [String: CustomStringConvertible]) -> String {
+ var result = string(key)
+ for (name, value) in arguments {
+ result = result.replacingOccurrences(of: "{\(name)}", with: value.description)
+ }
+ return result
+ }
+
+ private func table(for language: AppLanguage) -> [String: String] {
+ if let cached = tables[language] { return cached }
+ let json: String
+ switch language {
+ case .english: json = LocalizationTables.english
+ case .chineseSimplified: json = LocalizationTables.chineseSimplified
+ }
+ var parsed = (try? JSONSerialization.jsonObject(with: Data(json.utf8))) as? [String: String] ?? [:]
+ parsed.merge(ClientStrings.table(for: language)) { _, client in client }
+ tables[language] = parsed
+ return parsed
+ }
+}
+
+/// Shorthand for `Localization.shared.string(_:)`.
+func t(_ key: String) -> String {
+ Localization.shared.string(key)
+}
+
+/// Shorthand for `Localization.shared.string(_:_:)`.
+func t(_ key: String, _ arguments: [String: CustomStringConvertible]) -> String {
+ Localization.shared.string(key, arguments)
+}
diff --git a/frontend/Sources/Localization/LocalizationTables.swift b/frontend/Sources/Localization/LocalizationTables.swift
new file mode 100644
index 000000000..a19e98429
--- /dev/null
+++ b/frontend/Sources/Localization/LocalizationTables.swift
@@ -0,0 +1,14 @@
+// The MrRSS translation catalogue, stored as flat JSON keyed by dotted paths.
+// Edit the JSON directly to change or add wording; `Localization` parses it lazily.
+
+import Foundation
+
+enum LocalizationTables {
+ static let english = #"""
+{"article.action.addToFavorite":"Add to Favorites","article.action.addToReadLater":"Add to Read Later","article.action.backToUrl":"Back to URL","article.action.closeArticle":"Close Article","article.action.fetchFullArticle":"Fetch Full Article","article.action.fetchingFullArticle":"Fetching full article...","article.action.fullArticleFetched":"Full article content loaded","article.action.hideArticle":"Hide Article","article.action.markAboveAsRead":"Mark Above as Read","article.action.markAllAsReadFeed":"Mark All as Read","article.action.markAllRead":"Mark All as Read","article.action.markAllReadShortcut":"Mark All as Read","article.action.markAllReadConfirmMessage":"Are you sure you want to mark all articles as read?","article.action.markAllReadConfirmTitle":"Mark All as Read","article.action.markAboveReadConfirmMessage":"Are you sure you want to mark all articles above this one as read?","article.action.markAboveReadConfirmTitle":"Mark Above as Read","article.action.markBelowReadConfirmMessage":"Are you sure you want to mark all articles below this one as read?","article.action.markBelowReadConfirmTitle":"Mark Below as Read","article.action.markAsRead":"Mark as Read","article.action.markAsUnread":"Mark as Unread","article.action.markBelowAsRead":"Mark Below as Read","article.action.markedAllAsRead":"All articles marked as read","article.action.markedNArticlesAsRead":"Marked {count} articles as read","article.action.noArticlesToMark":"No articles to mark","article.action.openArticle":"Open Article","article.action.openInBrowser":"Open in Browser","article.action.openInBrowserShortcut":"Open in Browser","article.action.refresh":"Refresh","article.action.refreshFeed":"Refresh Feed","article.action.refreshFeedsShortcut":"Refresh Feeds","article.action.reloadContent":"Reload Article Content","article.action.refreshing":"Refreshing","article.action.removeFromFavorite":"Remove from Favorites","article.action.removeFromFavorites":"Remove from Favorites","article.action.removeFromReadLater":"Remove from Read Later","article.action.toggleFavoriteStatus":"Toggle Favorite","article.action.unhideArticle":"Unhide Article","article.action.viewArticle":"View Article","article.action.viewContent":"View Content","article.action.viewImage":"View Image","article.action.viewModeOriginal":"View as Webpage","article.action.viewModeRendered":"View as Rendered Content","article.action.viewModeExternal":"Open in Browser","article.action.viewOriginal":"View Original","article.audioPlayer.audioPlaybackError":"Failed to play audio. The file may be unavailable or in an unsupported format.","article.audioPlayer.pause":"Pause","article.audioPlayer.play":"Play","article.audioPlayer.playbackSpeed":"Playback Speed","article.audioPlayer.podcastAudio":"Podcast Audio","article.audioPlayer.skipBackward":"Backward 10s","article.audioPlayer.skipForward":"Forward 10s","article.audioPlayer.volume":"Volume","article.chat.aiChat":"AI Chat","article.chat.aiChatError":"Failed to get response from AI. Please try again.","article.chat.historySaveFailed":"The answer was generated but could not be saved to chat history.","article.chat.aiChatInputPlaceholder":"Type a message...","article.chat.aiChatWelcome":"Ask me anything about this article!","article.chat.confirmDeleteSession":"Are you sure you want to delete this chat session?","article.chat.hideThinking":"Hide Thinking","article.chat.newChat":"New Chat","article.chat.noSessions":"No chat sessions yet","article.chat.showThinking":"Show Thinking","article.chat.switchSession":"Switch chat session","article.chat.thinking":"Thinking","article.content.fetchingArticleContent":"Fetching article content from RSS feed...","article.content.loadingContent":"Loading content","article.content.noArticles":"No articles found.","article.content.noContentAvailable":"No content available","article.content.renderContent":"Render Content","article.content.selectArticle":"Select an article to start reading","article.imageGallery.actionFavorite":"Add to Favorites","article.imageGallery.actionUnfavorite":"Remove from Favorites","article.imageGallery.addToFavorite":"Add to Favorites","article.list.markAllVisibleAsRead":"Mark All Visible as Read","article.list.allArticlesLoaded":"All articles loaded","article.navigation.goToAllArticles":"Go to All Articles","article.navigation.goToFavorites":"Go to Favorites","article.navigation.goToReadLater":"Go to Read Later","article.navigation.goToUnread":"Go to Unread","article.navigation.nextArticle":"Next Article","article.navigation.previousArticle":"Previous Article","article.parts.articleTitle":"Article Title","article.progress.activeTasks":"Active Tasks","article.summary.aiLimitReached":"AI usage limit reached. Using free alternatives.","article.summary.aiSummaryFallback":"AI summarization failed. Using built-in algorithm.","article.summary.articleSummary":"Article Summary","article.summary.articleTooShort":"Article content is too short","article.summary.generatingSummaryTime":"Generating summary took {time}","article.summary.originalSummary":"Original summary","article.summary.translatedSummary":"Translated summary","article.summary.translatingSummary":"Translating summary...","article.toolbar.addToFavorite":"Add to Favorites","article.toolbar.addToReadLater":"Add to Read Later","article.translation.aiLimitReached":"AI usage limit reached. Using free alternatives.","article.videoPlayer.openInYouTube":"Open in YouTube","article.videoPlayer.videoLoadError":"Failed to load video. Please try opening it in the original platform.","article.videoPlayer.youtubeVideo":"YouTube Video","article.videoPlayer.videoPlayer":"{platform} Video","article.videoPlayer.openInPlatform":"Open in {platform}","aiSearch.button":"AI Search","aiSearch.buttonTitle":"Use AI to search articles with natural language","aiSearch.clearResults":"Clear","aiSearch.foundResults":"Found {count} articles","aiSearch.noResults":"No articles found matching your search","aiSearch.placeholder":"Describe what you want to find...","aiSearch.relevanceScore":"Relevance {score}","aiSearch.matchFields.title":"Title match","aiSearch.matchFields.summary":"Summary match","aiSearch.matchFields.content":"Content match","aiSearch.searchFailed":"AI search failed. Please check your AI settings.","aiSearch.showingResults":"Showing AI search results","aiErrors.configuration_invalid":"The AI configuration is incomplete or invalid. Check the endpoint and model.","aiErrors.usage_limit_reached":"The AI usage limit configured in MrRSS has been reached. Adjust it and try again.","aiErrors.rate_limited":"The AI service is receiving too many requests. Please try again later.","aiErrors.authentication_failed":"AI authentication failed. Check the API key and access permissions.","aiErrors.payment_required":"The AI service has insufficient quota or balance. Check the provider account.","aiErrors.model_or_endpoint_not_found":"The AI model or endpoint is unavailable. Check the configuration.","aiErrors.request_too_large":"The content sent to AI is too large. Shorten it or choose another model.","aiErrors.timeout":"The AI service response timed out. Please try again.","aiErrors.network_error":"Could not reach the AI service. Check the network, proxy, and endpoint.","aiErrors.provider_unavailable":"The AI service is temporarily unavailable. Please try again later.","aiErrors.invalid_response":"The AI service returned an invalid response. Retry or choose another model.","aiErrors.provider_rejected_request":"The AI service rejected the request. Check the model and endpoint settings.","aiErrors.request_failed":"The AI request failed. Check the AI configuration and try again.","common.cancel":"Cancel","common.confirm":"Confirm","common.save":"Save","common.action.add":"Add","common.action.addTags":"Add Tags","common.action.cancel":"Cancel","common.action.confirm":"Confirm","common.action.deleteSelected":"Delete Selected","common.action.deselectAll":"Deselect All","common.action.discard":"Discard","common.action.downloading":"Downloading...","common.action.escToClear":"Press Escape to clear","common.action.move":"Move","common.action.moveFeeds":"Move Feeds","common.action.moveSelected":"Move Selected","common.action.no":"No","common.action.openWebsite":"Open Website","common.action.remove":"Remove","common.action.resetToDefault":"Reset to Default","common.action.save":"Save","common.action.saveChanges":"Save Changes","common.action.saving":"Saving...","common.action.setImageMode":"Set Multimedia Mode","common.action.unsetImageMode":"Unset Multimedia Mode","common.action.switchTo":"Switch to","common.action.unsubscribe":"Unsubscribe","common.action.yes":"Yes","common.back":"Back","common.checking":"Checking...","common.clear":"Clear","common.clearReadLater":"Clear Read Later","common.close":"Close","common.connectionFailed":"Connection failed","common.connectionSuccessful":"Connection successful","common.contextMenu.copyImage":"Copy Image","common.contextMenu.copyLink":"Copy Link","common.contextMenu.copyTitle":"Copy Title","common.contextMenu.downloadAudio":"Download Audio File","common.contextMenu.downloadImage":"Download Image","common.copy":"Copy","common.delete":"Delete","common.done":"Done","common.edit":"Edit","common.error":"Error","common.errors.addingFeed":"Error adding feed","common.errors.cleaningDatabase":"Error cleaning up database","common.errors.createFailed":"Failed to create","common.errors.errorCheckingUpdates":"Error checking for updates","common.errors.failedToCopy":"Failed to copy","common.errors.failedToOpenLink":"Failed to open link","common.errors.failedToTransformRSSHubURL":"Failed to transform RSSHub URL","common.errors.fetchingArticleContent":"Failed to reload article content","common.errors.fetchingFullArticle":"Failed to fetch full article content","common.errors.invalidURLScheme":"Invalid URL scheme","common.errors.networkErrorCheckingUpdates":"Unable to connect to GitHub servers. If you are in mainland China, please try using a proxy or VPN.","common.errors.reorderingFeed":"Failed to reorder feed","common.errors.savingSettings":"Error saving settings","common.errors.subscribingFeeds":"Error subscribing to feeds","common.errors.translating":"Translation failed. Please check your network connection and translation settings.","common.errors.translatingContent":"Failed to translate content","common.errors.translatingTitle":"Failed to translate article title","common.errors.unknownError":"Unknown error occurred","common.findInPage.findInPagePlaceholder":"Find in article...","common.findInPage.nextMatch":"Next match","common.findInPage.previousMatch":"Previous match","common.form.apply":"Apply","common.form.category":"Category","common.form.disabled":"Disabled","common.form.enabled":"Enabled","common.form.requiredField":"This field is required","common.form.status":"Status","common.form.title":"Title","common.imageViewer.zoomIn":"Zoom In","common.imageViewer.zoomOut":"Zoom Out","common.language.chinese":"Chinese","common.language.english":"English","common.language.french":"French","common.language.german":"German","common.language.japanese":"Japanese","common.language.simplifiedChinese":"Simplified Chinese","common.language.spanish":"Spanish","common.language.traditionalChinese":"Traditional Chinese","common.language.turkish":"Turkish","common.toast.autoTranslateEnabled":"Auto-translate enabled","common.toast.clearedReadLater":"Read Later list cleared","common.toast.copiedToClipboard":"Copied to clipboard","common.toast.downloadComplete":"Download complete","common.toast.downloadFailed":"Download failed","common.pagination.deleting":"Deleting","common.pagination.loading":"Loading","common.pagination.preparing":"Preparing","common.pagination.saving":"Saving...","common.pagination.uploading":"Uploading","common.state.loading":"Loading...","common.search.itemsSelected":"{count} items selected","common.search.item":"item","common.search.noSearchResults":"No feeds match your search","common.search.searchFeeds":"Search feeds...","common.search.selectAll":"Select All","common.search.selectItems":"Select items","common.search.totalAndSelected":"{total} total, {selected} selected","common.select.placeholder":"Select...","common.select.noOptions":"No options","common.select.searchPlaceholder":"Search...","common.select.noResults":"No results found","common.select.addNew":"Add New","common.select.addNewPlaceholder":"Enter new value...","common.input.customValue":"Enter custom value...","common.time.days":"days","common.time.daysAgo":"{count} days ago","common.time.hoursAgo":"{count} hours ago","common.time.justNow":"Just now","common.time.minutes":"minutes","common.time.minutesAgo":"{count} minutes ago","common.time.minutesShort":"min","common.time.ms":"ms","common.time.never":"Never","common.time.seconds":"seconds","common.time.secondsAgo":"{count} seconds ago","common.text.andNMore":"+{count} more","common.text.forceTranslate":"Force Translate","common.text.image":"Image","common.text.or":"or","common.text.orTry":"Or try","common.text.progress":"Progress: ","common.warning.isInDevelopment":"This feature involves a third-party tool, which may be unstable and have issues.","modal.common.unsavedChangesMessage":"You have unsaved changes. Do you want to discard them?","modal.common.unsavedChangesTitle":"Unsaved Changes","modal.discovery.detecting":"Detecting...","modal.discovery.checkingRssFeed":"Checking RSS feed...","modal.discovery.discoverAllFeeds":"Discover All Feeds","modal.discovery.discoverAllFeedsDesc":"Automatically discover new feeds from all the subscriptions that haven't been scanned yet","modal.discovery.discoverFeeds":"Discover Feeds","modal.discovery.discovering":"Discovering feeds...","modal.discovery.discoveryFailed":"Discovery failed","modal.discovery.discoveryLongRunningWarning":"When you have many feeds, this process may take a very long time to complete. Notice that this is not refreshing your feeds.","modal.discovery.fetchingFriendPage":"Fetching friend links page","modal.discovery.fetchingHomepage":"Fetching homepage","modal.discovery.foundFeeds":"Found {count} feeds","modal.discovery.foundPotentialLinks":"Found {count} potential feed links","modal.discovery.foundSoFar":"Found {count} feeds so far","modal.discovery.noFriendLinksFound":"No friend links found","modal.discovery.preparingDiscovery":"Preparing discovery","modal.discovery.processingFeed":"Processing feed {current}/{total}","modal.discovery.searchingFriendLinks":"Searching for friend links","modal.discovery.startDiscovery":"Start discovery","modal.feed.adding":"Adding...","modal.feed.addNewFeed":"Add New Feed","modal.feed.addSubscription":"Add Subscription","modal.feed.categoryPlaceholder":"e.g. Tech/News","modal.feed.deleteFeedMessage":"Are you sure you want to delete this feed?","modal.feed.deleteFeedTitle":"Delete Feed","modal.feed.deleteMultipleFeedsMessage":"Are you sure you want to delete these {count} feeds?","modal.feed.deleteMultipleFeedsTitle":"Delete Multiple Feeds","modal.feed.dragToReorder":"Drag to reorder or move to another category","modal.feed.duplicateFeedURL":"A feed with this URL already exists","modal.feed.duplicateFeedSelected":"Opened the existing feed","modal.feed.editFeed":"Edit Feed","modal.feed.editSubscription":"Edit Subscription","modal.feed.email":"Email Newsletter","modal.feed.emailAddress":"Newsletter Sender","modal.feed.emailAddressHint":"Leave empty to fetch all emails from the folder","modal.feed.emailConnectionError":"Network error. Please try again.","modal.feed.emailFillRequired":"Please fill in IMAP server, username, and password.","modal.feed.emailFolder":"Email Folder","modal.feed.emailPassword":"IMAP Password","modal.feed.emailPasswordPlaceholder":"Enter your password or app-specific password","modal.feed.emailServer":"IMAP Server","modal.feed.emailTestConnection":"Test Connection","modal.feed.emailUsername":"IMAP Username","modal.feed.enterCategoryName":"Enter new category name:","modal.feed.errorConnection":"Connection failed, please check network","modal.feed.errorCertificate":"SSL certificate error","modal.feed.errorDNS":"DNS resolution failed","modal.feed.errorInvalidFormat":"Invalid feed format","modal.feed.errorNotFound":"Feed not found (404)","modal.feed.errorServer":"Server error, please try again later","modal.feed.errorTimeout":"Request timeout, please check network connection","modal.feed.errorUnauthorized":"Authentication required (401/403)","modal.feed.feedAddedSuccess":"Feed added successfully","modal.feed.feedCategory":"Feed Category","modal.feed.feedDeletedSuccess":"Feed deleted successfully","modal.feed.articlesDeleted":"{count} articles deleted","modal.feed.articlesRemoved":"{count} entries removed","modal.feed.filesRemoved":"{count} files removed","modal.feed.feedDiscovery":"Feed Discovery","modal.feed.feedName":"Feed Name","modal.feed.feedReordered":"Feed reordered successfully","modal.feed.feedRefreshStarted":"Feed refresh started","modal.feed.feedsDeletedSuccess":"Feeds deleted successfully","modal.feed.feedsMovedSuccess":"Feeds moved successfully","modal.feed.feedsSubscribedPartial":"Partially subscribed: {succeeded}/{total} feeds","modal.feed.feedsSubscribedSuccess":"Successfully subscribed to {count} feeds","modal.feed.feedUpdatedSuccess":"Feed updated successfully","modal.feed.imageModeSetSuccess":"Multimedia mode enabled for selected feeds","modal.feed.imageModeUnsetSuccess":"Multimedia mode disabled for selected feeds","modal.feed.selectTagsToAdd":"Select tags to add:","modal.feed.setImageModeMessage":"Enable multimedia mode for {count} selected feed(s)?","modal.feed.setImageModeTitle":"Set Multimedia Mode","modal.feed.tagsAddedSuccess":"Tags added successfully","modal.feed.unsetImageModeMessage":"Disable multimedia mode for {count} selected feed(s)?","modal.feed.unsetImageModeTitle":"Unset Multimedia Mode","modal.feed.manageFeeds":"Manage Feeds","modal.feed.noFeeds":"No feeds yet","modal.feed.originalOrder":"Original order","modal.feed.sortAscending":"Sort ascending","modal.feed.sortDescending":"Sort descending","modal.feed.loadFailed":"Unable to load feeds","modal.feed.loadFailedDesc":"Check your connection and try again.","modal.feed.loadFailedKeepingData":"Refresh failed. Showing the last successfully loaded feeds.","modal.feed.retry":"Retry","modal.feed.proxy":"Feed Proxy","modal.feed.proxyDesc":"Configure proxy settings for this feed","modal.feed.proxyHost":"Proxy Host","modal.feed.proxyPassword":"Password","modal.feed.proxyPort":"Proxy Port","modal.feed.proxyType":"Proxy Type","modal.feed.proxyUsername":"Username","modal.feed.refreshInterval":"Refresh Interval","modal.feed.refreshIntervalDesc":"Custom refresh interval for this feed","modal.feed.refreshIntervalPlaceholder":"Minutes","modal.feed.refreshes":"Feed Refreshes","modal.feed.refreshMode":"Refresh Mode","modal.feed.refreshModeDesc":"How this feed should be refreshed","modal.feed.refreshSettings":"Feed Refresh Settings","modal.feed.rssUrl":"RSS URL","modal.feed.sourceUrl":"Source URL","modal.feed.sourceUrlPlaceholder":"https://example.com/blog","modal.feed.syncFeed":"Sync Feed","modal.feed.syncFeedStarted":"Feed sync started","modal.feed.titlePlaceholder":"Custom feed title","modal.feed.typeCustomScript":"Custom Script","modal.feed.typeEmail":"Email Feed","modal.feed.typeFreshRSS":"FreshRSS Feed","modal.feed.typeRegular":"Regular Feed","modal.feed.typeRSSHub":"RSSHub Feed","modal.feed.typeXPath":"XPath","modal.feed.xpath":"XPath Support","modal.feed.xpathDocumentation":"XPath Documentation","modal.feed.xpathHtml":"HTML + XPath","modal.feed.xpathItem":"Item XPath","modal.feed.xpathItemAuthor":"Author XPath","modal.feed.xpathItemCategories":"Categories XPath","modal.feed.xpathItemContent":"Content XPath","modal.feed.xpathItemHelp":"XPath expression to select article containers","modal.feed.xpathItemThumbnail":"Thumbnail XPath","modal.feed.xpathItemTimeFormat":"Time Format","modal.feed.xpathItemTimestamp":"Timestamp XPath","modal.feed.xpathItemTitle":"Title XPath","modal.feed.xpathItemUid":"UID XPath","modal.feed.xpathItemUri":"URL XPath","modal.feed.xpathType":"XPath Type","modal.feed.xpathXml":"XML + XPath","modal.feed.renameCategory":"Rename Category","modal.feed.subscribeSelected":"Subscribe Selected","modal.feed.subscribing":"Subscribing","modal.feed.unsubscribedSuccess":"Successfully unsubscribed","modal.feed.unsubscribeMessage":"Are you sure you want to unsubscribe from this feed?","modal.feed.unsubscribeTitle":"Unsubscribe","modal.feed.feedTags":"Feed Tags","modal.tag.addNew":"Add New Tag","modal.tag.assignedFeeds":"Assigned Feeds ({count})","modal.tag.color":"Tag Color","modal.tag.confirmDelete":"Are you sure you want to delete this tag? It will be removed from all feeds.","modal.tag.createNew":"Create New Tag","modal.tag.createTag":"Create Tag","modal.tag.editTag":"Edit Tag","modal.tag.loadFailed":"Failed to load tags","modal.tag.manageTags":"Manage Tags","modal.tag.name":"Tag Name","modal.tag.noTags":"No tags yet","modal.tag.retry":"Retry","modal.tag.selectTags":"Select Tags","modal.tag.tagCreated":"Tag created successfully","modal.filter.addCondition":"Add Condition","modal.filter.and":"AND","modal.filter.applyFilters":"Apply Filters","modal.filter.filterConditions":"Filter Conditions","modal.filter.clearFilters":"Clear Filters","modal.filter.conditionAlways":"Always (all articles)","modal.filter.contains":"Contains","modal.filter.exactMatch":"Exact Match","modal.filter.favoriteStatus":"Favorite Status","modal.filter.feedType":"Feed Type","modal.filter.filter":"Filter","modal.filter.filterArticles":"Filter Articles","modal.filter.filterField":"Field","modal.filter.filterOperator":"Operator","modal.filter.filterValue":"Value","modal.filter.fromFeed":"From Feed","modal.filter.hiddenStatus":"Hidden Status","modal.filter.isImageModeFeed":"Multimedia Mode Feed","modal.filter.noFiltersApplied":"No filters applied","modal.filter.not":"NOT","modal.filter.or":"OR","modal.filter.publishedAfter":"Published On/After","modal.filter.publishedBefore":"Published On/Before","modal.filter.publishedAfterHours":"Published Within (Hours)","modal.filter.publishedAfterDays":"Published Within (Days)","modal.filter.readLaterStatus":"Read Later Status","modal.filter.readStatus":"Read Status","modal.filter.regex":"Regular Expression","modal.filter.author":"Author","modal.filter.url":"URL","modal.filter.articleContent":"Article Content","modal.filter.hasSummary":"Has Summary","modal.filter.hasTranslation":"Has Translation","modal.filter.hasImage":"Has Image","modal.filter.hasAudio":"Has Audio","modal.filter.hasVideo":"Has Video","modal.filter.feedArticlesPerMonth":"Feed Articles Per Month","modal.filter.feedLastUpdateStatus":"Feed Update Status","modal.filter.updateSuccess":"Success","modal.filter.updateFailed":"Failed","modal.filter.logicPrecedence":"Conditions are evaluated with the following precedence: NOT > AND > OR. This means NOT is evaluated first, then AND, and finally OR.","modal.rule.actions":"Actions","modal.rule.addAction":"Add Action","modal.rule.addCondition":"Add Condition","modal.rule.addRule":"Add Rule","modal.rule.condition":"Condition","modal.rule.deleteConfirmMessage":"Are you sure you want to delete this rule?","modal.rule.deleteConfirmTitle":"Delete Rule","modal.rule.deletedSuccess":"Rule deleted successfully","modal.rule.deleteRule":"Delete Rule","modal.rule.editRule":"Edit Rule","modal.rule.name":"Rule Name","modal.rule.namePlaceholder":"e.g., Auto-favorite tech news","modal.rule.rules":"Rules","modal.rule.rulesDesc":"Create automation rules to automatically perform actions on articles","modal.rule.ruleAppliedSuccess":"Rule applied successfully","modal.rule.savedSuccess":"Rule saved successfully","modal.rule.logicPrecedence":"Conditions are evaluated with the following precedence: NOT > AND > OR. This means NOT is evaluated first, then AND, and finally OR.","modal.opml.export":"Export Feeds","modal.opml.exportSuccess":"OPML exported successfully.","modal.opml.import":"Import Feeds","modal.update.downloadUpdate":"Download Update","modal.update.newVersionAvailable":"New version available","setting.about.version":"Version","setting.about.viewOnGitHub":"View on GitHub","setting.ai.aiApiKey":"API Key","setting.ai.aiApiKeyDesc":"API key for AI services","setting.ai.aiApiKeyPlaceholder":"Enter your API key","setting.ai.aiChatEnabled":"AI Chat","setting.ai.aiChatEnabledDesc":"Chat with AI for answers to article-related questions","setting.ai.aiConfigAllGood":"Your AI configuration is working correctly!","setting.ai.aiConfigurationGuide":"View AI Configuration Guide","setting.ai.aiCustomHeaders":"Custom Headers","setting.ai.aiCustomHeadersAdd":"Add Header","setting.ai.aiCustomHeadersDesc":"Additional HTTP headers to send with AI requests","setting.ai.aiCustomHeadersName":"Header Name","setting.ai.aiCustomHeadersRemove":"Remove","setting.ai.aiCustomHeadersValue":"Header Value","setting.ai.aiEndpoint":"API Endpoint","setting.ai.aiSearchEnabled":"AI Search","setting.ai.aiSearchEnabledDesc":"Use AI to intelligently search articles with keyword expansion","setting.ai.endpoint":"Endpoint","setting.ai.aiEndpointDesc":"Full API endpoint URL including path","setting.ai.aiEndpointPlaceholder":"https://api.openai.com/v1/chat/completions","setting.ai.aiFeatures":"AI Features","setting.ai.aiModel":"Model Name","setting.ai.aiModelDesc":"AI model to use for translation and summarization","setting.ai.aiModelPlaceholder":"gpt-4o-mini","setting.ai.aiProfiles":"AI Profiles","setting.ai.addProfile":"Add Profile","setting.ai.editProfile":"Edit Profile","setting.ai.deleteProfile":"Delete","setting.ai.deleteProfileTitle":"Delete AI Profile","setting.ai.deleteProfileConfirm":"Are you sure you want to delete \"{name}\"? This action cannot be undone.","setting.ai.deleteProfileFailed":"Failed to delete profile","setting.ai.profileDeleted":"Profile deleted successfully","setting.ai.profileCreated":"Profile created successfully","setting.ai.profileUpdated":"Profile updated successfully","setting.ai.saveFailed":"Failed to save profile","setting.ai.profileName":"Profile Name","setting.ai.profileNameDesc":"A friendly name to identify this AI configuration","setting.ai.profileNamePlaceholder":"My AI Profile","setting.ai.nameRequired":"Profile name is required","setting.ai.endpointRequired":"API endpoint is required","setting.ai.modelRequired":"Model name is required","setting.ai.configIncomplete":"Please fill in endpoint and model","setting.ai.noProfiles":"No AI profiles configured","setting.ai.noProfilesHint":"Add a profile to start using AI features","setting.ai.testProfile":"Test","setting.ai.testAllProfiles":"Test All","setting.ai.testingAll":"Testing...","setting.ai.selectProfile":"AI Profile","setting.ai.selectProfileForTranslation":"Select which AI profile to use for translation","setting.ai.selectProfileForSummary":"Select which AI profile to use for summary generation","setting.ai.selectProfileForChat":"Select which AI profile to use for AI chat","setting.ai.selectProfileForSearch":"Select which AI profile to use for AI search","setting.ai.model":"Model","setting.ai.aiTestFailed":"AI configuration test failed","setting.ai.aiUsage":"AI Usage","setting.ai.aiUsageReset":"Reset Usage","setting.ai.aiUsageResetConfirm":"Are you sure you want to reset the AI usage counter?","setting.ai.aiUsageResetError":"Failed to reset AI usage counter","setting.ai.aiUsageResetSuccess":"AI usage counter reset successfully","setting.ai.aiUsageTokens":"Tokens Used","setting.ai.aiUsageLimitPlaceholder":"0","setting.ai.clearAllChats":"Clear Chat History","setting.ai.clearAllChatsButton":"Clear","setting.ai.clearAllChatsConfirm":"Are you sure you want to clear all chat history? This action cannot be undone.","setting.ai.clearAllChatsDesc":"Delete all AI chat sessions","setting.ai.clearAllChatsFailed":"Failed to clear chat history","setting.ai.clearAllChatsSuccess":"Chat history cleared successfully","setting.ai.configValid":"Config Valid","setting.ai.connectionSuccess":"Connection","setting.ai.isBeta":"This feature is still in beta and may be unstable or have issues. Please use with caution.","setting.ai.isDanger":"Using AI services may incur costs, and some features may consume a significant number of tokens. Please ensure you understand the associated cost structure and monitor the usage accordingly.","setting.ai.responseTime":"Response Time","setting.ai.setUsageLimit":"Set Usage Limit","setting.ai.setUsageLimitDesc":"Maximum number of tokens allowed (set to 0 for unlimited)","setting.ai.testAIConfig":"Test Configuration","setting.ai.testing":"Testing...","setting.ai.tokens":"tokens","setting.content.addHeader":"Add Header","setting.content.addLangMapping":"Add Mapping","setting.content.aiSummary":"AI Summary","setting.content.aiSummaryPrompt":"Summary Prompt","setting.content.aiSummaryPromptDesc":"Custom system prompt for AI summarization","setting.content.aiSummaryPromptPlaceholder":"You are a summarizer. Generate a concise summary of the given text. Output ONLY the summary, nothing else.","setting.content.aiTranslation":"AI Translation","setting.content.aiTranslationPrompt":"Translation Prompt","setting.content.aiTranslationPromptDesc":"Custom system prompt for AI translation","setting.content.aiTranslationPromptPlaceholder":"You are a translator. Translate the given text accurately. Output ONLY the translated text, nothing else.","setting.content.apiLangCode":"API Code","setting.content.baiduAppId":"Baidu App ID","setting.content.baiduAppIdDesc":"Enter the Baidu Translate App ID","setting.content.baiduAppIdPlaceholder":"Enter your App ID","setting.content.baiduSecretKey":"Baidu Secret Key","setting.content.baiduSecretKeyDesc":"Enter the Baidu Translate Secret Key","setting.content.baiduSecretKeyPlaceholder":"Enter your Secret Key","setting.content.baiduTranslate":"Baidu Translate","setting.content.microsoftApiKey":"Microsoft API Key","setting.content.microsoftApiKeyDesc":"Enter the Microsoft Translator API key","setting.content.microsoftApiKeyPlaceholder":"Enter your Microsoft API key","setting.content.microsoftRegion":"Region","setting.content.microsoftRegionDesc":"Azure resource region (required for multi-service resources)","setting.content.microsoftRegionPlaceholder":"e.g., eastasia","setting.content.microsoftEndpoint":"Custom Endpoint","setting.content.microsoftEndpointDesc":"Custom API endpoint (leave empty to use official endpoint)","setting.content.microsoftEndpointPlaceholder":"https://api.cognitive.microsofttranslator.com","setting.content.microsoftTranslate":"Microsoft Translator","setting.content.tencentSecretId":"Tencent Cloud Secret ID","setting.content.tencentSecretIdDesc":"Enter your Tencent Cloud Secret ID","setting.content.tencentSecretIdPlaceholder":"Enter your Secret ID","setting.content.tencentSecretKey":"Tencent Cloud Secret Key","setting.content.tencentSecretKeyDesc":"Enter your Tencent Cloud Secret Key","setting.content.tencentSecretKeyPlaceholder":"Enter your Secret Key","setting.content.tencentRegion":"Region","setting.content.tencentRegionDesc":"Select the Tencent Cloud service region","setting.content.tencentTranslate":"Tencent Cloud Translate","setting.content.clearSummaryCache":"Clear Summary Cache","setting.content.clearSummaryCacheButton":"Clear","setting.content.clearSummaryCacheConfirm":"Are you sure you want to clear all summary cache? This action cannot be undone.","setting.content.clearSummaryCacheDesc":"Delete all cached summaries","setting.content.clearSummaryCacheFailed":"Failed to clear summary cache","setting.content.clearSummaryCacheSuccess":"Successfully cleared summary cache","setting.content.clearTranslationCache":"Clear Translation Cache","setting.content.clearTranslationCacheButton":"Clear","setting.content.clearTranslationCacheConfirm":"Are you sure you want to clear all translation cache? This action cannot be undone.","setting.content.clearTranslationCacheDesc":"Delete all cached translations","setting.content.clearTranslationCacheFailed":"Failed to clear translation cache","setting.content.clearTranslationCacheSuccess":"Successfully cleared translation cache","setting.content.deeplApi":"DeepL Translate","setting.content.deeplApiKey":"DeepL API Key","setting.content.deeplApiKeyDesc":"Enter the DeepL API key for translation","setting.content.deeplApiKeyPlaceholder":"Enter your DeepL API key","setting.content.deeplEndpoint":"Custom Endpoint (deeplx)","setting.content.deeplEndpointDesc":"Self hosted deeplx service URL (leave empty for official DeepL API)","setting.content.deeplEndpointPlaceholder":"http://localhost:1188","setting.content.enableSummary":"Enable Auto Summary","setting.content.enableSummaryDesc":"Automatically generate article summaries","setting.content.enableTranslation":"Enable Translation","setting.content.enableTranslationDesc":"Automatically translate article titles to the preferred language","setting.content.generateSummary":"Generate Summary","setting.content.generatingAISummary":"Generating AI summary...","setting.content.generatingSummary":"Generating summary...","setting.content.googleTranslate":"Google Translate","setting.content.googleTranslateEndpoint":"Google Translate Endpoint","setting.content.googleTranslateEndpointAlternate":"Alternate (clients5.google.com)","setting.content.googleTranslateEndpointDefault":"Default (translate.googleapis.com)","setting.content.googleTranslateEndpointDesc":"Select the Google Translate API endpoint to use","setting.content.localAlgorithm":"Local Algorithm","setting.content.noSummaryAvailable":"Summary not available","setting.content.regenerateSummary":"Regenerate","setting.content.retrySummary":"Retry","setting.content.summary":"Summary","setting.content.summaryCredentialsRequired":"AI summary requires API key","setting.content.summaryGenerationFailed":"Summary generation failed","setting.content.summaryLength":"Summary Length","setting.content.summaryLengthDesc":"Control the length of generated summaries","setting.content.summaryLengthLong":"Long","setting.content.summaryLengthMedium":"Medium","setting.content.summaryLengthShort":"Short","setting.content.summaryManualTriggerDesc":"Click the button to generate AI summary","setting.content.summaryProvider":"Summary Provider","setting.content.summaryProviderDesc":"Choose how to generate article summaries","setting.content.rssSummary":"RSS Summary","setting.content.summaryTooShort":"Article is too short to generate a meaningful summary","setting.content.summaryTriggerMode":"Trigger Mode","setting.content.summaryTriggerModeAuto":"Auto Trigger","setting.content.summaryTriggerModeDesc":"How AI summaries are triggered","setting.content.summaryTriggerModeManual":"Manual Trigger","setting.content.targetLanguage":"Target Language","setting.content.targetLanguageDesc":"Language to translate article titles to","setting.content.translatingContent":"Translating content...","setting.content.translation":"Translation","setting.content.translationCredentialsRequired":"Translation service requires API key or credentials","setting.content.translationOnlyMode":"Translation Only Mode","setting.content.translationOnlyModeDesc":"Show only translated text, hide original content","setting.content.translationProvider":"Translation Provider","setting.content.translationProviderDesc":"Choose the translation service to use","setting.content.translationSkippedAlreadyTarget":"Translation skipped","setting.content.custom.headerName":"Header name","setting.content.custom.headerValue":"Value","setting.content.custom.mrssLangCode":"en, zh, ...","setting.content.custom.selectTemplate":"Select Template","setting.translation.custom.bodyTemplate":"Request Body Template","setting.translation.custom.bodyTemplateDesc":"Use placeholders in your request body","setting.translation.custom.bodyTemplatePlaceholder":"Enter request body template","setting.translation.custom.endpoint":"API Endpoint","setting.translation.custom.endpointDesc":"The API endpoint URL for the translation service","setting.translation.custom.endpointPlaceholder":"https://api.example.com/translate","setting.translation.custom.headers":"HTTP Headers","setting.translation.custom.headersDesc":"Custom HTTP headers","setting.translation.custom.langMapping":"Language Code Mapping","setting.translation.custom.langMappingDesc":"Map MrRSS language codes to API-specific codes","setting.translation.custom.method":"HTTP Method","setting.translation.custom.methodDesc":"HTTP method for the API request","setting.translation.custom.responsePath":"Response Path","setting.translation.custom.responsePathDesc":"JSONPath to extract translation (e.g., data.translatedText)","setting.translation.custom.responsePathPlaceholder":"data","setting.translation.custom.template":"Preset Templates","setting.translation.custom.templateDesc":"Load preset configuration for common services","setting.translation.custom.timeout":"Timeout","setting.translation.custom.timeoutDesc":"Request timeout in seconds","setting.translation.custom.title":"Custom API","setting.typography.layoutMode":"Article List Layout","setting.typography.layoutModeDesc":"Choose the layout style for the article list","setting.typography.layoutModeNormal":"Normal","setting.typography.layoutModeCompact":"Compact","setting.typography.layoutModeCard":"Card","setting.typography.contentFontFamily":"Content Font Family","setting.typography.contentFontFamilyDesc":"Font family for article content","setting.typography.contentFontSize":"Content Font Size","setting.typography.contentFontSizeDesc":"Font size for article content","setting.typography.contentLineHeight":"Content Line Height","setting.typography.contentLineHeightDesc":"Line spacing for article content","setting.typography.fontMonospace":"Monospace","setting.typography.fontMonospaceDefault":"Default Monospace","setting.typography.fontSansSerif":"Sans Serif","setting.typography.fontSansSerifDefault":"Default Sans Serif","setting.typography.fontSerif":"Serif","setting.typography.fontSerifDefault":"Default Serif","setting.typography.fontSystem":"System Font","setting.typography.fontSystemDefault":"System Default","setting.customization.css":"Custom CSS for Articles","setting.customization.cssApplied":"Custom CSS is active","setting.customization.cssDeleteFailed":"Failed to delete CSS file","setting.customization.cssDeleted":"CSS file deleted successfully","setting.customization.cssDesc":"Upload a custom CSS file to style article content in rendered view","setting.customization.cssGuide":"View Custom CSS Guide","setting.customization.cssUpload":"Upload CSS","setting.customization.cssUploadFailed":"Failed to upload CSS file","setting.customization.cssUploaded":"CSS file uploaded successfully","setting.customization.deleteCSS":"Delete CSS","setting.customization.script":"Custom Script","setting.customization.scriptDoc":"View Documentation","setting.customization.scriptsFolder":"Open Scripts Folder","setting.customization.scriptsFolderOpened":"Scripts folder opened","setting.customization.scriptsNotFound":"No scripts found in the scripts folder.","setting.customization.selectScript":"Select Script","setting.customization.selectScriptPlaceholder":"Select a script...","setting.database.articleContentCacheCleanup":"Article Content Cache","setting.database.articleContentCacheCleanupDesc":"Clear all cached article content","setting.database.autoCleanup":"Auto Cleanup","setting.database.autoCleanupDesc":"Automatically remove old articles to save space","setting.database.clean":"Clean","setting.database.cleanDatabase":"Clean Database","setting.database.cleanDatabaseMessage":"This will delete unread articles that are neither favorited nor saved for later. Read, favorited, and read-later articles will be preserved. Continue?","setting.database.cleanDatabaseTitle":"Clean Database","setting.database.cleaning":"Cleaning...","setting.database.cleanupArticleContentCache":"Clean Now","setting.database.cleanupMediaCache":"Clean Now","setting.database.currentCacheSize":"Current cache size","setting.database.currentCachedArticles":"Current cached articles","setting.database.dataManagement":"Data Management","setting.database.days":"days","setting.database.maxArticleAge":"Max Article Age","setting.database.maxArticleAgeDesc":"Delete articles older than this many days (except favorites)","setting.database.maxCacheSize":"Max Cache Size","setting.database.maxCacheSizeDesc":"Maximum database size before cleanup","setting.database.mediaCacheCleanup":"Clean Media Cache","setting.database.mediaCacheCleanupDesc":"Remove old cached media files","setting.database.mediaCacheEnabled":"Enable Media Cache","setting.database.mediaCacheEnabledDesc":"Cache media files locally to avoid broken links from anti-hotlinking protection and fix image loading issues","setting.database.mediaCacheMaxAge":"Max Cache Age","setting.database.mediaCacheMaxAgeDesc":"Delete cached media older than this many days","setting.database.mediaCacheMaxSize":"Max Cache Size","setting.database.mediaCacheMaxSizeDesc":"Maximum media cache size","setting.database.clearArticleContentCacheConfirm":"Are you sure you want to clear all article content cache? This action cannot be undone.","setting.database.clearMediaCacheConfirm":"Are you sure you want to clear all media cache? This action cannot be undone.","setting.feed.addFeed":"Add Feed","setting.feed.articleViewMode":"Article View Mode","setting.feed.articleViewModeDesc":"Choose how articles from this feed should be displayed","setting.feed.autoExpandContent":"Auto Expand Content","setting.feed.autoExpandContentDesc":"Override global full-text fetch and auto-expand settings for this feed","setting.feed.enableFullTextFetch":"Enable Full-Text Fetching","setting.feed.enableFullTextFetchDesc":"Allow fetching full article content from original websites when RSS provides only summaries","setting.feed.fixedInterval":"Fixed Interval","setting.feed.imageMode":"Multimedia Mode","setting.feed.imageModeDesc":"Display this feed in multimedia gallery view instead of article list","setting.feed.intelligentInterval":"Intelligent Interval","setting.feed.neverRefresh":"Never Refresh","setting.feed.refreshMode":"Refresh Mode","setting.feed.refreshModeDesc":"Choose how often to refresh all subscriptions","setting.feed.retryTimeout":"Timeout","setting.feed.retryTimeoutDesc":"Time to wait before marking refresh as failed","setting.feed.useCustomInterval":"Custom Interval","setting.feed.useGlobalRefresh":"Use Global Setting","setting.feed.useGlobalSettings":"Use Global Settings","setting.feed.useIntelligentInterval":"Intelligent Interval","setting.general.application":"Application","setting.general.auto":"Auto (Follow System)","setting.general.closeToTray":"Minimize to Tray on Close","setting.general.closeToTrayDesc":"Hide the window to the system tray instead of quitting","setting.general.dark":"Dark","setting.general.language":"Language","setting.general.languageDesc":"Select interface language","setting.general.light":"Light","setting.general.uiFontFamily":"Interface Font Family","setting.general.uiFontFamilyDesc":"Font family for lists, controls, settings, and dialogs","setting.general.uiFontSize":"Interface Font Size","setting.general.uiFontSizeDesc":"Base font size for the application interface","setting.general.startupOnBoot":"Start on System Boot","setting.general.startupOnBootDesc":"Automatically start MrRSS when the computer starts","setting.general.theme":"Theme","setting.general.themeDesc":"Choose the preferred color scheme","setting.network.bandwidthLabel":"Bandwidth","setting.network.bandwidthMbps":"Mbps","setting.network.detectionComplete":"Network detection complete","setting.network.detectionFailed":"Network detection failed","setting.network.enableProxy":"Enable Proxy","setting.network.enableProxyDesc":"Use a proxy server for fetching feeds and articles","setting.network.httpProxy":"HTTP","setting.network.httpsProxy":"HTTPS","setting.network.invalidProxyUrl":"Invalid proxy URL format","setting.network.noProxy":"No Proxy","setting.network.proxyHost":"Proxy Host","setting.network.proxyHostDesc":"Proxy server hostname or IP address","setting.network.proxyHostPlaceholder":"proxy.example.com","setting.network.proxyPassword":"Proxy Password","setting.network.proxyPasswordDesc":"Password for proxy authentication","setting.network.proxyPasswordPlaceholder":"password","setting.network.proxyPort":"Proxy Port","setting.network.proxyPortDesc":"Proxy server port number","setting.network.proxyPortPlaceholder":"8080","setting.network.proxySettings":"Proxy Settings","setting.network.proxyType":"Proxy Type","setting.network.proxyTypeDesc":"Select the proxy protocol to use","setting.network.proxyUsername":"Proxy Username","setting.network.proxyUsernameDesc":"Username for proxy authentication","setting.network.proxyUsernamePlaceholder":"username","setting.network.socks5Proxy":"SOCKS5","setting.network.systemProxyInfo":"The app automatically uses the operating system's proxy settings by default. You only need to enable this option if you want to use a different proxy than the system proxy.","setting.network.tunModeInfo":"If you are using a proxy tool (such as Clash, V2Ray, etc.), please ensure TUN mode or Enhanced mode is enabled to allow all applications to use the proxy.","setting.network.tunModeInfoTitle":"Can not connect to the Internet?","setting.network.useCustomProxy":"Use Custom Proxy","setting.network.useGlobalProxy":"Use Global Proxy","setting.network.lastDetection":"Last Detection","setting.network.latencyLabel":"Latency","setting.network.latencyMs":"ms","setting.network.networkSettings":"Network Settings","setting.network.networkSettingsDescription":"Automatic network speed detection to optimize parallel feed refresh performance","setting.network.reDetectNetwork":"Re-detect","setting.freshrss.apiPassword":"API Password","setting.freshrss.apiPasswordDesc":"FreshRSS API password (different from login password)","setting.freshrss.apiPasswordPlaceholder":"Enter your API password","setting.freshrss.daysAgo":"{count} days ago","setting.freshrss.disableConfirm":"Disabling FreshRSS will delete local FreshRSS feeds and articles. This action cannot be undone. Are you sure you want to continue?","setting.freshrss.enabled":"FreshRSS Integration","setting.freshrss.enabledDesc":"Sync feeds and articles with a FreshRSS server","setting.freshrss.hoursAgo":"{count} hours ago","setting.freshrss.minsAgo":"{count} minutes ago","setting.freshrss.syncFailed":"Sync failed","setting.freshrss.feedLocked":"FreshRSS feed cannot be edited, moved, or modified","setting.freshrss.justNow":"Just now","setting.freshrss.lastSync":"Last Sync","setting.freshrss.never":"Never","setting.freshrss.serverUrl":"Server URL","setting.freshrss.serverUrlDesc":"FreshRSS server endpoint (without /api path)","setting.freshrss.serverUrlPlaceholder":"https://freshrss.example.com","setting.freshrss.sync":"Sync Now","setting.freshrss.syncedFeed":"Synced from FreshRSS","setting.freshrss.syncing":"Syncing...","setting.freshrss.syncNow":"Sync Subscription Status","setting.freshrss.syncNowDesc":"Synchronize feed and article statuses bidirectionally","setting.freshrss.syncStarted":"Sync started","setting.freshrss.username":"Username","setting.freshrss.usernameDesc":"The FreshRSS username","setting.freshrss.usernamePlaceholder":"Enter your username","setting.plugins.notion.apiKey":"API Key","setting.plugins.notion.apiKeyDesc":"Internal Integration Token from Notion","setting.plugins.notion.apiKeyPlaceholder":"xxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","setting.plugins.notion.exported":"Article successfully exported to Notion","setting.plugins.notion.exportFailed":"Failed to export to Notion","setting.plugins.notion.exporting":"Exporting to Notion","setting.plugins.notion.exportTo":"Export to Notion","setting.plugins.notion.integration":"Notion Integration","setting.plugins.notion.integrationDescription":"Export articles directly to Notion","setting.plugins.notion.pageId":"Note Page ID","setting.plugins.notion.pageIdDesc":"The ID of the Notion page where articles will be created as sub-pages","setting.plugins.notion.pageIdPlaceholder":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","setting.plugins.notion.setupInstructions":"To set up Notion integration:","setting.plugins.notion.step1":"Go to notion.so/my-integrations and create a new integration","setting.plugins.notion.step2":"Copy the Internal Integration Token as your API Key","setting.plugins.notion.step3":"Open the note page in Notion and click \"...\" â \"Connections\" â Add your integration","setting.plugins.notion.step4":"Copy the page ID from the URL (the 32-character string after the page name)","setting.plugins.obsidian.exported":"Article successfully exported to Obsidian","setting.plugins.obsidian.exportFailed":"Failed to export to Obsidian","setting.plugins.obsidian.exporting":"Exporting to Obsidian","setting.plugins.obsidian.exportTo":"Export to Obsidian","setting.plugins.obsidian.integration":"Obsidian Integration","setting.plugins.obsidian.integrationDescription":"Export articles directly to the Obsidian vault","setting.plugins.obsidian.vaultName":"Vault Name","setting.plugins.obsidian.vaultNameDesc":"Name of the Obsidian vault","setting.plugins.obsidian.vaultNamePlaceholder":"My Vault","setting.plugins.obsidian.vaultPath":"Vault Path","setting.plugins.obsidian.vaultPathDesc":"Full path to the Obsidian vault directory","setting.plugins.zotero.apiKey":"API Key","setting.plugins.zotero.apiKeyDesc":"Zotero API key from your account settings","setting.plugins.zotero.apiKeyPlaceholder":"Enter your Zotero API key","setting.plugins.zotero.exported":"Article successfully exported to Zotero","setting.plugins.zotero.exportFailed":"Failed to export to Zotero","setting.plugins.zotero.exporting":"Exporting to Zotero...","setting.plugins.zotero.exportTo":"Export to Zotero","setting.plugins.zotero.integration":"Zotero Integration","setting.plugins.zotero.integrationDescription":"Export articles directly to your Zotero library","setting.plugins.zotero.setupInstructions":"To set up Zotero integration:","setting.plugins.zotero.step1":"Go to zotero.org/settings/keys and create a new key","setting.plugins.zotero.step2":"Enter the key with write permissions enabled","setting.plugins.zotero.step3":"Find your user ID in your Zotero library feed URL or profile","setting.plugins.zotero.step4":"Enter your user ID below","setting.plugins.zotero.userId":"User ID","setting.plugins.zotero.userIdDesc":"Your Zotero user ID (numeric)","setting.plugins.zotero.userIdPlaceholder":"12345678","setting.reading.autoShowAllContent":"Auto Show All Content","setting.reading.autoShowAllContentDesc":"Automatically display the full content of all articles when viewed as rendered content (may increase loading time)","setting.reading.defaultViewMode":"Article View Mode","setting.reading.defaultViewModeDesc":"Choose how articles should be displayed","setting.reading.hideAdvancedSettings":"Hide Advanced Settings","setting.reading.hideFromTimeline":"Hide from Timeline","setting.reading.hideFromTimelineDesc":"Hide this feed's articles from \"All Articles\" and \"Unread\" views","setting.reading.hideText":"Hide Text","setting.reading.hideTranslations":"Hide Translations","setting.reading.showText":"Show Text","setting.reading.hoverMarkAsRead":"Hover to Mark as Read","setting.reading.hoverMarkAsReadDesc":"Automatically mark articles as read when hovering over them (does not apply to Read Later articles)","setting.reading.imageGalleryEnabled":"Enable Multimedia Gallery","setting.reading.imageGalleryEnabledDesc":"Enable multimedia waterfall mode for multimedia-focused feeds","setting.reading.showAdvancedSettings":"Show Advanced Settings","setting.reading.showArticlePreviewImages":"Show Preview Images","setting.reading.showArticlePreviewImagesDesc":"Display preview images in the article list","setting.reading.showFloatingToc":"Show Floating TOC","setting.reading.showFloatingTocDesc":"Show a desktop floating table of contents in article reading view","setting.reading.showHiddenArticles":"Show Hidden Articles","setting.reading.showHiddenArticlesDesc":"Show articles hidden in the All Articles list","setting.reading.showOnlyUnread":"Show only unread articles","setting.reading.showAllArticles":"Show all articles","setting.reading.showOriginal":"Original","setting.reading.showTranslations":"Show Translations","setting.reading.viewAsRendered":"View as Rendered Content","setting.reading.viewAsWebpage":"View as Webpage","setting.rsshub.apiKey":"API Key","setting.rsshub.apiKeyDesc":"API key for private RSSHub instance","setting.rsshub.cannotDisableWithFeeds":"Cannot disable RSSHub while there are active RSSHub feeds","setting.rsshub.connectionFailed":"Connection failed","setting.rsshub.connectionSuccessful":"Connection successful","setting.rsshub.enabled":"RSSHub Integration","setting.rsshub.enabledDesc":"Use RSSHub for custom RSS routes","setting.rsshub.endpoint":"RSSHub Endpoint","setting.rsshub.endpointDesc":"Your RSSHub server address","setting.rsshub.feed":"RSSHub Feed","setting.rsshub.notSuggestOfficial":"Visit https://docs.rsshub.app/guide/instances for available public instances or deploy your own instance.","setting.rsshub.optional":"Optional","setting.rsshub.testConnection":"Test Connection","setting.rsshub.testConnectionDesc":"Verify RSSHub endpoint and credentials","setting.rsshub.testing":"Testing...","setting.rsshub.urlPlaceholder":"RSS route (supporting rsshub:// protocol)","setting.rule.actionFavorite":"Add to Favorites","setting.rule.actionHide":"Hide Article","setting.rule.actionMarkRead":"Mark as Read","setting.rule.actionMarkUnread":"Mark as Unread","setting.rule.actionReadLater":"Add to Read Later","setting.rule.actionRemoveReadLater":"Remove from Read Later","setting.rule.actionUnfavorite":"Remove from Favorites","setting.rule.actionUnhide":"Unhide Article","setting.rule.addRule":"Add Rule","setting.rule.applyRuleNow":"Apply Now","setting.rule.noActionsSelected":"Please select at least one action","setting.rule.noRules":"No rules defined","setting.rule.noRulesHint":"Create a rule to automatically process articles","setting.rule.removeAction":"Remove Action","setting.rule.removeCondition":"Remove","setting.shortcut.addFeedShortcut":"Add Feed","setting.shortcut.focusFeedSearch":"Focus Feed Search","setting.shortcut.openSettingsShortcut":"Open Settings","setting.shortcut.shortcuts":"Shortcuts","setting.shortcut.shortcutsCleared":"Shortcut cleared","setting.shortcut.shortcutsConflict":"This shortcut is already in use","setting.shortcut.shortcutsDesc":"Customize keyboard shortcuts for common actions","setting.shortcut.shortcutsEnabled":"Enable Shortcuts","setting.shortcut.shortcutsEnabledDesc":"Enable or disable keyboard shortcuts","setting.shortcut.shortcutsUpdated":"Shortcut updated","setting.shortcut.notSet":"Not set","setting.statistic.aiChats":"AI Chats","setting.statistic.aiSummaries":"AI Summaries","setting.statistic.allTime":"All Time","setting.statistic.articlesFavorited":"Articles Favorited","setting.statistic.articlesRead":"Articles Read","setting.statistic.articlesViewed":"Articles Viewed","setting.statistic.byMonth":"By Month","setting.statistic.byWeek":"By Week","setting.statistic.byYear":"By Year","setting.statistic.customRange":"Custom Range","setting.statistic.description":"View your usage statistics over time","setting.statistic.endDate":"End Date","setting.statistic.resetConfirm":"Are you sure you want to reset all usage statistics? This action cannot be undone.","setting.statistic.resetFailed":"Failed to reset statistics","setting.statistic.resetSuccess":"Statistics reset successfully","setting.statistic.resetToDefault":"Reset usage statistics","setting.statistic.startDate":"Start Date","setting.statistic.statistics":"Statistics","setting.tab.about":"About","setting.tab.ai":"AI","setting.tab.articleDisplay":"Article Display","setting.tab.content":"Content","setting.tab.contentSettings":"Content Settings","setting.tab.customization":"Customization","setting.tab.general":"General","setting.tab.interactionSettings":"Interaction Settings","setting.tab.network":"Network","setting.tab.plugins":"Plugins","setting.tab.readingAndDisplay":"Reading","setting.tab.settings":"Settings","setting.tab.settingsTitle":"Settings","setting.tab.typography":"Typography","setting.update.autoUpdateInterval":"Auto Update Interval","setting.update.autoUpdateIntervalDesc":"Interval for automatic update checks","setting.update.checkForUpdates":"Check for Updates","setting.update.currentVersion":"Current version","setting.update.installFailed":"Installation failed","setting.update.installingUpdate":"Installing update...","setting.update.latestVersion":"Latest version","setting.update.noInstallerAvailable":"No installer available for your platform. Please download manually from","setting.update.notNow":"Not Now","setting.update.updateCheckEnabled":"Check for Updates on Startup","setting.update.updateCheckEnabledDesc":"Show an update prompt automatically when a new version is available","setting.update.updateAvailable":"Update available","setting.update.updateFailed":"Last update failed","setting.update.updateNow":"Update Now","setting.update.updates":"Updates","setting.update.updateSuccess":"Last update successful","setting.update.updateWillRestart":"The application will restart to install the update","setting.update.upToDate":"You are using the latest version","sidebar.activity.addFeed":"Add Feed","sidebar.activity.allArticles":"All Articles","sidebar.activity.collapseActivityBar":"Collapse Activity Bar","sidebar.activity.collapseFeedList":"Collapse Feed List","sidebar.activity.expandActivityBar":"Expand Activity Bar","sidebar.activity.expandFeedList":"Expand Feed List","sidebar.activity.favorites":"Favorites","sidebar.activity.imageGallery":"Multimedia Gallery","sidebar.activity.immediateTasks":"Immediate Tasks","sidebar.activity.lastGlobalRefresh":"Last Update","sidebar.activity.queuedTasks":"Queued Tasks","sidebar.activity.readLater":"Read Later","sidebar.activity.unreadArticles":"Unread Articles","sidebar.feedList.articles":"Articles","sidebar.feedList.feeds":"Feeds","sidebar.feedList.pin":"Pin","sidebar.feedList.recentArticles":"Recent Articles","sidebar.feedList.uncategorized":"Uncategorized","sidebar.feedList.unpin":"Unpin","sidebar.feedList.unread":"Unread","sidebar.savedFilters.title":"Saved Filters","sidebar.savedFilters.saveFilter":"Save Filter","sidebar.savedFilters.editFilter":"Edit Filter","sidebar.savedFilters.filterName":"Filter Name","sidebar.savedFilters.filterNamePlaceholder":"e.g., Tech News from Last Week","sidebar.savedFilters.saveCurrentFilter":"Save Current Filter","sidebar.savedFilters.nameRequired":"Please enter a filter name","sidebar.savedFilters.conditionsRequired":"Please add at least one condition","sidebar.savedFilters.filterSaved":"Filter saved successfully","sidebar.savedFilters.saveFailed":"Failed to save filter","sidebar.savedFilters.filterUpdated":"Filter updated successfully","sidebar.savedFilters.updateFailed":"Failed to update filter","sidebar.savedFilters.filterDeleted":"Filter deleted successfully","sidebar.savedFilters.deleteFailed":"Failed to delete filter","sidebar.savedFilters.deleteConfirmTitle":"Delete Filter","sidebar.savedFilters.deleteConfirmMessage":"Are you sure you want to delete \"{name}\"?","sidebar.savedFilters.save":"Save","sidebar.sort.byArticlesPerMonth":"Sort by update frequency","sidebar.sort.byCategory":"Category","sidebar.sort.byLatestArticle":"Sort by latest article time","sidebar.sort.byName":"Name","sidebar.sort.byUpdateStatus":"Sort by update status","sidebar.sort.frequency":"Frequency","sidebar.sort.latest":"Latest","shortcut.category.articles":"Articles","shortcut.category.navigation":"Navigation","shortcut.category.other":"Other","shortcut.pressKey":"Press key...","shortcut.toggle.contentView":"Content View","shortcut.toggle.favoritesFilter":"Toggle Favorites Filter","shortcut.toggle.filter":"Toggle Article Filter","shortcut.toggle.readLaterFilter":"Toggle Read Later Filter","shortcut.toggle.readLaterStatus":"Toggle Read Later","shortcut.toggle.readStatus":"Toggle Read Status","shortcut.toggle.sidebar":"Toggle Sidebar","shortcut.toggle.unreadFilter":"Toggle Unread Filter","appName":"MrRSS"}
+"""#
+
+ static let chineseSimplified = #"""
+{"article.action.addToFavorite":"æ·ŧå å°æķč","article.action.addToReadLater":"æ·ŧå å°įĻåé čŊŧ","article.action.backToUrl":"čŋå URL","article.action.closeArticle":"å ģéæįŦ ","article.action.fetchFullArticle":"č·åå Ļæ","article.action.fetchingFullArticle":"æĢåĻæååŪæīæįŦ ...","article.action.fullArticleFetched":"åŪæīæįŦ å åŪđå·ēå č――","article.action.hideArticle":"éčæįŦ ","article.action.markAboveAsRead":"å°äŧĨäļæ čŪ°äļšå·ēčŊŧ","article.action.markAllAsReadFeed":"å ĻéĻå·ēčŊŧ","article.action.markAllRead":"å ĻéĻå·ēčŊŧ","article.action.markAllReadShortcut":"å ĻéĻæ čŪ°äļšå·ēčŊŧ","article.action.markAllReadConfirmMessage":"įĄŪåŪčĶå°æææįŦ æ čŪ°äļšå·ēčŊŧåïž","article.action.markAllReadConfirmTitle":"å ĻéĻå·ēčŊŧ","article.action.markAboveReadConfirmMessage":"įĄŪåŪčĶ尿οįŦ äļæđįæææįŦ æ čŪ°äļšå·ēčŊŧåïž","article.action.markAboveReadConfirmTitle":"å°äŧĨäļæ čŪ°äļšå·ēčŊŧ","article.action.markBelowReadConfirmMessage":"įĄŪåŪčĶ尿οįŦ äļæđįæææįŦ æ čŪ°äļšå·ēčŊŧåïž","article.action.markBelowReadConfirmTitle":"å°äŧĨäļæ čŪ°äļšå·ēčŊŧ","article.action.markAsRead":"æ čŪ°äļšå·ēčŊŧ","article.action.markAsUnread":"æ čŪ°äļšæŠčŊŧ","article.action.markBelowAsRead":"å°äŧĨäļæ čŪ°äļšå·ēčŊŧ","article.action.markedAllAsRead":"æææįŦ å·ēæ čŪ°äļšå·ēčŊŧ","article.action.markedNArticlesAsRead":"å·ēæ čŪ° {count} įŊæįŦ äļšå·ēčŊŧ","article.action.noArticlesToMark":"æēĄæåŊæ čŪ°įæįŦ ","article.action.openArticle":"æåžæįŦ ","article.action.openInBrowser":"åĻæĩč§åĻäļæåž","article.action.openInBrowserShortcut":"åĻæĩč§åĻäļæåž","article.action.refresh":"å·æ°","article.action.refreshFeed":"å·æ°čŪĒé ","article.action.refreshFeedsShortcut":"å·æ°čŪĒé ","article.action.reloadContent":"éæ°å č――æįŦ å åŪđ","article.action.refreshing":"å·æ°äļ","article.action.removeFromFavorite":"åæķæķč","article.action.removeFromFavorites":"äŧæķčäļį§ŧéĪ","article.action.removeFromReadLater":"äŧįĻåé čŊŧäļį§ŧéĪ","article.action.toggleFavoriteStatus":"åæĒæķč","article.action.unhideArticle":"åæķéč","article.action.viewArticle":"æĨįæįŦ ","article.action.viewContent":"æĨįå åŪđ","article.action.viewImage":"æĨįåūį","article.action.viewModeOriginal":"äŧĨį―éĄĩæĨį","article.action.viewModeRendered":"äŧĨæļēææĨį","article.action.viewModeExternal":"åĻæĩč§åĻäļæåž","article.action.viewOriginal":"æĨįåæ","article.audioPlayer.audioPlaybackError":"æ æģææūéģéĒãæäŧķåŊč―äļåŊįĻææ žåžäļåæŊæã","article.audioPlayer.pause":"æå","article.audioPlayer.play":"ææū","article.audioPlayer.playbackSpeed":"ææūéåšĶ","article.audioPlayer.podcastAudio":"æåŪĒéģéĒ","article.audioPlayer.skipBackward":"åé 10 į§","article.audioPlayer.skipForward":"åčŋ 10 į§","article.audioPlayer.volume":"éģé","article.chat.aiChat":"AI čåĪĐ","article.chat.aiChatError":"æ æģč·å AI ååšïžčŊ·éčŊã","article.chat.historySaveFailed":"åįå·ēįæïžä―æŠč―äŋåå°ååēčŪ°å―ã","article.chat.aiChatInputPlaceholder":"čūå ĨæķæŊ...","article.chat.aiChatWelcome":"čŊ·éŪå ģäščŋįŊæįŦ įäŧŧä―éŪéĒïž","article.chat.confirmDeleteSession":"įĄŪåŪčĶå éĪčŋäļŠåŊđčŊåïž","article.chat.hideThinking":"éčæččŋįĻ","article.chat.newChat":"æ°åŊđčŊ","article.chat.noSessions":"ææ åŊđčŊčŪ°å―","article.chat.showThinking":"æūįĪšæččŋįĻ","article.chat.switchSession":"åæĒåŊđčŊ","article.chat.thinking":"æčäļ","article.content.fetchingArticleContent":"æĢåĻč·å RSS čŪĒé æšäļįæįŦ å åŪđ...","article.content.loadingContent":"å č――å åŪđäļ","article.content.noArticles":"æŠæūå°æįŦ ","article.content.noContentAvailable":"ææ å åŪđ","article.content.renderContent":"æļēæå åŪđ","article.content.selectArticle":"éæĐäļįŊæįŦ åžå§é čŊŧ","article.imageGallery.actionFavorite":"æ·ŧå å°æķč","article.imageGallery.actionUnfavorite":"åæķæķč","article.imageGallery.addToFavorite":"æ·ŧå å°æķč","article.list.markAllVisibleAsRead":"å ĻéĻæ čŪ°äļšå·ēčŊŧ","article.list.allArticlesLoaded":"å·ēå č――å ĻéĻæįŦ ","article.navigation.goToAllArticles":"č―Žå°æææįŦ ","article.navigation.goToFavorites":"č―Žå°æķč","article.navigation.goToReadLater":"č―Žå°įĻåé čŊŧ","article.navigation.goToUnread":"č―Žå°æŠčŊŧ","article.navigation.nextArticle":"äļäļįŊæįŦ ","article.navigation.previousArticle":"äļäļįŊæįŦ ","article.parts.articleTitle":"æįŦ æ éĒ","article.progress.activeTasks":"čŋčĄäļäŧŧåĄ","article.summary.aiLimitReached":"AI ä―ŋįĻéå·ēčūūäļéïžæĢåĻä―ŋįĻå čīđæŋäŧĢæđæĄã","article.summary.aiSummaryFallback":"AI æčĶįæåĪąčīĨïžæĢåĻä―ŋįĻå į―ŪįŪæģã","article.summary.articleSummary":"æįŦ æčĶ","article.summary.articleTooShort":"æįŦ å åŪđčŋį","article.summary.generatingSummaryTime":"įææčĶčæķ {time}","article.summary.originalSummary":"åææčĶ","article.summary.translatedSummary":"įŋŧčŊåįæčĶ","article.summary.translatingSummary":"æĢåĻįŋŧčŊæčĶ...","article.toolbar.addToFavorite":"æ·ŧå å°æķč","article.toolbar.addToReadLater":"æ·ŧå å°įĻåé čŊŧ","article.translation.aiLimitReached":"AI ä―ŋįĻéå·ēčūūäļéïžæĢåĻä―ŋįĻå čīđæŋäŧĢæđæĄã","article.videoPlayer.openInYouTube":"åĻ YouTube äļæåž","article.videoPlayer.videoLoadError":"å č――č§éĒåĪąčīĨïžčŊ·å°čŊåĻ YouTube äļæåžã","article.videoPlayer.youtubeVideo":"YouTube č§éĒ","aiSearch.button":"AI æįīĒ","aiSearch.buttonTitle":"ä―ŋįĻ AI éčŋčŠįķčŊčĻæįīĒæįŦ ","aiSearch.clearResults":"æļ éĪ","aiSearch.foundResults":"æūå° {count} įŊæįŦ ","aiSearch.noResults":"æēĄææūå°įŽĶåæįīĒæĄäŧķįæįŦ ","aiSearch.placeholder":"æčŋ°ä― æģčĶæĨæūįå åŪđ...","aiSearch.relevanceScore":"įļå ģåšĶ {score}","aiSearch.matchFields.title":"æ éĒå―äļ","aiSearch.matchFields.summary":"æčĶå―äļ","aiSearch.matchFields.content":"æĢæå―äļ","aiSearch.searchFailed":"AI æįīĒåĪąčīĨïžčŊ·æĢæĨ AI čŪūį―Ūã","aiSearch.showingResults":"æĢåĻæūįĪš AI æįīĒįŧæ","aiErrors.configuration_invalid":"AI é į―ŪäļåŪæīææ æïžčŊ·æĢæĨæĨåĢå°ååæĻĄåã","aiErrors.usage_limit_reached":"å·ēčūūå° MrRSS čŪūį―Ūį AI ä―ŋįĻäļéïžčŊ·č°æīäļéåéčŊã","aiErrors.rate_limited":"AI æåĄčŊ·æąčŋäšéĒįđïžčŊ·įĻååčŊã","aiErrors.authentication_failed":"AI æåĄéīæåĪąčīĨïžčŊ·æĢæĨ API Key åčŪŋéŪæéã","aiErrors.payment_required":"AI æåĄéĒåšĶäļčķģïžčŊ·æĢæĨæåĄåčīĶæ·ä―éĒã","aiErrors.model_or_endpoint_not_found":"AI æĻĄåææĨåĢå°åäļåŊįĻïžčŊ·æĢæĨé į―Ūã","aiErrors.request_too_large":"åéįŧ AI įå åŪđčŋéŋïžčŊ·įžĐįå åŪđææīæĒæĻĄåã","aiErrors.timeout":"AI æåĄååščķ æķïžčŊ·įĻåéčŊã","aiErrors.network_error":"æ æģčŋæĨ AI æåĄïžčŊ·æĢæĨį―įŧãäŧĢįåæĨåĢå°åã","aiErrors.provider_unavailable":"AI æåĄææķäļåŊįĻïžčŊ·įĻåéčŊã","aiErrors.invalid_response":"AI æåĄčŋåå åŪđæ žåžåžåļļïžčŊ·éčŊææīæĒæĻĄåã","aiErrors.provider_rejected_request":"AI æåĄæįŧäščŊ·æąïžčŊ·æĢæĨæĻĄååæĨåĢé į―Ūã","aiErrors.request_failed":"AI čŊ·æąåĪąčīĨïžčŊ·æĢæĨ AI é į―ŪåéčŊã","common.cancel":"åæķ","common.confirm":"įĄŪčŪĪ","common.save":"äŋå","common.action.add":"æ·ŧå ","common.action.addTags":"æ·ŧå æ įū","common.action.cancel":"åæķ","common.action.confirm":"įĄŪčŪĪ","common.action.deleteSelected":"å éĪéäļ","common.action.deselectAll":"åæķå Ļé","common.action.discard":"æūåž","common.action.downloading":"äļč――äļ...","common.action.escToClear":"æ Escape æļ éĪ","common.action.move":"į§ŧåĻ","common.action.moveFeeds":"į§ŧåĻčŪĒé ","common.action.moveSelected":"į§ŧåĻéäļ","common.action.no":"åĶ","common.action.openWebsite":"æåžį―įŦ","common.action.remove":"į§ŧéĪ","common.action.resetToDefault":"æĒåĪéŧčŪĪ","common.action.save":"äŋå","common.action.saveChanges":"äŋåæīæđ","common.action.saving":"äŋåäļ...","common.action.setImageMode":"čŪūį―ŪäļšåĪåŠä―æĻĄåž","common.action.unsetImageMode":"åæķåĪåŠä―æĻĄåž","common.action.switchTo":"åæĒå°","common.action.unsubscribe":"åæķčŪĒé ","common.action.yes":"æŊ","common.back":"čŋå","common.checking":"æĢæĨäļ...","common.clear":"æļ éĪ","common.clearReadLater":"æļ įĐšįĻåé čŊŧ","common.close":"å ģé","common.connectionFailed":"čŋæĨåĪąčīĨ","common.connectionSuccessful":"čŋæĨæå","common.contextMenu.copyImage":"åĪåķåūį","common.contextMenu.copyLink":"åĪåķéūæĨ","common.contextMenu.copyTitle":"åĪåķæ éĒ","common.contextMenu.downloadAudio":"äļč――éģéĒæäŧķ","common.contextMenu.downloadImage":"äļč――åūį","common.copy":"åĪåķ","common.delete":"å éĪ","common.done":"åŪæ","common.edit":"įžčū","common.error":"éčŊŊ","common.errors.addingFeed":"æ·ŧå čŪĒé æšåĪąčīĨ","common.errors.cleaningDatabase":"æļ įæ°æŪåšæķåšé","common.errors.createFailed":"ååŧšåĪąčīĨ","common.errors.errorCheckingUpdates":"æĢæĨæīæ°æķåšé","common.errors.failedToCopy":"åĪåķåĪąčīĨ","common.errors.failedToOpenLink":"æåžéūæĨåĪąčīĨ","common.errors.failedToTransformRSSHubURL":"č―ŽæĒ RSSHub URL åĪąčīĨ","common.errors.fetchingArticleContent":"éæ°å č――æįŦ å åŪđåĪąčīĨ","common.errors.fetchingFullArticle":"č·ååŪæīæįŦ å åŪđåĪąčīĨ","common.errors.invalidURLScheme":"æ æį URL æ žåž","common.errors.networkErrorCheckingUpdates":"æ æģčŋæĨå° GitHub æåĄåĻãåĶææĻåĻäļå―åΧéïžčŊ·å°čŊä―ŋįĻäŧĢįæVPNã","common.errors.reorderingFeed":"éæ°æåščŪĒé æšåĪąčīĨ","common.errors.savingSettings":"äŋåčŪūį―Ūæķåšé","common.errors.subscribingFeeds":"čŪĒé æķåšé","common.errors.translating":"įŋŧčŊåĪąčīĨãčŊ·æĢæĨį―įŧčŋæĨåįŋŧčŊčŪūį―Ūã","common.errors.translatingContent":"å åŪđįŋŧčŊåĪąčīĨ","common.errors.translatingTitle":"æįŦ æ éĒįŋŧčŊåĪąčīĨ","common.errors.unknownError":"åįæŠįĨéčŊŊ","common.findInPage.findInPagePlaceholder":"åĻæįŦ äļæĨæū...","common.findInPage.nextMatch":"äļäļäļŠåđé ","common.findInPage.previousMatch":"äļäļäļŠåđé ","common.form.apply":"åšįĻ","common.form.category":"åįąŧ","common.form.disabled":"įĶįĻ","common.form.enabled":"åŊįĻ","common.form.requiredField":"æĪåæŪĩäļšåŋ åĄŦéĄđ","common.form.status":"įķæ","common.form.title":"æ éĒ","common.imageViewer.zoomIn":"æūåΧ","common.imageViewer.zoomOut":"įžĐå°","common.language.chinese":"äļæ","common.language.english":"čąčŊ","common.language.french":"æģčŊ","common.language.german":"åū·čŊ","common.language.japanese":"æĨčŊ","common.language.simplifiedChinese":"įŪä―äļæ","common.language.spanish":"čĨŋįįčŊ","common.language.traditionalChinese":"įđä―äļæ","common.language.turkish":"åčģå ķčŊ","common.toast.autoTranslateEnabled":"čŠåĻįŋŧčŊå·ēåŊįĻ","common.toast.clearedReadLater":"įĻåé čŊŧåčĄĻå·ēæļ įĐš","common.toast.copiedToClipboard":"å·ēåĪåķå°åŠčīīæŋ","common.toast.downloadComplete":"äļč――åŪæ","common.toast.downloadFailed":"äļč――åĪąčīĨ","common.pagination.deleting":"å éĪäļ","common.pagination.loading":"å č――äļ","common.pagination.preparing":"ååĪäļ","common.pagination.saving":"äŋåäļ...","common.pagination.uploading":"äļäž äļ","common.state.loading":"å č――äļ...","common.search.itemsSelected":"å·ēé {count} éĄđ","common.search.item":"éĄđ","common.search.noSearchResults":"æēĄæåđé įčŪĒé æš","common.search.searchFeeds":"æįīĒčŪĒé æš...","common.search.selectAll":"å Ļé","common.search.selectItems":"éæĐéĄđįŪ","common.search.totalAndSelected":"å ą {total} äļŠïžå·ēé {selected} äļŠ","common.select.placeholder":"čŊ·éæĐ","common.select.noOptions":"æ ééĄđ","common.select.searchPlaceholder":"æįīĒ...","common.select.noResults":"æŠæūå°įŧæ","common.select.addNew":"æ°åĒ","common.select.addNewPlaceholder":"čūå Ĩæ°åž...","common.input.customValue":"čūå ĨčŠåŪäđåž...","common.time.days":"åĪĐ","common.time.daysAgo":"{count} åĪĐå","common.time.hoursAgo":"{count} å°æķå","common.time.justNow":"åå","common.time.minutes":"åé","common.time.minutesAgo":"{count} åéå","common.time.minutesShort":"åé","common.time.ms":"æŊŦį§","common.time.never":"äŧæŠ","common.time.seconds":"į§","common.time.secondsAgo":"{count} į§å","common.text.andNMore":"ååĒå {count} äļŠ","common.text.forceTranslate":"åžšåķįŋŧčŊ","common.text.image":"åūį","common.text.or":"æ","common.text.orTry":"æč čŊčŊ","common.text.progress":"čŋåšĶïž","common.warning.isInDevelopment":"čŊĨåč―æķåå°įŽŽäļæđå·Ĩå ·ïžåŊč―äļįĻģåŪäļååĻéŪéĒã","modal.common.unsavedChangesMessage":"æĻææŠäŋåįæīæđãčĶæūåžčŋäšæīæđåïž","modal.common.unsavedChangesTitle":"æŠäŋåįæīæđ","modal.discovery.detecting":"æĢæĩäļ...","modal.discovery.checkingRssFeed":"æĢåϿ̿Ĩ RSS čŪĒé æš...","modal.discovery.discoverAllFeeds":"åį°ææčŪĒé æš","modal.discovery.discoverAllFeedsDesc":"čŠåĻäŧææå°æŠæŦæįčŪĒé äļåį°æ°įčŪĒé æš","modal.discovery.discoverFeeds":"åį°čŪĒé æš","modal.discovery.discovering":"åį°čŪĒé æšäļ...","modal.discovery.discoveryFailed":"åį°åĪąčīĨ","modal.discovery.discoveryLongRunningWarning":"å―æĻæåūåĪčŪĒé æšæķïžæĪčŋįĻåŊč―éčĶåūéŋæķéīæč―åŪæãčŊ·æģĻæïžčŋäļæŊå·æ°æĻįčŪĒé æšã","modal.discovery.fetchingFriendPage":"æĢåĻč·ååéūéĄĩéĒ","modal.discovery.fetchingHomepage":"æĢåĻč·åäļŧéĄĩ","modal.discovery.foundFeeds":"å·ēæūå° {count} äļŠčŪĒé æš","modal.discovery.foundPotentialLinks":"å·ēæūå° {count} äļŠæ―åĻįčŪĒé æšéūæĨ","modal.discovery.foundSoFar":"įŪåå·ēæūå° {count} äļŠčŪĒé æš","modal.discovery.noFriendLinksFound":"æŠæūå°åéū","modal.discovery.preparingDiscovery":"ååĪåį°","modal.discovery.processingFeed":"æĢåĻåĪįčŪĒé æš {current}/{total}","modal.discovery.searchingFriendLinks":"æĢåĻæįīĒåéū","modal.discovery.startDiscovery":"åžå§åį°","modal.feed.adding":"æ·ŧå äļ...","modal.feed.addNewFeed":"æ·ŧå æ°čŪĒé ","modal.feed.addSubscription":"æ·ŧå čŪĒé ","modal.feed.categoryPlaceholder":"äūåĶ į§æ/æ°éŧ","modal.feed.deleteFeedMessage":"įĄŪåŪčĶå éĪčŋäļŠčŪĒé åïž","modal.feed.deleteFeedTitle":"å éĪčŪĒé ","modal.feed.deleteMultipleFeedsMessage":"įĄŪåŪčĶå éĪčŋ {count} äļŠčŪĒé åïž","modal.feed.deleteMultipleFeedsTitle":"å éĪåĪäļŠčŪĒé ","modal.feed.dragToReorder":"ææ―åŊäŧĨéæ°æåšæį§ŧåĻå°å ķäŧåįąŧ","modal.feed.duplicateFeedURL":"å·ēååĻįļå URL įčŪĒé ","modal.feed.duplicateFeedSelected":"å·ēč·ģč―Žå°į°æčŪĒé ","modal.feed.editFeed":"įžčūčŪĒé ","modal.feed.editSubscription":"įžčūčŪĒé ","modal.feed.email":"éŪäŧķčŪĒé ","modal.feed.emailAddress":"Newsletter åäŧķäšš","modal.feed.emailAddressHint":"įįĐšåč·åæäŧķåĪđäļįææéŪäŧķ","modal.feed.emailConnectionError":"į―įŧéčŊŊãčŊ·éčŊã","modal.feed.emailFillRequired":"čŊ·åĄŦå IMAP æåĄåĻãįĻæ·åååŊį ã","modal.feed.emailFolder":"éŪäŧķæäŧķåĪđ","modal.feed.emailPassword":"IMAP åŊį ","modal.feed.emailPasswordPlaceholder":"čūå ĨæĻįåŊį æåšįĻäļįĻåŊį ","modal.feed.emailServer":"IMAP æåĄåĻ","modal.feed.emailTestConnection":"æĩčŊčŋæĨ","modal.feed.emailUsername":"IMAP įĻæ·å","modal.feed.enterCategoryName":"čūå Ĩæ°åįąŧåį§°ïž","modal.feed.errorCertificate":"SSL čŊäđĶéčŊŊ","modal.feed.errorConnection":"čŋæĨåĪąčīĨïžčŊ·æĢæĨį―įŧ","modal.feed.errorDNS":"DNS č§ĢæåĪąčīĨ","modal.feed.errorInvalidFormat":"æ æįčŪĒé æ žåž","modal.feed.errorNotFound":"čŪĒé æŠæūå° (404)","modal.feed.errorServer":"æåĄåĻéčŊŊïžčŊ·įĻåéčŊ","modal.feed.errorTimeout":"čŊ·æąčķ æķïžčŊ·æĢæĨį―įŧčŋæĨ","modal.feed.errorUnauthorized":"éčĶčŪĪčŊ (401/403)","modal.feed.feedAddedSuccess":"čŪĒé æ·ŧå æå","modal.feed.feedCategory":"čŪĒé åįąŧ","modal.feed.feedDeletedSuccess":"čŪĒé å éĪæå","modal.feed.articlesDeleted":"å·ēå éĪ {count} įŊæįŦ ","modal.feed.articlesRemoved":"å·ēå éĪ {count} æĄčŪ°å―","modal.feed.filesRemoved":"å·ēå éĪ {count} äļŠæäŧķ","modal.feed.feedDiscovery":"čŪĒé æšåį°","modal.feed.feedName":"čŪĒé åį§°","modal.feed.feedReordered":"čŪĒé æåšæå","modal.feed.feedRefreshStarted":"čŪĒé å·æ°å·ēåžå§","modal.feed.feedsDeletedSuccess":"čŪĒé å éĪæå","modal.feed.feedsMovedSuccess":"čŪĒé į§ŧåĻæå","modal.feed.feedsSubscribedPartial":"éĻåčŪĒé ïž{succeeded}/{total} äļŠčŪĒé æš","modal.feed.feedsSubscribedSuccess":"æåčŪĒé {count} äļŠčŪĒé æš","modal.feed.feedUpdatedSuccess":"čŪĒé æīæ°æå","modal.feed.imageModeSetSuccess":"å·ēäļšéäļįčŪĒé æšåŊįĻåĪåŠä―æĻĄåž","modal.feed.imageModeUnsetSuccess":"å·ēäļšéäļįčŪĒé æšįĶįĻåĪåŠä―æĻĄåž","modal.feed.selectTagsToAdd":"éæĐčĶæ·ŧå įæ įūïž","modal.feed.setImageModeMessage":"äļš {count} äļŠéäļįčŪĒé æšåŊįĻåĪåŠä―æĻĄåžïž","modal.feed.setImageModeTitle":"čŪūį―ŪåĪåŠä―æĻĄåž","modal.feed.tagsAddedSuccess":"æ įūæ·ŧå æå","modal.feed.unsetImageModeMessage":"äļš {count} äļŠéäļįčŪĒé æšįĶįĻåĪåŠä―æĻĄåžïž","modal.feed.unsetImageModeTitle":"åæķåĪåŠä―æĻĄåž","modal.feed.manageFeeds":"įŪĄįčŪĒé ","modal.feed.noFeeds":"ææ čŪĒé ","modal.feed.originalOrder":"åå§éĄšåš","modal.feed.sortAscending":"ååšæå","modal.feed.sortDescending":"éåšæå","modal.feed.loadFailed":"æ æģå č――čŪĒé æš","modal.feed.loadFailedDesc":"čŊ·æĢæĨčŋæĨåéčŊã","modal.feed.loadFailedKeepingData":"å·æ°åĪąčīĨïžå―åæūįĪšäļæŽĄæåå č――įčŪĒé æšã","modal.feed.retry":"éčŊ","modal.feed.proxy":"čŪĒé äŧĢį","modal.feed.proxyDesc":"äļšæĪčŪĒé é į―ŪäŧĢįčŪūį―Ū","modal.feed.proxyHost":"äŧĢįäļŧæš","modal.feed.proxyPassword":"åŊį ","modal.feed.proxyPort":"äŧĢįįŦŊåĢ","modal.feed.proxyType":"äŧĢįįąŧå","modal.feed.proxyUsername":"įĻæ·å","modal.feed.refreshes":"čŪĒé æšå·æ°","modal.feed.refreshInterval":"å·æ°éīé","modal.feed.refreshIntervalDesc":"æĪčŪĒé įčŠåŪäđå·æ°éīé","modal.feed.refreshIntervalPlaceholder":"åé","modal.feed.refreshMode":"å·æ°æĻĄåž","modal.feed.refreshModeDesc":"æĪčŪĒé įå·æ°æđåž","modal.feed.refreshSettings":"čŪĒé æšå·æ°čŪūį―Ū","modal.feed.rssUrl":"RSS éūæĨ","modal.feed.sourceUrl":"æĨæšéūæĨ","modal.feed.sourceUrlPlaceholder":"https://example.com/blog","modal.feed.syncFeed":"åæĨčŪĒé ","modal.feed.syncFeedStarted":"čŪĒé åæĨå·ēåžå§","modal.feed.titlePlaceholder":"čŠåŪäđčŪĒé æ éĒ","modal.feed.typeCustomScript":"čŠåŪäđčæŽ","modal.feed.typeEmail":"éŪäŧķčŪĒé ","modal.feed.typeFreshRSS":"FreshRSS čŪĒé ","modal.feed.typeRegular":"åļļč§čŪĒé ","modal.feed.typeRSSHub":"RSSHub čŪĒé ","modal.feed.typeXPath":"XPath","modal.feed.xpath":"XPath æŊæ","modal.feed.xpathDocumentation":"XPath ææĄĢ","modal.feed.xpathHtml":"HTML + XPath","modal.feed.xpathItem":"æįŦ XPath","modal.feed.xpathItemAuthor":"ä―č XPath","modal.feed.xpathItemCategories":"åįąŧ XPath","modal.feed.xpathItemContent":"å åŪđ XPath","modal.feed.xpathItemHelp":"įĻäšéæĐæįŦ åŪđåĻį XPath čĄĻčūūåž","modal.feed.xpathItemThumbnail":"įžĐįĨåū XPath","modal.feed.xpathItemTimeFormat":"æķéīæ žåž","modal.feed.xpathItemTimestamp":"æķéī XPath","modal.feed.xpathItemTitle":"æ éĒ XPath","modal.feed.xpathItemUid":"UID XPath","modal.feed.xpathItemUri":"éūæĨ XPath","modal.feed.xpathType":"XPath įąŧå","modal.feed.xpathXml":"XML + XPath","modal.feed.renameCategory":"éå―ååįąŧ","modal.feed.subscribeSelected":"čŪĒé éäļ","modal.feed.subscribing":"æĢåĻčŪĒé ","modal.feed.unsubscribedSuccess":"åæķčŪĒé æå","modal.feed.unsubscribeMessage":"įĄŪåŪčĶåæķčŪĒé æĪčŪĒé æšåïž","modal.feed.unsubscribeTitle":"åæķčŪĒé ","modal.feed.feedTags":"čŪĒé æ įū","modal.tag.addNew":"æ·ŧå æ°æ įū","modal.tag.color":"æ įūéĒčē","modal.tag.confirmDelete":"įĄŪåŪčĶå éοο įūåïžåŪå°äŧææčŪĒé äļį§ŧéĪã","modal.tag.createNew":"ååŧšæ°æ įū","modal.tag.createTag":"ååŧšæ įū","modal.tag.editTag":"įžčūæ įū","modal.tag.loadFailed":"å č――æ įūåĪąčīĨ","modal.tag.manageTags":"įŪĄįæ įū","modal.tag.name":"æ įūåį§°","modal.tag.noTags":"ææ æ įū","modal.tag.retry":"éčŊ","modal.tag.selectTags":"éæĐæ įū","modal.tag.tagCreated":"æ įūååŧšæå","modal.tag.assignedFeeds":"å·ēåé įčŪĒé ({count})","modal.filter.addCondition":"æ·ŧå æĄäŧķ","modal.filter.and":"äļ","modal.filter.applyFilters":"åšįĻčŋæŧĪåĻ","modal.filter.filterConditions":"čŋæŧĪæĄäŧķ","modal.filter.clearFilters":"æļ éĪčŋæŧĪåĻ","modal.filter.conditionAlways":"å§įŧïžæææįŦ ïž","modal.filter.contains":"å åŦ","modal.filter.exactMatch":"åŪå Ļåđé ","modal.filter.favoriteStatus":"æķčįķæ","modal.filter.feedType":"čŪĒé æšįąŧå","modal.filter.filter":"čŋæŧĪ","modal.filter.filterArticles":"čŋæŧĪæįŦ ","modal.filter.filterField":"åæŪĩ","modal.filter.filterOperator":"čŋįŪįŽĶ","modal.filter.filterValue":"åž","modal.filter.fromFeed":"æĨčŠčŪĒé æš","modal.filter.hiddenStatus":"éčįķæ","modal.filter.isImageModeFeed":"åĪåŠä―æĻĄåžčŪĒé æš","modal.filter.noFiltersApplied":"æŠåšįĻčŋæŧĪæĄäŧķ","modal.filter.not":"é","modal.filter.or":"æ","modal.filter.publishedAfter":"ååļäšæĪæĨæåäđå","modal.filter.publishedBefore":"ååļäšæĪæĨæåäđå","modal.filter.publishedAfterHours":"æčŋNå°æķååļ","modal.filter.publishedAfterDays":"æčŋNåĪĐååļ","modal.filter.readLaterStatus":"įĻåé čŊŧįķæ","modal.filter.readStatus":"å·ēčŊŧįķæ","modal.filter.regex":"æĢåčĄĻčūūåž","modal.filter.author":"ä―č ","modal.filter.url":"éūæĨ","modal.filter.articleContent":"æįŦ å åŪđ","modal.filter.hasSummary":"ææčĶ","modal.filter.hasTranslation":"æįŋŧčŊ","modal.filter.hasImage":"æåūį","modal.filter.hasAudio":"æéģéĒ","modal.filter.hasVideo":"æč§éĒ","modal.filter.feedArticlesPerMonth":"čŪĒé æšæŊææįŦ æ°","modal.filter.feedLastUpdateStatus":"čŪĒé æšæīæ°įķæ","modal.filter.updateSuccess":"æå","modal.filter.updateFailed":"åĪąčīĨ","modal.filter.logicPrecedence":"æĄäŧķæäŧĨäļäžå įš§čŋčĄčŪĄįŪïžNOT > AND > ORãčŋæåģį NOT æå čŪĄįŪïžįķåæŊ ANDïžæåæŊ ORã","modal.rule.actions":"æä―","modal.rule.addAction":"æ·ŧå æä―","modal.rule.addCondition":"æ·ŧå æĄäŧķ","modal.rule.addRule":"æ·ŧå č§å","modal.rule.condition":"æĄäŧķ","modal.rule.deleteConfirmMessage":"įĄŪåŪčĶå éĪæĪč§ååïž","modal.rule.deleteConfirmTitle":"å éĪč§å","modal.rule.deletedSuccess":"č§åå éĪæå","modal.rule.deleteRule":"å éĪč§å","modal.rule.editRule":"įžčūč§å","modal.rule.name":"č§ååį§°","modal.rule.namePlaceholder":"äūåĶïžčŠåĻæķčį§ææ°éŧ","modal.rule.rules":"č§å","modal.rule.rulesDesc":"ååŧščŠåĻåč§åäŧĨčŠåĻåĪįæįŦ ","modal.rule.ruleAppliedSuccess":"č§ååšįĻæå","modal.rule.savedSuccess":"č§åäŋåæå","modal.rule.logicPrecedence":"æĄäŧķæäŧĨäļäžå įš§čŋčĄčŪĄįŪïžNOT > AND > ORãčŋæåģį NOT æå čŪĄįŪïžįķåæŊ ANDïžæåæŊ ORã","modal.opml.export":"åŊžåščŪĒé æš","modal.opml.exportSuccess":"OPML åŊžåšæåã","modal.opml.import":"åŊžå ĨčŪĒé æš","modal.update.downloadUpdate":"äļč――æīæ°","modal.update.newVersionAvailable":"åį°æ°įæŽ","setting.about.version":"įæŽ","setting.about.viewOnGitHub":"åĻ GitHub äļæĨį","setting.ai.aiApiKey":"API åŊéĨ","setting.ai.aiApiKeyDesc":"AI æåĄį API åŊéĨ","setting.ai.aiApiKeyPlaceholder":"čūå ĨæĻį API åŊéĨ","setting.ai.aiChatEnabled":"AI čåĪĐ","setting.ai.aiChatEnabledDesc":"å AI čåĪĐïžåįæå ģæįŦ įéŪéĒ","setting.ai.aiConfigAllGood":"æĻį AI é į―Ūå·Ĩä―æĢåļļïž","setting.ai.aiConfigurationGuide":"æĨį AI é į―Ūæå","setting.ai.aiCustomHeaders":"čŠåŪäđčŊ·æąåĪī","setting.ai.aiCustomHeadersAdd":"æ·ŧå čŊ·æąåĪī","setting.ai.aiCustomHeadersDesc":"åé AI čŊ·æąæķéå į HTTP čŊ·æąåĪī","setting.ai.aiCustomHeadersName":"čŊ·æąåĪīåį§°","setting.ai.aiCustomHeadersRemove":"å éĪ","setting.ai.aiCustomHeadersValue":"čŊ·æąåĪīå åŪđ","setting.ai.aiEndpoint":"API įŦŊįđ","setting.ai.aiSearchEnabled":"AI æįīĒ","setting.ai.aiSearchEnabledDesc":"ä―ŋįĻ AI æšč―æĐåąå ģéŪčŊæįīĒæįŦ ","setting.ai.endpoint":"įŦŊįđ","setting.ai.aiEndpointDesc":"åŪæīį API įŦŊįđ URLïžå æŽč·Ŋåū","setting.ai.aiEndpointPlaceholder":"https://api.openai.com/v1/chat/completions","setting.ai.aiFeatures":"AI åč―","setting.ai.aiModel":"æĻĄååį§°","setting.ai.aiModelDesc":"įĻäšįŋŧčŊåæčĶį AI æĻĄå","setting.ai.aiModelPlaceholder":"gpt-4o-mini","setting.ai.aiProfiles":"AI é į―Ū","setting.ai.addProfile":"æ·ŧå é į―Ū","setting.ai.editProfile":"įžčūé į―Ū","setting.ai.deleteProfile":"å éĪ","setting.ai.deleteProfileTitle":"å éĪ AI é į―Ū","setting.ai.deleteProfileConfirm":"įĄŪåŪčĶå éĪ \"{name}\" åïžæĪæä―äļåŊæĪéã","setting.ai.deleteProfileFailed":"å éĪé į―ŪåĪąčīĨ","setting.ai.profileDeleted":"é į―Ūå·ēå éĪ","setting.ai.profileCreated":"é į―Ūå·ēååŧš","setting.ai.profileUpdated":"é į―Ūå·ēæīæ°","setting.ai.saveFailed":"äŋåé į―ŪåĪąčīĨ","setting.ai.profileName":"é į―Ūåį§°","setting.ai.profileNameDesc":"äļšæĪ AI é į―ŪčŪūį―ŪäļäļŠäūŋäščŊåŦįåį§°","setting.ai.profileNamePlaceholder":"æį AI é į―Ū","setting.ai.nameRequired":"é į―Ūåį§°äļč―äļšįĐš","setting.ai.endpointRequired":"API įŦŊįđäļč―äļšįĐš","setting.ai.modelRequired":"æĻĄååį§°äļč―äļšįĐš","setting.ai.configIncomplete":"čŊ·åĄŦåįŦŊįđåæĻĄå","setting.ai.noProfiles":"ææ AI é į―Ū","setting.ai.noProfilesHint":"æ·ŧå äļäļŠé į―ŪæĨåžå§ä―ŋįĻ AI åč―","setting.ai.testProfile":"æĩčŊ","setting.ai.testAllProfiles":"æĩčŊå ĻéĻ","setting.ai.testingAll":"æĩčŊäļ...","setting.ai.selectProfile":"AI é į―Ū","setting.ai.selectProfileForTranslation":"éæĐįĻäšįŋŧčŊį AI é į―Ū","setting.ai.selectProfileForSummary":"éæĐįĻäšįææčĶį AI é į―Ū","setting.ai.selectProfileForChat":"éæĐįĻäš AI čåĪĐįé į―Ū","setting.ai.selectProfileForSearch":"éæĐįĻäš AI æįīĒįé į―Ū","setting.ai.model":"æĻĄå","setting.ai.aiTestFailed":"AI é į―ŪæĩčŊåĪąčīĨ","setting.ai.aiUsage":"AI ä―ŋįĻé","setting.ai.aiUsageReset":"éį―Ūä―ŋįĻé","setting.ai.aiUsageResetConfirm":"įĄŪåŪčĶéį―Ū AI ä―ŋįĻéčŪĄæ°åĻåïž","setting.ai.aiUsageResetError":"éį―Ū AI ä―ŋįĻéčŪĄæ°åĻåĪąčīĨ","setting.ai.aiUsageResetSuccess":"AI ä―ŋįĻéčŪĄæ°åĻå·ēéį―Ū","setting.ai.aiUsageTokens":"å·ēä―ŋįĻ Token","setting.ai.aiUsageLimitPlaceholder":"0","setting.ai.clearAllChats":"æļ įĐšåŊđčŊčŪ°å―","setting.ai.clearAllChatsButton":"æļ įĐš","setting.ai.clearAllChatsConfirm":"įĄŪåŪčĶæļ įĐšææåŊđčŊčŪ°å―åïžæĪæä―äļåŊæĪéã","setting.ai.clearAllChatsDesc":"å éĪææ AI åŊđčŊčŪ°å―","setting.ai.clearAllChatsFailed":"æļ įĐšåŊđčŊčŪ°å―åĪąčīĨ","setting.ai.clearAllChatsSuccess":"åŊđčŊčŪ°å―å·ēæļ įĐš","setting.ai.configValid":"é į―Ūææ","setting.ai.connectionSuccess":"čŋæĨįķæ","setting.ai.isBeta":"čŊĨåč―äŧåĪäšæĩčŊéķæŪĩïžåŊč―ååĻéŪéĒæäļįĻģåŪïžčŊ·č°Ļæ ä―ŋįĻã","setting.ai.isDanger":"ä―ŋįĻ AI æåĄåŊč―äžäš§įčīđįĻïžéĻååč―åŊč―æķč Token čūåĪïžčŊ·įĄŪäŋæĻäšč§Ģįļå ģčīđįĻįŧæåđķåŪæķįæ§ä―ŋįĻæ åĩã","setting.ai.responseTime":"ååšæķéī","setting.ai.setUsageLimit":"čŪūį―Ūä―ŋįĻäļé","setting.ai.setUsageLimitDesc":"Token ä―ŋįĻæ°éäļéïžčŪūį―Ūäļš 0 æķåæ éåķïž","setting.ai.testAIConfig":"æĩčŊé į―Ū","setting.ai.testing":"æĩčŊäļ...","setting.ai.tokens":"Token","setting.content.addHeader":"æ·ŧå čŊ·æąåĪī","setting.content.addLangMapping":"æ·ŧå æ å°","setting.content.aiSummary":"AI æčĶ","setting.content.aiSummaryPrompt":"æčĶæįĪščŊ","setting.content.aiSummaryPromptDesc":"AI æčĶįčŠåŪäđįģŧįŧæįĪščŊ","setting.content.aiSummaryPromptPlaceholder":"ä― æŊäļäļŠæčĶįæåĻãįæįŧåŪææŽįįŪæīæčĶãåŠčūåšæčĶïžäļčĶčūåšå ķäŧå åŪđã","setting.content.aiTranslation":"AI įŋŧčŊ","setting.content.aiTranslationPrompt":"įŋŧčŊæįĪščŊ","setting.content.aiTranslationPromptDesc":"AI įŋŧčŊįčŠåŪäđįģŧįŧæįĪščŊ","setting.content.aiTranslationPromptPlaceholder":"ä― æŊäļäļŠįŋŧčŊåĻãåįĄŪįŋŧčŊįŧåŪįææŽãåŠčūåšįŋŧčŊįææŽïžäļčĶčūåšå ķäŧå åŪđã","setting.content.apiLangCode":"API äŧĢį ","setting.content.baiduAppId":"įūåšĶ App ID","setting.content.baiduAppIdDesc":"čūå ĨįūåšĶįŋŧčŊį App ID","setting.content.baiduAppIdPlaceholder":"čūå ĨæĻį App ID","setting.content.baiduSecretKey":"įūåšĶåŊéĨ","setting.content.baiduSecretKeyDesc":"čūå ĨįūåšĶįŋŧčŊįåŊéĨ","setting.content.baiduSecretKeyPlaceholder":"čūå ĨæĻįåŊéĨ","setting.content.baiduTranslate":"įūåšĶįŋŧčŊ","setting.content.microsoftApiKey":"Microsoft API åŊéĨ","setting.content.microsoftApiKeyDesc":"čūå Ĩ Microsoft Translator į API åŊéĨ","setting.content.microsoftApiKeyPlaceholder":"čūå ĨæĻį Microsoft API åŊéĨ","setting.content.microsoftRegion":"åšå","setting.content.microsoftRegionDesc":"Azure čĩæšįåšåïžåĪæåĄčĩæšéčĶïž","setting.content.microsoftRegionPlaceholder":"äūåĶïžeastasia","setting.content.microsoftEndpoint":"čŠåŪäđįŦŊįđ","setting.content.microsoftEndpointDesc":"čŠåŪäđ API įŦŊįđïžįįĐšä―ŋįĻåŪæđįŦŊįđïž","setting.content.microsoftEndpointPlaceholder":"https://api.cognitive.microsofttranslator.com","setting.content.microsoftTranslate":"Microsoft įŋŧčŊ","setting.content.tencentSecretId":"č ūčŪŊäš Secret ID","setting.content.tencentSecretIdDesc":"čūå Ĩč ūčŪŊäšį Secret ID","setting.content.tencentSecretIdPlaceholder":"čūå ĨæĻį Secret ID","setting.content.tencentSecretKey":"č ūčŪŊäš Secret Key","setting.content.tencentSecretKeyDesc":"čūå Ĩč ūčŪŊäšį Secret Key","setting.content.tencentSecretKeyPlaceholder":"čūå ĨæĻį Secret Key","setting.content.tencentRegion":"å°å","setting.content.tencentRegionDesc":"éæĐč ūčŪŊäšæåĄįå°å","setting.content.tencentTranslate":"č ūčŪŊäšįŋŧčŊ","setting.content.clearSummaryCache":"æļ įĐšæčĶįžå","setting.content.clearSummaryCacheButton":"æļ įĐš","setting.content.clearSummaryCacheConfirm":"įĄŪåŪčĶæļ įĐšæææčĶįžååïžæĪæä―äļåŊæĪéã","setting.content.clearSummaryCacheDesc":"å éĪææįžåįæčĶ","setting.content.clearSummaryCacheFailed":"æļ įĐšæčĶįžååĪąčīĨ","setting.content.clearSummaryCacheSuccess":"å·ēæåæļ įĐšæčĶįžå","setting.content.clearTranslationCache":"æļ įĐšįŋŧčŊįžå","setting.content.clearTranslationCacheButton":"æļ įĐš","setting.content.clearTranslationCacheConfirm":"įĄŪåŪčĶæļ įĐšææįŋŧčŊįžååïžæĪæä―äļåŊæĪéã","setting.content.clearTranslationCacheDesc":"å éĪææįžåįįŋŧčŊ","setting.content.clearTranslationCacheFailed":"æļ įĐšįŋŧčŊįžååĪąčīĨ","setting.content.clearTranslationCacheSuccess":"å·ēæåæļ įĐšįŋŧčŊįžå","setting.content.deeplApi":"DeepL įŋŧčŊ","setting.content.deeplApiKey":"DeepL API åŊéĨ","setting.content.deeplApiKeyDesc":"čūå Ĩ DeepL įŋŧčŊį API åŊéĨ","setting.content.deeplApiKeyPlaceholder":"čūå ĨæĻį DeepL API åŊéĨ","setting.content.deeplEndpoint":"čŠåŪäđįŦŊįđ (deeplx)","setting.content.deeplEndpointDesc":"čŠæįŪĄį deeplx æåĄ URLïžįįĐšä―ŋįĻåŪæđ DeepL APIïž","setting.content.deeplEndpointPlaceholder":"http://localhost:1188","setting.content.enableSummary":"åŊįĻčŠåĻæčĶ","setting.content.enableSummaryDesc":"čŠåĻįææįŦ æčĶ","setting.content.enableTranslation":"åŊįĻįŋŧčŊ","setting.content.enableTranslationDesc":"čŠåĻå°æįŦ æ éĒįŋŧčŊæįŪæ čŊčĻ","setting.content.generateSummary":"įææčĶ","setting.content.generatingAISummary":"æĢåĻįæ AI æčĶ...","setting.content.generatingSummary":"æĢåĻįææčĶ...","setting.content.googleTranslate":"č°·æįŋŧčŊ","setting.content.googleTranslateEndpoint":"č°·æįŋŧčŊįŦŊįđ","setting.content.googleTranslateEndpointAlternate":"åĪįĻ (clients5.google.com)","setting.content.googleTranslateEndpointDefault":"éŧčŪĪ (translate.googleapis.com)","setting.content.googleTranslateEndpointDesc":"éæĐčĶä―ŋįĻįč°·æįŋŧčŊ API įŦŊįđ","setting.content.localAlgorithm":"æŽå°įŪæģ","setting.content.noSummaryAvailable":"æčĶäļåŊįĻ","setting.content.regenerateSummary":"éæ°įæ","setting.content.retrySummary":"éčŊ","setting.content.summary":"æčĶ","setting.content.summaryCredentialsRequired":"AI æčĶéčĶ API åŊéĨ","setting.content.summaryGenerationFailed":"æčĶįæåĪąčīĨ","setting.content.summaryLength":"æčĶéŋåšĶ","setting.content.summaryLengthDesc":"æ§åķįææčĶįéŋåšĶ","setting.content.summaryLengthLong":"éŋ","setting.content.summaryLengthMedium":"äļ","setting.content.summaryLengthShort":"į","setting.content.summaryManualTriggerDesc":"įđåŧæéŪįæ AI æčĶ","setting.content.summaryProvider":"æčĶæåĄ","setting.content.summaryProviderDesc":"éæĐįææįŦ æčĶįæđåž","setting.content.rssSummary":"RSS åææčĶ","setting.content.summaryTooShort":"æįŦ åĪŠįïžæ æģįæææäđįæčĶ","setting.content.summaryTriggerMode":"č§ĶåæĻĄåž","setting.content.summaryTriggerModeAuto":"čŠåĻč§Ķå","setting.content.summaryTriggerModeDesc":"AI æčĶįč§Ķåæđåž","setting.content.summaryTriggerModeManual":"æåĻč§Ķå","setting.content.targetLanguage":"įŪæ čŊčĻ","setting.content.targetLanguageDesc":"å°æįŦ æ éĒįŋŧčŊææĪčŊčĻ","setting.content.translatingContent":"æĢåĻįŋŧčŊå åŪđ...","setting.content.translation":"įŋŧčŊ","setting.content.translationCredentialsRequired":"įŋŧčŊæåĄéčĶ API åŊéĨæåæŪ","setting.content.translationOnlyMode":"äŧ įŋŧčŊæĻĄåž","setting.content.translationOnlyModeDesc":"äŧ æūįĪšįŋŧčŊåįææŽïžéčåæ","setting.content.translationProvider":"įŋŧčŊæåĄ","setting.content.translationProviderDesc":"éæĐčĶä―ŋįĻįįŋŧčŊæåĄ","setting.content.translationSkippedAlreadyTarget":"å·ēč·ģčŋįŋŧčŊ","setting.content.custom.headerName":"čŊ·æąåĪīåį§°","setting.content.custom.headerValue":"åž","setting.content.custom.mrssLangCode":"enãzh į","setting.content.custom.selectTemplate":"éæĐæĻĄæŋ","setting.translation.custom.bodyTemplate":"čŊ·æąä―æĻĄæŋ","setting.translation.custom.bodyTemplateDesc":"åĻčŊ·æąä―äļä―ŋįĻå ä―įŽĶ","setting.translation.custom.bodyTemplatePlaceholder":"čūå ĨčŊ·æąä―æĻĄæŋ","setting.translation.custom.endpoint":"API įŦŊįđ","setting.translation.custom.endpointDesc":"įŋŧčŊæåĄį API įŦŊįđ URL","setting.translation.custom.endpointPlaceholder":"https://api.example.com/translate","setting.translation.custom.headers":"HTTP čŊ·æąåĪī","setting.translation.custom.headersDesc":"čŠåŪäđ HTTP čŊ·æąåĪī","setting.translation.custom.langMapping":"čŊčĻäŧĢį æ å°","setting.translation.custom.langMappingDesc":"å° MrRSS čŊčĻäŧĢį æ å°å° API įđåŪäŧĢį ","setting.translation.custom.method":"HTTP æđæģ","setting.translation.custom.methodDesc":"API čŊ·æąį HTTP æđæģ","setting.translation.custom.responsePath":"ååšč·Ŋåū","setting.translation.custom.responsePathDesc":"æåįŋŧčŊį JSONPath (äūåĶ data.translatedText)","setting.translation.custom.responsePathPlaceholder":"data","setting.translation.custom.template":"éĒčŪūæĻĄæŋ","setting.translation.custom.templateDesc":"å č――åļļįĻæåĄįéĒčŪūé į―Ū","setting.translation.custom.timeout":"čķ æķæķéī","setting.translation.custom.timeoutDesc":"čŊ·æąčķ æķæķéīïžį§ïž","setting.translation.custom.title":"čŠåŪäđ API","setting.customization.css":"čŠåŪäđæįŦ CSS","setting.customization.cssApplied":"čŠåŪäđ CSS å·ēåŊįĻ","setting.customization.cssDeleteFailed":"å éĪ CSS æäŧķåĪąčīĨ","setting.customization.cssDeleted":"CSS æäŧķå éĪæå","setting.customization.cssDesc":"äļäž čŠåŪäđ CSS æäŧķæĨįūåæįŦ å åŪđįæļēæč§åū","setting.customization.cssGuide":"æĨįčŠåŪäđ CSS æå","setting.customization.cssUpload":"äļäž CSS","setting.customization.cssUploadFailed":"äļäž CSS æäŧķåĪąčīĨ","setting.customization.cssUploaded":"CSS æäŧķäļäž æå","setting.customization.deleteCSS":"å éĪ CSS","setting.customization.script":"čŠåŪäđčæŽ","setting.customization.scriptDoc":"æĨįææĄĢ","setting.customization.scriptsFolder":"æåžčæŽæäŧķåĪđ","setting.customization.scriptsFolderOpened":"čæŽæäŧķåĪđå·ēæåž","setting.customization.scriptsNotFound":"čæŽæäŧķåĪđäļæŠæūå°čæŽã","setting.customization.selectScript":"éæĐčæŽ","setting.customization.selectScriptPlaceholder":"éæĐäļäļŠčæŽ...","setting.typography.layoutMode":"æįŦ åčĄĻåļåą","setting.typography.layoutModeDesc":"éæĐæįŦ åčĄĻįåļåąæ ·åž","setting.typography.layoutModeNormal":"æŪé","setting.typography.layoutModeCompact":"įī§å","setting.typography.layoutModeCard":"åĄį","setting.typography.contentFontFamily":"æĢæåä―","setting.typography.contentFontFamilyDesc":"æįŦ å åŪđįåä―įģŧå","setting.typography.contentFontSize":"æĢæåå·","setting.typography.contentFontSizeDesc":"æįŦ å åŪđįåä―åΧå°","setting.typography.contentLineHeight":"æĢæčĄéŦ","setting.typography.contentLineHeightDesc":"æįŦ å åŪđįčĄéīč·","setting.typography.fontMonospace":"įåŪ―åä―","setting.typography.fontMonospaceDefault":"éŧčŪĪįåŪ―","setting.typography.fontSansSerif":"æ 襎įšŋåä―","setting.typography.fontSansSerifDefault":"éŧčŪĪæ 襎įšŋ","setting.typography.fontSerif":"襎įšŋåä―","setting.typography.fontSerifDefault":"éŧčŪĪ襎įšŋ","setting.typography.fontSystem":"įģŧįŧåä―","setting.typography.fontSystemDefault":"įģŧįŧéŧčŪĪ","setting.database.articleContentCacheCleanup":"æįŦ å åŪđįžå","setting.database.articleContentCacheCleanupDesc":"æļ éĪææįžåįæįŦ å åŪđ","setting.database.autoCleanup":"čŠåĻæļ į","setting.database.autoCleanupDesc":"čŠåĻå éĪæ§æįŦ äŧĨčįįĐšéī","setting.database.clean":"æļ į","setting.database.cleanDatabase":"æļ įæ°æŪåš","setting.database.cleanDatabaseMessage":"čŋå°å éĪæŠčŊŧãæŠæķčäļæŠå å ĨįĻåé čŊŧįæįŦ ãå·ēčŊŧãæķčåįĻåé čŊŧįæįŦ äžäŋįãįŧ§įŧåïž","setting.database.cleanDatabaseTitle":"æļ įæ°æŪåš","setting.database.cleaning":"æļ įäļ...","setting.database.cleanupArticleContentCache":"įŦåģæļ į","setting.database.cleanupMediaCache":"įŦåģæļ į","setting.database.currentCacheSize":"å―åįžååΧå°","setting.database.currentCachedArticles":"å―åįžåæįŦ æ°","setting.database.dataManagement":"æ°æŪįŪĄį","setting.database.days":"åĪĐ","setting.database.maxArticleAge":"æįŦ æåΧäŋįåĪĐæ°","setting.database.maxArticleAgeDesc":"å éĪčķ čŋæĪåĪĐæ°įæįŦ ïžæķčéĪåĪïž","setting.database.maxCacheSize":"æåΧįžååΧå°","setting.database.maxCacheSizeDesc":"æļ įåįæåĪ§æ°æŪåšåΧå°","setting.database.mediaCacheCleanup":"æļ įåŠä―įžå","setting.database.mediaCacheCleanupDesc":"å éĪæ§įįžååŠä―æäŧķ","setting.database.mediaCacheEnabled":"åŊįĻåŠä―įžå","setting.database.mediaCacheEnabledDesc":"æŽå°įžååŠä―æäŧķäŧĨéŋå éēįéūäŋæĪåŊžčīįéūæĨåĪąæïžč§Ģåģåūįæ æģå č――įéŪéĒ","setting.database.mediaCacheMaxAge":"æåΧįžåäŋįåĪĐæ°","setting.database.mediaCacheMaxAgeDesc":"å éĪčķ čŋæĪåĪĐæ°įįžååŠä―","setting.database.mediaCacheMaxSize":"æåΧįžååΧå°","setting.database.mediaCacheMaxSizeDesc":"åŠä―įžåæåΧåΧå°","setting.database.clearArticleContentCacheConfirm":"įĄŪåŪčĶæļ įĐšæææįŦ å åŪđįžååïžæĪæä―äļåŊæĪéã","setting.database.clearMediaCacheConfirm":"įĄŪåŪčĶæļ įĐšææåŠä―įžååïžæĪæä―äļåŊæĪéã","setting.feed.addFeed":"æ·ŧå čŪĒé ","setting.feed.articleViewMode":"æįŦ æĨįæĻĄåž","setting.feed.articleViewModeDesc":"éæĐæĪčŪĒé æšįæįŦ åšåĶä―æūįĪš","setting.feed.autoExpandContent":"čŠåĻåąåžå åŪđ","setting.feed.autoExpandContentDesc":"čĶįæĪčŪĒé æšįå Ļåąå ĻææååčŠåĻåąåžčŪūį―Ū","setting.feed.enableFullTextFetch":"åŊįĻå Ļææå","setting.feed.enableFullTextFetchDesc":"å― RSS äŧ æäūæčĶæķïžå čŪļäŧåå§į―įŦæååŪæīæįŦ å åŪđ","setting.feed.fixedInterval":"åšåŪéīé","setting.feed.imageMode":"åĪåŠä―æĻĄåž","setting.feed.imageModeDesc":"äŧĨåĪåŠä―åšč§åūčéæįŦ åčĄĻåąįĪšæĪčŪĒé æš","setting.feed.intelligentInterval":"æšč―éīé","setting.feed.neverRefresh":"äļå·æ°","setting.feed.refreshMode":"å·æ°æĻĄåž","setting.feed.refreshModeDesc":"éæĐäŧĨä―į§éĒįå·æ°ææčŪĒé æš","setting.feed.retryTimeout":"čķ æķæķéī","setting.feed.retryTimeoutDesc":"åĻåŪĢåå·æ°åĪąčīĨåįåū ååšįæķéī","setting.feed.useCustomInterval":"čŠåŪäđéīé","setting.feed.useGlobalRefresh":"ä―ŋįĻå ĻåąčŪūį―Ū","setting.feed.useGlobalSettings":"ä―ŋįĻå ĻåąčŪūį―Ū","setting.feed.useIntelligentInterval":"æšč―éīé","setting.general.application":"åšįĻ","setting.general.auto":"čŠåĻïžč·éįģŧįŧïž","setting.general.closeToTray":"å ģéæķæå°åå°æį","setting.general.closeToTrayDesc":"įđåŧå ģéæķéčå°įģŧįŧæįåđķįŧ§įŧčŋčĄ","setting.general.dark":"æčē","setting.general.language":"čŊčĻ","setting.general.languageDesc":"éæĐįéĒčŊčĻ","setting.general.light":"äšŪčē","setting.general.uiFontFamily":"įéĒåä―","setting.general.uiFontFamilyDesc":"čŪĒé åčĄĻãæįŦ åčĄĻãčŪūį―ŪååžđįŠä―ŋįĻįåä―","setting.general.uiFontSize":"įéĒåå·","setting.general.uiFontSizeDesc":"åšįĻįéĒįåšįĄåä―åΧå°","setting.general.startupOnBoot":"åžæščŠåŊåĻ","setting.general.startupOnBootDesc":"åĻįĩčåŊåĻæķčŠåĻåŊåĻ MrRSS","setting.general.theme":"äļŧéĒ","setting.general.themeDesc":"éæĐéĶéé čēæđæĄ","setting.network.bandwidthLabel":"åļĶåŪ―","setting.network.bandwidthMbps":"å æŊį§","setting.network.detectionComplete":"į―įŧæĢæĩåŪæ","setting.network.detectionFailed":"į―įŧæĢæĩåĪąčīĨ","setting.network.enableProxy":"åŊįĻäŧĢį","setting.network.enableProxyDesc":"ä―ŋįĻäŧĢįæåĄåĻč·åčŪĒé åæįŦ ","setting.network.httpProxy":"HTTP","setting.network.httpsProxy":"HTTPS","setting.network.invalidProxyUrl":"æ æįäŧĢį URL æ žåž","setting.network.noProxy":"æ äŧĢį","setting.network.proxyHost":"äŧĢįäļŧæš","setting.network.proxyHostDesc":"äŧĢįæåĄåĻäļŧæšåæ IP å°å","setting.network.proxyHostPlaceholder":"proxy.example.com","setting.network.proxyPassword":"äŧĢįåŊį ","setting.network.proxyPasswordDesc":"äŧĢįčšŦäŧ―éŠčŊåŊį ","setting.network.proxyPasswordPlaceholder":"åŊį ","setting.network.proxyPort":"äŧĢįįŦŊåĢ","setting.network.proxyPortDesc":"äŧĢįæåĄåĻįŦŊåĢå·","setting.network.proxyPortPlaceholder":"8080","setting.network.proxySettings":"äŧĢįčŪūį―Ū","setting.network.proxyType":"äŧĢįįąŧå","setting.network.proxyTypeDesc":"éæĐčĶä―ŋįĻįäŧĢįåčŪŪ","setting.network.proxyUsername":"äŧĢįįĻæ·å","setting.network.proxyUsernameDesc":"äŧĢįčšŦäŧ―éŠčŊįĻæ·å","setting.network.proxyUsernamePlaceholder":"įĻæ·å","setting.network.socks5Proxy":"SOCKS5","setting.network.systemProxyInfo":"åšįĻéŧčŪĪčŠåĻä―ŋįĻæä―įģŧįŧįäŧĢįčŪūį―Ūãäŧ å―æĻæģä―ŋįĻäļįģŧįŧäŧĢįäļåįäŧĢįæķïžæéčĶåŊįĻæĪééĄđã","setting.network.tunModeInfo":"åĶææĻä―ŋįĻäŧĢįå·Ĩå ·ïžåĶ ClashãV2Ray įïžïžčŊ·įĄŪäŋåŊįĻ TUN æĻĄåžæåĒåžšæĻĄåžïžäŧĨäūŋææåšįĻįĻåšé―č―ä―ŋįĻäŧĢįã","setting.network.tunModeInfoTitle":"æ æģčŋæĨå°į―įŧïž","setting.network.useCustomProxy":"ä―ŋįĻčŠåŪäđäŧĢį","setting.network.useGlobalProxy":"ä―ŋįĻå ĻåąäŧĢį","setting.network.lastDetection":"äļæŽĄæĢæĩ","setting.network.latencyLabel":"åŧķčŋ","setting.network.latencyMs":"æŊŦį§","setting.network.networkSettings":"į―įŧčŪūį―Ū","setting.network.networkSettingsDescription":"čŠåϿ̿ĩį―įŧéåšĶäŧĨäžååđķčĄå·æ°čŪĒé æšįæ§č―","setting.network.reDetectNetwork":"éæ°æĢæĩ","setting.freshrss.apiPassword":"API åŊį ","setting.freshrss.apiPasswordDesc":"FreshRSS API åŊį ïžäļåäšįŧå―åŊį ïž","setting.freshrss.apiPasswordPlaceholder":"čūå Ĩ API åŊį ","setting.freshrss.daysAgo":"{count} åĪĐå","setting.freshrss.disableConfirm":"įĶįĻ FreshRSS å°å éĪæŽå°į FreshRSS čŪĒé æšåæįŦ ãæĪæä―äļåŊæĪéãįĄŪåŪčĶįŧ§įŧåïž","setting.freshrss.enabled":"FreshRSS éæ","setting.freshrss.enabledDesc":"äļ FreshRSS æåĄåĻåæĨčŪĒé æšåæįŦ ","setting.freshrss.hoursAgo":"{count} å°æķå","setting.freshrss.minsAgo":"{count} åéå","setting.freshrss.syncFailed":"åæĨåĪąčīĨ","setting.freshrss.feedLocked":"FreshRSS čŪĒé æšæ æģįžčūãį§ŧåĻæäŋŪæđ","setting.freshrss.justNow":"åå","setting.freshrss.lastSync":"äļæŽĄåæĨ","setting.freshrss.never":"äŧæŠ","setting.freshrss.serverUrl":"æåĄåĻå°å","setting.freshrss.serverUrlDesc":"FreshRSS æåĄåĻįŦŊįđïžäļåŦ /api č·Ŋåūïž","setting.freshrss.serverUrlPlaceholder":"https://freshrss.example.com","setting.freshrss.sync":"įŦåģåæĨ","setting.freshrss.syncedFeed":"äŧ FreshRSS åæĨ","setting.freshrss.syncing":"åæĨäļ...","setting.freshrss.syncNow":"åæĨčŪĒé įķæ","setting.freshrss.syncNowDesc":"åååæĨčŪĒé æšåæįŦ įķæ","setting.freshrss.syncStarted":"åæĨå·ēåžå§","setting.freshrss.username":"įĻæ·å","setting.freshrss.usernameDesc":"FreshRSS įĻæ·å","setting.freshrss.usernamePlaceholder":"čūå ĨįĻæ·å","setting.plugins.notion.apiKey":"API åŊéĨ","setting.plugins.notion.apiKeyDesc":"æĨčŠ Notion įå éĻéæäŧĪį","setting.plugins.notion.apiKeyPlaceholder":"xxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","setting.plugins.notion.exported":"æįŦ å·ēæååŊžåšå° Notion","setting.plugins.notion.exportFailed":"åŊžåšå° Notion åĪąčīĨ","setting.plugins.notion.exporting":"æĢåĻåŊžåšå° Notion...","setting.plugins.notion.exportTo":"åŊžåšå° Notion","setting.plugins.notion.integration":"Notion éæ","setting.plugins.notion.integrationDescription":"įīæĨå°æįŦ åŊžåšå° Notion","setting.plugins.notion.pageId":"įŽčŪ°éĄĩéĒ ID","setting.plugins.notion.pageIdDesc":"æįŦ å°ä―äļšåéĄĩéĒååŧšåϿΠNotion éĄĩéĒäļ","setting.plugins.notion.pageIdPlaceholder":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","setting.plugins.notion.setupInstructions":"čŪūį―Ū Notion éæįæĨéŠĪïž","setting.plugins.notion.step1":"čŪŋéŪ notion.so/my-integrations ååŧšæ°įéæ","setting.plugins.notion.step2":"åĪåķå éĻéæäŧĪįä―äļš API åŊéĨ","setting.plugins.notion.step3":"åĻ Notion äļæåžįŽčŪ°éĄĩéĒïžįđåŧ\"...\" â \"čŋæĨ\" â æ·ŧå æĻįéæ","setting.plugins.notion.step4":"äŧ URL äļåĪåķéĄĩéĒ IDïžéĄĩéĒåį§°åį 32 ä―åįŽĶäļēïž","setting.plugins.obsidian.exported":"æįŦ å·ēæååŊžåšå° Obsidian","setting.plugins.obsidian.exportFailed":"åŊžåšå° Obsidian åĪąčīĨ","setting.plugins.obsidian.exporting":"æĢåĻåŊžåšå° Obsidian...","setting.plugins.obsidian.exportTo":"åŊžåšå° Obsidian","setting.plugins.obsidian.integration":"Obsidian éæ","setting.plugins.obsidian.integrationDescription":"įīæĨå°æįŦ åŊžåšå° Obsidian äŧåš","setting.plugins.obsidian.vaultName":"äŧåšåį§°","setting.plugins.obsidian.vaultNameDesc":"Obsidian äŧåšåį§°","setting.plugins.obsidian.vaultNamePlaceholder":"æįäŧåš","setting.plugins.obsidian.vaultPath":"äŧåšč·Ŋåū","setting.plugins.obsidian.vaultPathDesc":"Obsidian äŧåšįŪå―įåŪæīč·Ŋåū","setting.plugins.zotero.apiKey":"API åŊéĨ","setting.plugins.zotero.apiKeyDesc":"äŧæĻį Zotero čīĶæ·čŪūį―Ūäļč·åį API åŊéĨ","setting.plugins.zotero.apiKeyPlaceholder":"čūå ĨæĻį Zotero API åŊéĨ","setting.plugins.zotero.exported":"æįŦ å·ēæååŊžåšå° Zotero","setting.plugins.zotero.exportFailed":"åŊžåšå° Zotero åĪąčīĨ","setting.plugins.zotero.exporting":"æĢåĻåŊžåšå° Zotero...","setting.plugins.zotero.exportTo":"åŊžåšå° Zotero","setting.plugins.zotero.integration":"Zotero éæ","setting.plugins.zotero.integrationDescription":"įīæĨå°æįŦ åŊžåšå°æĻį Zotero åūäđĶéĶ","setting.plugins.zotero.setupInstructions":"čŪūį―Ū Zotero éæįæĨéŠĪïž","setting.plugins.zotero.step1":"čŪŋéŪ zotero.org/settings/keys ååŧšæ°įåŊéĨ","setting.plugins.zotero.step2":"čūå Ĩå ·æåå ĨæéįåŊéĨ","setting.plugins.zotero.step3":"äŧæĻį Zotero åūäđĶéĶčŪĒé URL æäļŠäšščĩæäļæūå°įĻæ· ID","setting.plugins.zotero.step4":"åĻäļæđčūå ĨæĻįįĻæ· ID","setting.plugins.zotero.userId":"įĻæ· ID","setting.plugins.zotero.userIdDesc":"æĻį Zotero įĻæ· IDïžæ°åïž","setting.plugins.zotero.userIdPlaceholder":"12345678","setting.reading.autoShowAllContent":"čŠåĻåąįĪšææå åŪđ","setting.reading.autoShowAllContentDesc":"ä―äļšæļēæå åŪđæĨįæķïžčŠåĻæūįĪšæææįŦ įåŪæīå åŪđïžåŊč―äžåĒå å č――æķéīïž","setting.reading.defaultViewMode":"æįŦ æĨįæĻĄåž","setting.reading.defaultViewModeDesc":"éæĐæįŦ åšåĶä―æūįĪš","setting.reading.hideAdvancedSettings":"éčéŦįš§čŪūį―Ū","setting.reading.hideFromTimeline":"äŧæķéīįšŋäļéč","setting.reading.hideFromTimelineDesc":"åĻ\"å ĻéĻæįŦ \"å\"æŠčŊŧ\"č§åūäļéčæĪčŪĒé æšįæįŦ ","setting.reading.hideText":"éčæå","setting.reading.hideTranslations":"éčįŋŧčŊ","setting.reading.showText":"æūįĪšæå","setting.reading.hoverMarkAsRead":"æŽåæ čŪ°äļšå·ēčŊŧ","setting.reading.hoverMarkAsReadDesc":"éž æ æŽååĻæįŦ äļæķčŠåĻæ čŪ°äļšå·ēčŊŧïžäļéįĻäšįĻåé čŊŧįæįŦ ïž","setting.reading.imageGalleryEnabled":"åŊįĻåĪåŠä―åš","setting.reading.imageGalleryEnabledDesc":"äļšåūįãč§éĒįåŠä―įąŧčŪĒé æšåŊįĻåĪåŠä―įåļæĩæĻĄåž","setting.reading.showAdvancedSettings":"æūįĪšéŦįš§čŪūį―Ū","setting.reading.showArticlePreviewImages":"æūįĪšéĒč§åūį","setting.reading.showArticlePreviewImagesDesc":"åĻæįŦ åčĄĻäļæūįĪšéĒč§åūį","setting.reading.showFloatingToc":"æūįĪšæĩŪåĻįŪå―","setting.reading.showFloatingTocDesc":"åĻæĄéĒįŦŊé čŊŧč§åūäļæūįĪšåģäū§æĩŪåĻįŪå―","setting.reading.showHiddenArticles":"æūįĪšéčæįŦ ","setting.reading.showHiddenArticlesDesc":"åĻå ĻéĻæįŦ åčĄĻäļæūįĪšéčįæįŦ ","setting.reading.showOnlyUnread":"äŧ æūįĪšæŠčŊŧæįŦ ","setting.reading.showAllArticles":"æūįĪšå ĻéĻæįŦ ","setting.reading.showOriginal":"åæ","setting.reading.showTranslations":"æūįĪšįŋŧčŊ","setting.reading.viewAsRendered":"ä―äļšæļēæå åŪđæĨį","setting.reading.viewAsWebpage":"ä―äļšį―éĄĩæĨį","setting.rsshub.apiKey":"API åŊéĨ","setting.rsshub.apiKeyDesc":"į§æ RSSHub åŪäūį API åŊéĨ","setting.rsshub.cannotDisableWithFeeds":"ååĻæīŧč·į RSSHub čŪĒé æšæķæ æģįĶįĻ RSSHub","setting.rsshub.connectionFailed":"čŋæĨåĪąčīĨ","setting.rsshub.connectionSuccessful":"čŋæĨæå","setting.rsshub.enabled":"RSSHub éæ","setting.rsshub.enabledDesc":"ä―ŋįĻ RSSHub č·åčŠåŪäđ RSS æš","setting.rsshub.endpoint":"RSSHub įŦŊįđ","setting.rsshub.endpointDesc":"æĻį RSSHub æåĄåĻå°å","setting.rsshub.feed":"RSSHub čŪĒé æš","setting.rsshub.notSuggestOfficial":"čŊ·čŪŋéŪ https://docs.rsshub.app/guide/instances æĨįåŊįĻįå Žå ąåŪäūæéĻį―ēæĻčŠå·ąįåŪäūã","setting.rsshub.optional":"åŊé","setting.rsshub.testConnection":"æĩčŊčŋæĨ","setting.rsshub.testConnectionDesc":"éŠčŊ RSSHub įŦŊįđååæŪ","setting.rsshub.testing":"æĩčŊäļ...","setting.rsshub.urlPlaceholder":"RSS č·ŊįąïžæŊæ rsshub:// åčŪŪïž","setting.rule.actionFavorite":"æ·ŧå å°æķč","setting.rule.actionHide":"éčæįŦ ","setting.rule.actionMarkRead":"æ čŪ°äļšå·ēčŊŧ","setting.rule.actionMarkUnread":"æ čŪ°äļšæŠčŊŧ","setting.rule.actionReadLater":"æ·ŧå å°įĻåé čŊŧ","setting.rule.actionRemoveReadLater":"äŧįĻåé čŊŧäļį§ŧéĪ","setting.rule.actionUnfavorite":"åæķæķč","setting.rule.actionUnhide":"åæķéč","setting.rule.addRule":"æ·ŧå č§å","setting.rule.applyRuleNow":"įŦåģåšįĻ","setting.rule.noActionsSelected":"čŊ·čģå°éæĐäļäļŠæä―","setting.rule.noRules":"ææ č§å","setting.rule.noRulesHint":"ååŧšč§åäŧĨčŠåĻåĪįæįŦ ","setting.rule.removeAction":"å éĪæä―","setting.rule.removeCondition":"å éĪ","setting.shortcut.addFeedShortcut":"æ·ŧå čŪĒé ","setting.shortcut.focusFeedSearch":"čįĶčŪĒé æįīĒ","setting.shortcut.openSettingsShortcut":"æåžčŪūį―Ū","setting.shortcut.shortcuts":"åŋŦæ·éŪ","setting.shortcut.shortcutsCleared":"åŋŦæ·éŪå·ēæļ éĪ","setting.shortcut.shortcutsConflict":"æĪåŋŦæ·éŪå·ēčĒŦä―ŋįĻ","setting.shortcut.shortcutsDesc":"čŠåŪäđåļļįĻæä―įéŪįåŋŦæ·éŪ","setting.shortcut.shortcutsEnabled":"åŊįĻåŋŦæ·éŪ","setting.shortcut.shortcutsEnabledDesc":"åŊįĻæįĶįĻéŪįåŋŦæ·éŪ","setting.shortcut.shortcutsUpdated":"åŋŦæ·éŪå·ēæīæ°","setting.shortcut.notSet":"æŠčŪūį―Ū","setting.statistic.aiChats":"AI åŊđčŊ","setting.statistic.aiSummaries":"AI æčĶ","setting.statistic.allTime":"æŧčŪĄ","setting.statistic.articlesFavorited":"æķčæįŦ ","setting.statistic.articlesRead":"å·ēčŊŧæįŦ ","setting.statistic.articlesViewed":"æĨįæįŦ ","setting.statistic.byMonth":"ææ","setting.statistic.byWeek":"æåĻ","setting.statistic.byYear":"æåđī","setting.statistic.customRange":"čŠåŪäđčåī","setting.statistic.description":"æĨįæĻįä―ŋįĻįŧčŪĄæ°æŪ","setting.statistic.endDate":"įŧææĨæ","setting.statistic.resetConfirm":"įĄŪåŪčĶéį―Ūææä―ŋįĻįŧčŪĄæ°æŪåïžæĪæä―æ æģæĪéã","setting.statistic.resetFailed":"éį―ŪįŧčŪĄæ°æŪåĪąčīĨ","setting.statistic.resetSuccess":"įŧčŪĄæ°æŪéį―Ūæå","setting.statistic.resetToDefault":"éį―Ūä―ŋįĻįŧčŪĄ","setting.statistic.startDate":"åžå§æĨæ","setting.statistic.statistics":"įŧčŪĄ","setting.tab.about":"å ģäš","setting.tab.ai":"AI","setting.tab.articleDisplay":"æįŦ æūįĪš","setting.tab.content":"å åŪđ","setting.tab.contentSettings":"å åŪđčŪūį―Ū","setting.tab.customization":"čŠåŪäđ","setting.tab.general":"åļļč§","setting.tab.interactionSettings":"äšĪäščŪūį―Ū","setting.tab.network":"į―įŧ","setting.tab.plugins":"æäŧķ","setting.tab.readingAndDisplay":"é čŊŧ","setting.tab.settings":"čŪūį―Ū","setting.tab.settingsTitle":"čŪūį―Ū","setting.tab.typography":"åä―æį","setting.update.autoUpdateInterval":"čŠåĻæīæ°éīé","setting.update.autoUpdateIntervalDesc":"čŠåϿ̿Ĩæīæ°įæķéīéīé","setting.update.checkForUpdates":"æĢæĨæīæ°","setting.update.currentVersion":"å―åįæŽ","setting.update.installFailed":"åŪčĢ åĪąčīĨ","setting.update.installingUpdate":"æĢåĻåŪčĢ æīæ°...","setting.update.latestVersion":"ææ°įæŽ","setting.update.noInstallerAvailable":"æēĄæéįĻäšæĻåđģå°įåŪčĢ įĻåšãčŊ·æåĻäŧäŧĨäļå°åäļč――","setting.update.notNow":"æäļæīæ°","setting.update.updateCheckEnabled":"åŊåĻæķæĢæĨæīæ°","setting.update.updateCheckEnabledDesc":"åį°æ°įæŽæķčŠåĻæūįĪšæīæ°æįĪš","setting.update.updateAvailable":"æåŊįĻæīæ°","setting.update.updateFailed":"äļæŽĄæīæ°åĪąčīĨ","setting.update.updateNow":"įŦåģæīæ°","setting.update.updates":"æīæ°","setting.update.updateSuccess":"äļæŽĄæīæ°æå","setting.update.updateWillRestart":"åšįĻįĻåšå°éåŊäŧĨåŪčĢ æīæ°","setting.update.upToDate":"æĻæĢåĻä―ŋįĻææ°įæŽ","sidebar.activity.addFeed":"æ·ŧå čŪĒé ","sidebar.activity.allArticles":"æææįŦ ","sidebar.activity.collapseActivityBar":"æå æīŧåĻæ ","sidebar.activity.collapseFeedList":"æå čŪĒé æšåčĄĻ","sidebar.activity.expandActivityBar":"åąåžæīŧåĻæ ","sidebar.activity.expandFeedList":"åąåžčŪĒé æšåčĄĻ","sidebar.activity.favorites":"æķč","sidebar.activity.imageGallery":"åĪåŠä―æĻĄåž","sidebar.activity.immediateTasks":"åģæķäŧŧåĄ","sidebar.activity.lastGlobalRefresh":"æåæīæ°æķéī","sidebar.activity.queuedTasks":"æéäŧŧåĄ","sidebar.activity.readLater":"įĻåé čŊŧ","sidebar.activity.unreadArticles":"æŠčŊŧæįŦ ","sidebar.feedList.articles":"æįŦ ","sidebar.feedList.feeds":"čŪĒé æš","sidebar.feedList.pin":"į―ŪéĄķ","sidebar.feedList.recentArticles":"æčŋæįŦ ","sidebar.feedList.uncategorized":"æŠåįąŧ","sidebar.feedList.unpin":"åæķį―ŪéĄķ","sidebar.feedList.unread":"æŠčŊŧ","sidebar.savedFilters.title":"å·ēäŋåįčŋæŧĪåĻ","sidebar.savedFilters.saveFilter":"äŋåčŋæŧĪåĻ","sidebar.savedFilters.editFilter":"įžčūčŋæŧĪåĻ","sidebar.savedFilters.filterName":"čŋæŧĪåĻåį§°","sidebar.savedFilters.filterNamePlaceholder":"äūåĶ: äļåĻį§ææ°éŧ","sidebar.savedFilters.saveCurrentFilter":"äŋåå―åčŋæŧĪåĻ","sidebar.savedFilters.nameRequired":"čŊ·čūå ĨčŋæŧĪåĻåį§°","sidebar.savedFilters.conditionsRequired":"čŊ·čģå°æ·ŧå äļäļŠæĄäŧķ","sidebar.savedFilters.filterSaved":"čŋæŧĪåĻäŋåæå","sidebar.savedFilters.saveFailed":"äŋåčŋæŧĪåĻåĪąčīĨ","sidebar.savedFilters.filterUpdated":"čŋæŧĪåĻæīæ°æå","sidebar.savedFilters.updateFailed":"æīæ°čŋæŧĪåĻåĪąčīĨ","sidebar.savedFilters.filterDeleted":"čŋæŧĪåĻå éĪæå","sidebar.savedFilters.deleteFailed":"å éĪčŋæŧĪåĻåĪąčīĨ","sidebar.savedFilters.deleteConfirmTitle":"å éĪčŋæŧĪåĻ","sidebar.savedFilters.deleteConfirmMessage":"įĄŪåŪčĶå éĪ\"{name}\"å?","sidebar.savedFilters.save":"äŋå","sidebar.sort.byArticlesPerMonth":"ææīæ°éĒįæåš","sidebar.sort.byCategory":"åįąŧ","sidebar.sort.byLatestArticle":"æææ°æįŦ æķéīæåš","sidebar.sort.byName":"åį§°","sidebar.sort.byUpdateStatus":"ææīæ°įķææåš","sidebar.sort.frequency":"éĒį","sidebar.sort.latest":"ææ°","shortcut.category.articles":"æįŦ ","shortcut.category.navigation":"åŊžčŠ","shortcut.category.other":"å ķäŧ","shortcut.pressKey":"æäļæéŪ...","shortcut.toggle.contentView":"å åŪđč§åū","shortcut.toggle.favoritesFilter":"åæĒæķččŋæŧĪ","shortcut.toggle.filter":"åæĒæįŦ čŋæŧĪåĻ","shortcut.toggle.readLaterFilter":"åæĒįĻåé čŊŧčŋæŧĪ","shortcut.toggle.readLaterStatus":"åæĒįĻåé čŊŧ","shortcut.toggle.readStatus":"åæĒå·ēčŊŧįķæ","shortcut.toggle.sidebar":"åæĒäū§čūđæ ","shortcut.toggle.unreadFilter":"åæĒæŠčŊŧčŋæŧĪ","appName":"MrRSS"}
+"""#
+}
diff --git a/frontend/Sources/Models/AI.swift b/frontend/Sources/Models/AI.swift
new file mode 100644
index 000000000..70bee7a78
--- /dev/null
+++ b/frontend/Sources/Models/AI.swift
@@ -0,0 +1,285 @@
+import Foundation
+
+/// A saved AI configuration. The API key is only returned when the backend is
+/// asked for it explicitly, so it is optional here.
+struct AIProfile: Identifiable, Codable, Hashable {
+ let id: Int
+ var name: String
+ var apiKey: String
+ var endpoint: String
+ var model: String
+ var customHeaders: String
+ var isDefault: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case id, name, endpoint, model
+ case apiKey = "api_key"
+ case customHeaders = "custom_headers"
+ case isDefault = "is_default"
+ }
+
+ init(
+ id: Int = 0,
+ name: String = "",
+ apiKey: String = "",
+ endpoint: String = "",
+ model: String = "",
+ customHeaders: String = "",
+ isDefault: Bool = false
+ ) {
+ self.id = id
+ self.name = name
+ self.apiKey = apiKey
+ self.endpoint = endpoint
+ self.model = model
+ self.customHeaders = customHeaders
+ self.isDefault = isDefault
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey) ?? ""
+ endpoint = try container.decodeIfPresent(String.self, forKey: .endpoint) ?? ""
+ model = try container.decodeIfPresent(String.self, forKey: .model) ?? ""
+ customHeaders = try container.decodeIfPresent(String.self, forKey: .customHeaders) ?? ""
+ isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false
+ }
+}
+
+/// The outcome of testing one AI profile.
+struct AIProfileTestResult: Codable, Equatable, Identifiable {
+ let profileID: Int
+ let profileName: String
+ let configValid: Bool
+ let connectionSuccess: Bool
+ let modelAvailable: Bool
+ let responseTimeMs: Int
+ let errorMessage: String?
+ let errorCode: String?
+
+ var id: Int { profileID }
+
+ /// True when the profile is usable end to end.
+ var succeeded: Bool { configValid && connectionSuccess }
+
+ enum CodingKeys: String, CodingKey {
+ case profileID = "profile_id"
+ case profileName = "profile_name"
+ case configValid = "config_valid"
+ case connectionSuccess = "connection_success"
+ case modelAvailable = "model_available"
+ case responseTimeMs = "response_time_ms"
+ case errorMessage = "error_message"
+ case errorCode = "error_code"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ profileID = try container.decodeIfPresent(Int.self, forKey: .profileID) ?? 0
+ profileName = try container.decodeIfPresent(String.self, forKey: .profileName) ?? ""
+ configValid = try container.decodeIfPresent(Bool.self, forKey: .configValid) ?? false
+ connectionSuccess = try container.decodeIfPresent(Bool.self, forKey: .connectionSuccess) ?? false
+ modelAvailable = try container.decodeIfPresent(Bool.self, forKey: .modelAvailable) ?? false
+ responseTimeMs = try container.decodeIfPresent(Int.self, forKey: .responseTimeMs) ?? 0
+ errorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage)?.nilIfBlank
+ errorCode = try container.decodeIfPresent(String.self, forKey: .errorCode)?.nilIfBlank
+ }
+}
+
+/// A saved conversation about one article.
+struct ChatSession: Identifiable, Codable, Hashable {
+ let id: Int
+ let articleID: Int
+ var title: String
+ let createdAt: String
+ let updatedAt: String
+ let messageCount: Int
+
+ enum CodingKeys: String, CodingKey {
+ case id, title
+ case articleID = "article_id"
+ case createdAt = "created_at"
+ case updatedAt = "updated_at"
+ case messageCount = "message_count"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(Int.self, forKey: .id)
+ articleID = try container.decodeIfPresent(Int.self, forKey: .articleID) ?? 0
+ title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
+ createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
+ updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) ?? ""
+ messageCount = try container.decodeIfPresent(Int.self, forKey: .messageCount) ?? 0
+ }
+
+ init(id: Int, articleID: Int, title: String, createdAt: String = "", updatedAt: String = "", messageCount: Int = 0) {
+ self.id = id
+ self.articleID = articleID
+ self.title = title
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ self.messageCount = messageCount
+ }
+}
+
+/// One stored chat message.
+struct ChatMessage: Identifiable, Codable, Hashable {
+ let id: Int
+ let sessionID: Int
+ let role: String
+ let content: String
+ let html: String?
+ let thinking: String?
+ let createdAt: String
+
+ enum CodingKeys: String, CodingKey {
+ case id, role, content, html, thinking
+ case sessionID = "session_id"
+ case createdAt = "created_at"
+ }
+
+ init(
+ id: Int,
+ sessionID: Int,
+ role: String,
+ content: String,
+ html: String? = nil,
+ thinking: String? = nil,
+ createdAt: String = ""
+ ) {
+ self.id = id
+ self.sessionID = sessionID
+ self.role = role
+ self.content = content
+ self.html = html
+ self.thinking = thinking
+ self.createdAt = createdAt
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
+ sessionID = try container.decodeIfPresent(Int.self, forKey: .sessionID) ?? 0
+ role = try container.decodeIfPresent(String.self, forKey: .role) ?? "user"
+ content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
+ html = try container.decodeIfPresent(String.self, forKey: .html)?.nilIfBlank
+ thinking = try container.decodeIfPresent(String.self, forKey: .thinking)?.nilIfBlank
+ createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
+ }
+
+ var isAssistant: Bool { role == "assistant" }
+}
+
+/// What the chat endpoint returns for one exchange.
+struct ChatResponse: Codable, Equatable {
+ let response: String
+ let html: String?
+ let thinking: String?
+ let sessionID: Int?
+ let historySaved: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case response, html, thinking
+ case sessionID = "session_id"
+ case historySaved = "history_saved"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ response = try container.decodeIfPresent(String.self, forKey: .response) ?? ""
+ html = try container.decodeIfPresent(String.self, forKey: .html)?.nilIfBlank
+ thinking = try container.decodeIfPresent(String.self, forKey: .thinking)?.nilIfBlank
+ sessionID = try container.decodeIfPresent(Int.self, forKey: .sessionID)
+ historySaved = try container.decodeIfPresent(Bool.self, forKey: .historySaved) ?? false
+ }
+}
+
+/// One hit from the AI-assisted search: the article plus why it matched.
+struct AISearchHit: Identifiable, Codable, Hashable {
+ let article: Article
+ let relevanceScore: Double
+ let matchedTerms: [String]
+ let matchedFields: [String]
+ let excerpt: String?
+
+ var id: Int { article.id }
+
+ enum CodingKeys: String, CodingKey {
+ case excerpt
+ case relevanceScore = "relevance_score"
+ case matchedTerms = "matched_terms"
+ case matchedFields = "matched_fields"
+ }
+
+ init(from decoder: Decoder) throws {
+ article = try Article(from: decoder)
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ relevanceScore = try container.decodeIfPresent(Double.self, forKey: .relevanceScore) ?? 0
+ matchedTerms = try container.decodeIfPresent([String].self, forKey: .matchedTerms) ?? []
+ matchedFields = try container.decodeIfPresent([String].self, forKey: .matchedFields) ?? []
+ excerpt = try container.decodeIfPresent(String.self, forKey: .excerpt)?.nilIfBlank
+ }
+
+ func encode(to encoder: Encoder) throws {
+ try article.encode(to: encoder)
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(relevanceScore, forKey: .relevanceScore)
+ try container.encode(matchedTerms, forKey: .matchedTerms)
+ try container.encode(matchedFields, forKey: .matchedFields)
+ try container.encodeIfPresent(excerpt, forKey: .excerpt)
+ }
+}
+
+/// What `/api/ai/search` returns.
+struct AISearchResponse: Codable, Equatable {
+ let success: Bool
+ let articles: [AISearchHit]
+ let searchTerms: String?
+ let error: String?
+ let errorCode: String?
+ let totalCount: Int
+
+ enum CodingKeys: String, CodingKey {
+ case success, articles, error
+ case searchTerms = "search_terms"
+ case errorCode = "error_code"
+ case totalCount = "total_count"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ success = try container.decodeIfPresent(Bool.self, forKey: .success) ?? false
+ articles = try container.decodeIfPresent([AISearchHit].self, forKey: .articles) ?? []
+ searchTerms = try container.decodeIfPresent(String.self, forKey: .searchTerms)?.nilIfBlank
+ error = try container.decodeIfPresent(String.self, forKey: .error)?.nilIfBlank
+ errorCode = try container.decodeIfPresent(String.self, forKey: .errorCode)?.nilIfBlank
+ totalCount = try container.decodeIfPresent(Int.self, forKey: .totalCount) ?? 0
+ }
+}
+
+struct AIUsage: Codable, Equatable {
+ let usage: Int
+ let limit: Int
+ let limitReached: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case usage, limit
+ case limitReached = "limit_reached"
+ }
+
+ init(usage: Int, limit: Int, limitReached: Bool) {
+ self.usage = usage
+ self.limit = limit
+ self.limitReached = limitReached
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ usage = try container.decodeIfPresent(Int.self, forKey: .usage) ?? 0
+ limit = try container.decodeIfPresent(Int.self, forKey: .limit) ?? 0
+ limitReached = try container.decodeIfPresent(Bool.self, forKey: .limitReached) ?? false
+ }
+}
diff --git a/frontend/Sources/Models/Article.swift b/frontend/Sources/Models/Article.swift
new file mode 100644
index 000000000..62a598c97
--- /dev/null
+++ b/frontend/Sources/Models/Article.swift
@@ -0,0 +1,279 @@
+import Foundation
+
+/// An article as the backend returns it.
+struct Article: Identifiable, Codable, Hashable {
+ let id: Int
+ let feedID: Int
+ let feedTitle: String?
+ let title: String
+ let url: String
+ let imageURL: String?
+ let audioURL: String?
+ let videoURL: String?
+ let author: String?
+ let publishedAt: String
+ var isRead: Bool
+ var isFavorite: Bool
+ var isHidden: Bool
+ var isReadLater: Bool
+ var translatedTitle: String?
+ var summary: String?
+ var originalSummary: String?
+ var freshRSSItemID: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id, title, url, summary, author
+ case feedID = "feed_id"
+ case feedTitle = "feed_title"
+ case imageURL = "image_url"
+ case audioURL = "audio_url"
+ case videoURL = "video_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"
+ case originalSummary = "original_summary"
+ case freshRSSItemID = "freshrss_item_id"
+ }
+
+ init(
+ id: Int,
+ feedID: Int,
+ feedTitle: String? = nil,
+ title: String,
+ url: String,
+ imageURL: String? = nil,
+ audioURL: String? = nil,
+ videoURL: String? = nil,
+ author: String? = nil,
+ publishedAt: String,
+ isRead: Bool = false,
+ isFavorite: Bool = false,
+ isHidden: Bool = false,
+ isReadLater: Bool = false,
+ translatedTitle: String? = nil,
+ summary: String? = nil,
+ originalSummary: String? = nil,
+ freshRSSItemID: String? = nil
+ ) {
+ self.id = id
+ self.feedID = feedID
+ self.feedTitle = feedTitle?.nilIfBlank
+ self.title = title
+ self.url = url
+ self.imageURL = imageURL?.nilIfBlank
+ self.audioURL = audioURL?.nilIfBlank
+ self.videoURL = videoURL?.nilIfBlank
+ self.author = author?.nilIfBlank
+ self.publishedAt = publishedAt
+ self.isRead = isRead
+ self.isFavorite = isFavorite
+ self.isHidden = isHidden
+ self.isReadLater = isReadLater
+ self.translatedTitle = translatedTitle?.nilIfBlank
+ self.summary = summary?.nilIfBlank
+ self.originalSummary = originalSummary?.nilIfBlank
+ self.freshRSSItemID = freshRSSItemID?.nilIfBlank
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(Int.self, forKey: .id)
+ feedID = try container.decodeIfPresent(Int.self, forKey: .feedID) ?? 0
+ feedTitle = try container.decodeIfPresent(String.self, forKey: .feedTitle)?.nilIfBlank
+ title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
+ url = try container.decodeIfPresent(String.self, forKey: .url) ?? ""
+ imageURL = try container.decodeIfPresent(String.self, forKey: .imageURL)?.nilIfBlank
+ audioURL = try container.decodeIfPresent(String.self, forKey: .audioURL)?.nilIfBlank
+ videoURL = try container.decodeIfPresent(String.self, forKey: .videoURL)?.nilIfBlank
+ author = try container.decodeIfPresent(String.self, forKey: .author)?.nilIfBlank
+ publishedAt = try container.decodeIfPresent(String.self, forKey: .publishedAt) ?? ""
+ isRead = try container.decodeIfPresent(Bool.self, forKey: .isRead) ?? false
+ isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false
+ isHidden = try container.decodeIfPresent(Bool.self, forKey: .isHidden) ?? false
+ isReadLater = try container.decodeIfPresent(Bool.self, forKey: .isReadLater) ?? false
+ translatedTitle = try container.decodeIfPresent(String.self, forKey: .translatedTitle)?.nilIfBlank
+ summary = try container.decodeIfPresent(String.self, forKey: .summary)?.nilIfBlank
+ originalSummary = try container.decodeIfPresent(String.self, forKey: .originalSummary)?.nilIfBlank
+ freshRSSItemID = try container.decodeIfPresent(String.self, forKey: .freshRSSItemID)?.nilIfBlank
+ }
+
+ /// The title to show, preferring the translation when one exists.
+ func displayTitle(preferTranslation: Bool) -> String {
+ guard preferTranslation, let translatedTitle, !translatedTitle.isEmpty else { return title }
+ return translatedTitle
+ }
+
+ var publishedDate: Date? {
+ ArticleDateFormatter.date(from: publishedAt)
+ }
+
+ var hasMedia: Bool {
+ audioURL != nil || videoURL != nil
+ }
+}
+
+/// Parses the timestamps the backend emits, which are RFC 3339 with or without
+/// fractional seconds.
+enum ArticleDateFormatter {
+ private static let withFractionalSeconds: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }()
+
+ private static let withoutFractionalSeconds: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter
+ }()
+
+ static func date(from string: String) -> Date? {
+ if let date = withFractionalSeconds.date(from: string) { return date }
+ return withoutFractionalSeconds.date(from: string)
+ }
+
+ /// A short, human-readable form such as "3h ago" or "12 Mar".
+ static func relativeDescription(for string: String, now: Date = Date()) -> String {
+ guard let date = date(from: string) else { return "" }
+ let interval = now.timeIntervalSince(date)
+
+ if interval < 60 {
+ return t("common.time.justNow")
+ }
+ if interval < 3_600 {
+ return t("common.time.minutesAgo", ["count": Int(interval / 60)])
+ }
+ if interval < 86_400 {
+ return t("common.time.hoursAgo", ["count": Int(interval / 3_600)])
+ }
+ if interval < 7 * 86_400 {
+ return t("common.time.daysAgo", ["count": Int(interval / 86_400)])
+ }
+
+ return formatter(dateStyle: .medium, timeStyle: .none).string(from: date)
+ }
+
+ static func fullDescription(for string: String) -> String {
+ guard let date = date(from: string) else { return string }
+ return formatter(dateStyle: .long, timeStyle: .short).string(from: date)
+ }
+
+ /// Dates follow the language chosen in settings rather than the system
+ /// locale, so the whole interface reads in one language.
+ private static func formatter(
+ dateStyle: DateFormatter.Style,
+ timeStyle: DateFormatter.Style
+ ) -> DateFormatter {
+ let formatter = DateFormatter()
+ formatter.dateStyle = dateStyle
+ formatter.timeStyle = timeStyle
+ formatter.locale = Locale(identifier: Localization.shared.language.rawValue)
+ return formatter
+ }
+}
+
+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) }
+ })
+ }
+}
+
+/// Per-feed counts for each activity filter, as `/api/articles/filter-counts` returns them.
+struct FilterCounts: Codable, Equatable {
+ var unread: [Int: Int] = [:]
+ var favorites: [Int: Int] = [:]
+ var favoritesUnread: [Int: Int] = [:]
+ var readLater: [Int: Int] = [:]
+ var readLaterUnread: [Int: Int] = [:]
+ var images: [Int: Int] = [:]
+ var imagesUnread: [Int: Int] = [:]
+
+ static let empty = FilterCounts()
+
+ enum CodingKeys: String, CodingKey {
+ case unread, favorites, images
+ case favoritesUnread = "favorites_unread"
+ case readLater = "read_later"
+ case readLaterUnread = "read_later_unread"
+ case imagesUnread = "images_unread"
+ }
+
+ init() {}
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ func counts(_ key: CodingKeys) throws -> [Int: Int] {
+ let raw = try container.decodeIfPresent([String: Int].self, forKey: key) ?? [:]
+ return Dictionary(uniqueKeysWithValues: raw.compactMap { key, value in
+ Int(key).map { ($0, value) }
+ })
+ }
+ unread = try counts(.unread)
+ favorites = try counts(.favorites)
+ favoritesUnread = try counts(.favoritesUnread)
+ readLater = try counts(.readLater)
+ readLaterUnread = try counts(.readLaterUnread)
+ images = try counts(.images)
+ imagesUnread = try counts(.imagesUnread)
+ }
+
+ func total(for keyPath: KeyPath) -> Int {
+ self[keyPath: keyPath].values.reduce(0, +)
+ }
+}
+
+struct ArticleContent: Codable, Equatable {
+ let content: String
+ let feedURL: String?
+
+ enum CodingKeys: String, CodingKey {
+ case content
+ case feedURL = "feed_url"
+ }
+}
+
+/// The images the backend extracted from an article, used by the gallery view.
+struct ArticleImages: Codable, Equatable {
+ let images: [String]
+
+ init(images: [String]) {
+ self.images = images
+ }
+
+ init(from decoder: Decoder) throws {
+ if let container = try? decoder.container(keyedBy: CodingKeys.self) {
+ images = try container.decodeIfPresent([String].self, forKey: .images) ?? []
+ } else {
+ let single = try decoder.singleValueContainer()
+ images = (try? single.decode([String].self)) ?? []
+ }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case images
+ }
+}
diff --git a/frontend/Sources/Models/Feed.swift b/frontend/Sources/Models/Feed.swift
new file mode 100644
index 000000000..b8b45db59
--- /dev/null
+++ b/frontend/Sources/Models/Feed.swift
@@ -0,0 +1,224 @@
+import Foundation
+
+/// A subscription as the backend stores it. Every field the API returns is kept
+/// so the feed editor can round-trip a feed without losing configuration.
+struct Feed: Identifiable, Codable, Hashable {
+ let id: Int
+ 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?
+ var link: String
+ var feedDescription: String
+ var lastError: String
+ var hideFromTimeline: Bool
+ var refreshInterval: Int
+ var isImageMode: Bool
+ var scriptPath: String
+ var proxyURL: String
+ var proxyEnabled: Bool
+ var articleViewMode: String
+ var autoExpandContent: String
+ var type: String
+ var xPathItem: String
+ var xPathItemTitle: String
+ var xPathItemContent: String
+ var xPathItemURI: String
+ var xPathItemAuthor: String
+ var xPathItemTimestamp: String
+ var xPathItemTimeFormat: String
+ var xPathItemThumbnail: String
+ var xPathItemCategories: String
+ var xPathItemUID: String
+ var emailAddress: String
+ var emailIMAPServer: String
+ var emailIMAPPort: Int
+ var emailUsername: String
+ var emailPassword: String
+ var emailFolder: String
+ var isFreshRSSSource: Bool
+ /// The tags assigned to this feed, which the feed listing includes.
+ var tags: [Tag]
+
+ enum CodingKeys: String, CodingKey {
+ case id, url, title, category, position, link, type
+ case feedDescription = "description"
+ case lastUpdated = "last_updated"
+ case iconURL = "image_url"
+ case lastError = "last_error"
+ case hideFromTimeline = "hide_from_timeline"
+ case refreshInterval = "refresh_interval"
+ case isImageMode = "is_image_mode"
+ case scriptPath = "script_path"
+ case proxyURL = "proxy_url"
+ case proxyEnabled = "proxy_enabled"
+ case articleViewMode = "article_view_mode"
+ case autoExpandContent = "auto_expand_content"
+ case xPathItem = "xpath_item"
+ case xPathItemTitle = "xpath_item_title"
+ case xPathItemContent = "xpath_item_content"
+ case xPathItemURI = "xpath_item_uri"
+ case xPathItemAuthor = "xpath_item_author"
+ case xPathItemTimestamp = "xpath_item_timestamp"
+ case xPathItemTimeFormat = "xpath_item_time_format"
+ case xPathItemThumbnail = "xpath_item_thumbnail"
+ case xPathItemCategories = "xpath_item_categories"
+ case xPathItemUID = "xpath_item_uid"
+ case emailAddress = "email_address"
+ case emailIMAPServer = "email_imap_server"
+ case emailIMAPPort = "email_imap_port"
+ case emailUsername = "email_username"
+ case emailPassword = "email_password"
+ case emailFolder = "email_folder"
+ case isFreshRSSSource = "is_freshrss_source"
+ case tags
+ }
+
+ init(
+ id: Int,
+ url: String,
+ title: String,
+ category: String,
+ position: Int = 0,
+ lastUpdated: String = "",
+ iconURL: String? = nil,
+ link: String = "",
+ feedDescription: String = "",
+ lastError: String = "",
+ hideFromTimeline: Bool = false,
+ refreshInterval: Int = 0,
+ isImageMode: Bool = false,
+ scriptPath: String = "",
+ proxyURL: String = "",
+ proxyEnabled: Bool = false,
+ articleViewMode: String = "global",
+ autoExpandContent: String = "global",
+ type: String = "",
+ xPathItem: String = "",
+ xPathItemTitle: String = "",
+ xPathItemContent: String = "",
+ xPathItemURI: String = "",
+ xPathItemAuthor: String = "",
+ xPathItemTimestamp: String = "",
+ xPathItemTimeFormat: String = "",
+ xPathItemThumbnail: String = "",
+ xPathItemCategories: String = "",
+ xPathItemUID: String = "",
+ emailAddress: String = "",
+ emailIMAPServer: String = "",
+ emailIMAPPort: Int = 993,
+ emailUsername: String = "",
+ emailPassword: String = "",
+ emailFolder: String = "INBOX",
+ isFreshRSSSource: Bool = false,
+ tags: [Tag] = []
+ ) {
+ self.id = id
+ self.url = url
+ self.title = title
+ self.category = category
+ self.position = position
+ self.lastUpdated = lastUpdated
+ self.iconURL = iconURL?.nilIfBlank
+ self.link = link
+ self.feedDescription = feedDescription
+ self.lastError = lastError
+ self.hideFromTimeline = hideFromTimeline
+ self.refreshInterval = refreshInterval
+ self.isImageMode = isImageMode
+ self.scriptPath = scriptPath
+ self.proxyURL = proxyURL
+ self.proxyEnabled = proxyEnabled
+ self.articleViewMode = articleViewMode
+ self.autoExpandContent = autoExpandContent
+ self.type = type
+ self.xPathItem = xPathItem
+ self.xPathItemTitle = xPathItemTitle
+ self.xPathItemContent = xPathItemContent
+ self.xPathItemURI = xPathItemURI
+ self.xPathItemAuthor = xPathItemAuthor
+ self.xPathItemTimestamp = xPathItemTimestamp
+ self.xPathItemTimeFormat = xPathItemTimeFormat
+ self.xPathItemThumbnail = xPathItemThumbnail
+ self.xPathItemCategories = xPathItemCategories
+ self.xPathItemUID = xPathItemUID
+ self.emailAddress = emailAddress
+ self.emailIMAPServer = emailIMAPServer
+ self.emailIMAPPort = emailIMAPPort
+ self.emailUsername = emailUsername
+ self.emailPassword = emailPassword
+ self.emailFolder = emailFolder
+ self.isFreshRSSSource = isFreshRSSSource
+ self.tags = tags
+ }
+
+ 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) ?? ""
+ 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)?.nilIfBlank
+ link = try container.decodeIfPresent(String.self, forKey: .link) ?? ""
+ feedDescription = try container.decodeIfPresent(String.self, forKey: .feedDescription) ?? ""
+ lastError = try container.decodeIfPresent(String.self, forKey: .lastError) ?? ""
+ hideFromTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideFromTimeline) ?? false
+ refreshInterval = try container.decodeIfPresent(Int.self, forKey: .refreshInterval) ?? 0
+ isImageMode = try container.decodeIfPresent(Bool.self, forKey: .isImageMode) ?? false
+ scriptPath = try container.decodeIfPresent(String.self, forKey: .scriptPath) ?? ""
+ proxyURL = try container.decodeIfPresent(String.self, forKey: .proxyURL) ?? ""
+ proxyEnabled = try container.decodeIfPresent(Bool.self, forKey: .proxyEnabled) ?? false
+ articleViewMode = try container.decodeIfPresent(String.self, forKey: .articleViewMode) ?? "global"
+ autoExpandContent = try container.decodeIfPresent(String.self, forKey: .autoExpandContent) ?? "global"
+ type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
+ xPathItem = try container.decodeIfPresent(String.self, forKey: .xPathItem) ?? ""
+ xPathItemTitle = try container.decodeIfPresent(String.self, forKey: .xPathItemTitle) ?? ""
+ xPathItemContent = try container.decodeIfPresent(String.self, forKey: .xPathItemContent) ?? ""
+ xPathItemURI = try container.decodeIfPresent(String.self, forKey: .xPathItemURI) ?? ""
+ xPathItemAuthor = try container.decodeIfPresent(String.self, forKey: .xPathItemAuthor) ?? ""
+ xPathItemTimestamp = try container.decodeIfPresent(String.self, forKey: .xPathItemTimestamp) ?? ""
+ xPathItemTimeFormat = try container.decodeIfPresent(String.self, forKey: .xPathItemTimeFormat) ?? ""
+ xPathItemThumbnail = try container.decodeIfPresent(String.self, forKey: .xPathItemThumbnail) ?? ""
+ xPathItemCategories = try container.decodeIfPresent(String.self, forKey: .xPathItemCategories) ?? ""
+ xPathItemUID = try container.decodeIfPresent(String.self, forKey: .xPathItemUID) ?? ""
+ emailAddress = try container.decodeIfPresent(String.self, forKey: .emailAddress) ?? ""
+ emailIMAPServer = try container.decodeIfPresent(String.self, forKey: .emailIMAPServer) ?? ""
+ emailIMAPPort = try container.decodeIfPresent(Int.self, forKey: .emailIMAPPort) ?? 993
+ emailUsername = try container.decodeIfPresent(String.self, forKey: .emailUsername) ?? ""
+ emailPassword = try container.decodeIfPresent(String.self, forKey: .emailPassword) ?? ""
+ emailFolder = try container.decodeIfPresent(String.self, forKey: .emailFolder) ?? "INBOX"
+ isFreshRSSSource = try container.decodeIfPresent(Bool.self, forKey: .isFreshRSSSource) ?? false
+ tags = try container.decodeIfPresent([Tag].self, forKey: .tags) ?? []
+ }
+
+ /// True when the feed is scraped from a page rather than parsed from a feed document.
+ var isXPathFeed: Bool {
+ type == "HTML+XPath" || type == "XML+XPath"
+ }
+
+ /// True when the feed collects newsletters over IMAP.
+ var isEmailFeed: Bool {
+ !emailAddress.isEmpty || !emailIMAPServer.isEmpty
+ }
+
+ /// The site the feed belongs to, used for favicons and "open home page".
+ var siteURL: URL? {
+ if !link.isEmpty, let url = URL(string: link) { return url }
+ guard let feedURL = URL(string: url), let host = feedURL.host else { return nil }
+ var components = URLComponents()
+ components.scheme = feedURL.scheme ?? "https"
+ components.host = host
+ return components.url
+ }
+}
+
+extension String {
+ var nilIfBlank: String? {
+ trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
+ }
+}
diff --git a/frontend/Sources/Models/FeedTransfer.swift b/frontend/Sources/Models/FeedTransfer.swift
new file mode 100644
index 000000000..5d5876885
--- /dev/null
+++ b/frontend/Sources/Models/FeedTransfer.swift
@@ -0,0 +1,22 @@
+import UniformTypeIdentifiers
+
+extension UTType {
+ /// Specific to MrRSS, so a drag coming from another application is never
+ /// mistaken for one of our subscriptions.
+ ///
+ /// 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. The outline view
+/// writes it straight onto the drag pasteboard under `UTType.mrrssFeed`.
+struct FeedTransfer: Codable, Equatable {
+ let feedID: Int
+}
diff --git a/frontend/Sources/Models/FilterFields.swift b/frontend/Sources/Models/FilterFields.swift
new file mode 100644
index 000000000..d82f2fa5c
--- /dev/null
+++ b/frontend/Sources/Models/FilterFields.swift
@@ -0,0 +1,110 @@
+import Foundation
+
+/// A field a saved filter can test, matching the identifiers the backend
+/// understands in `/api/articles/filter`.
+enum FilterField: String, CaseIterable, Identifiable {
+ case feedName = "feed_name"
+ case feedCategory = "feed_category"
+ case feedTags = "feed_tags"
+ case articleTitle = "article_title"
+ case author
+ case url
+ case articleContent = "article_content"
+ case feedType = "feed_type"
+ case isImageModeFeed = "is_image_mode_feed"
+ case publishedAfter = "published_after"
+ case publishedBefore = "published_before"
+ case publishedAfterHours = "published_after_hours"
+ case publishedAfterDays = "published_after_days"
+ case isRead = "is_read"
+ case isFavorite = "is_favorite"
+ case isHidden = "is_hidden"
+ case isReadLater = "is_read_later"
+ case hasSummary = "has_summary"
+ case hasTranslation = "has_translation"
+ case hasImage = "has_image"
+ case hasAudio = "has_audio"
+ case hasVideo = "has_video"
+ case feedArticlesPerMonth = "feed_articles_per_month"
+ case feedLastUpdateStatus = "feed_last_update_status"
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .feedName: t("modal.filter.fromFeed")
+ case .feedCategory: t("sidebar.sort.byCategory")
+ case .feedTags: t("modal.feed.feedTags")
+ case .articleTitle: t("common.form.title")
+ case .author: t("modal.filter.author")
+ case .url: t("modal.filter.url")
+ case .articleContent: t("modal.filter.articleContent")
+ case .feedType: t("modal.filter.feedType")
+ case .isImageModeFeed: t("modal.filter.isImageModeFeed")
+ case .publishedAfter: t("modal.filter.publishedAfter")
+ case .publishedBefore: t("modal.filter.publishedBefore")
+ case .publishedAfterHours: t("modal.filter.publishedAfterHours")
+ case .publishedAfterDays: t("modal.filter.publishedAfterDays")
+ case .isRead: t("modal.filter.readStatus")
+ case .isFavorite: t("modal.filter.favoriteStatus")
+ case .isHidden: t("modal.filter.hiddenStatus")
+ case .isReadLater: t("modal.filter.readLaterStatus")
+ case .hasSummary: t("modal.filter.hasSummary")
+ case .hasTranslation: t("modal.filter.hasTranslation")
+ case .hasImage: t("modal.filter.hasImage")
+ case .hasAudio: t("modal.filter.hasAudio")
+ case .hasVideo: t("modal.filter.hasVideo")
+ case .feedArticlesPerMonth: t("modal.filter.feedArticlesPerMonth")
+ case .feedLastUpdateStatus: t("modal.filter.feedLastUpdateStatus")
+ }
+ }
+
+ /// How the value for this field is entered.
+ enum ValueKind {
+ case text
+ case number
+ case date
+ case boolean
+ case none
+ }
+
+ var valueKind: ValueKind {
+ switch self {
+ case .articleTitle, .author, .url, .articleContent, .feedName, .feedCategory,
+ .feedTags, .feedType, .feedLastUpdateStatus:
+ .text
+ case .publishedAfterHours, .publishedAfterDays, .feedArticlesPerMonth:
+ .number
+ case .publishedAfter, .publishedBefore:
+ .date
+ case .isRead, .isFavorite, .isHidden, .isReadLater, .hasSummary, .hasTranslation,
+ .hasImage, .hasAudio, .hasVideo, .isImageModeFeed:
+ .boolean
+ }
+ }
+
+ /// Only the free-text fields offer a matching mode.
+ var supportsOperators: Bool {
+ switch self {
+ case .articleTitle, .author, .url, .articleContent: true
+ default: false
+ }
+ }
+}
+
+/// How a text field is compared.
+enum FilterOperator: String, CaseIterable, Identifiable {
+ case contains
+ case exact
+ case regex
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .contains: t("modal.filter.contains")
+ case .exact: t("modal.filter.exactMatch")
+ case .regex: t("modal.filter.regex")
+ }
+ }
+}
diff --git a/frontend/Sources/Models/Organization.swift b/frontend/Sources/Models/Organization.swift
new file mode 100644
index 000000000..e54d331f2
--- /dev/null
+++ b/frontend/Sources/Models/Organization.swift
@@ -0,0 +1,150 @@
+import Foundation
+
+/// A user-defined tag attached to feeds.
+struct Tag: Identifiable, Codable, Hashable {
+ let id: Int
+ var name: String
+ var color: String
+ var position: Int
+
+ init(id: Int, name: String, color: String = "#3b82f6", position: Int = 0) {
+ self.id = id
+ self.name = name
+ self.color = color
+ self.position = position
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(Int.self, forKey: .id)
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#3b82f6"
+ position = try container.decodeIfPresent(Int.self, forKey: .position) ?? 0
+ }
+}
+
+/// One clause of a saved filter, matching the shape the previous frontend stored.
+struct FilterCondition: Codable, Hashable, Identifiable {
+ var id: Int
+ var logic: String
+ var negate: Bool
+ var field: String
+ var `operator`: String
+ var value: String
+ var values: [String]
+
+ init(
+ id: Int = Int(Date().timeIntervalSince1970 * 1_000),
+ logic: String = "and",
+ negate: Bool = false,
+ field: String = "article_title",
+ operator: String = "contains",
+ value: String = "",
+ values: [String] = []
+ ) {
+ self.id = id
+ self.logic = logic
+ self.negate = negate
+ self.field = field
+ self.operator = `operator`
+ self.value = value
+ self.values = values
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = (try? container.decode(Int.self, forKey: .id)) ?? Int(Date().timeIntervalSince1970 * 1_000)
+ logic = try container.decodeIfPresent(String.self, forKey: .logic) ?? "and"
+ negate = try container.decodeIfPresent(Bool.self, forKey: .negate) ?? false
+ field = try container.decodeIfPresent(String.self, forKey: .field) ?? "article_title"
+ `operator` = try container.decodeIfPresent(String.self, forKey: .operator) ?? "contains"
+ value = try container.decodeIfPresent(String.self, forKey: .value) ?? ""
+ values = try container.decodeIfPresent([String].self, forKey: .values) ?? []
+ }
+}
+
+/// A saved filter. The backend stores the conditions as a JSON string, so the
+/// model decodes and re-encodes that payload itself.
+struct SavedFilter: Identifiable, Codable, Hashable {
+ let id: Int
+ var name: String
+ var conditions: [FilterCondition]
+ var position: Int
+
+ enum CodingKeys: String, CodingKey {
+ case id, name, conditions, position
+ }
+
+ init(id: Int, name: String, conditions: [FilterCondition] = [], position: Int = 0) {
+ self.id = id
+ self.name = name
+ self.conditions = conditions
+ self.position = position
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(Int.self, forKey: .id)
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ position = try container.decodeIfPresent(Int.self, forKey: .position) ?? 0
+
+ if let nested = try? container.decode([FilterCondition].self, forKey: .conditions) {
+ conditions = nested
+ } else if let encoded = try? container.decode(String.self, forKey: .conditions),
+ let data = encoded.data(using: .utf8),
+ let decoded = try? JSONDecoder().decode([FilterCondition].self, from: data) {
+ conditions = decoded
+ } else {
+ conditions = []
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(id, forKey: .id)
+ try container.encode(name, forKey: .name)
+ try container.encode(position, forKey: .position)
+ let data = try JSONEncoder().encode(conditions)
+ try container.encode(String(data: data, encoding: .utf8) ?? "[]", forKey: .conditions)
+ }
+}
+
+/// One clause of an automation rule.
+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: "", enabled: true, conditions: [], actions: [])
+ }
+}
+
+struct RuleApplicationResult: Codable, Equatable {
+ let success: Bool
+ let affected: Int
+}
diff --git a/frontend/Sources/Models/SettingsCatalog.generated.swift b/frontend/Sources/Models/SettingsCatalog.generated.swift
new file mode 100644
index 000000000..140f6a71e
--- /dev/null
+++ b/frontend/Sources/Models/SettingsCatalog.generated.swift
@@ -0,0 +1,1001 @@
+// Generated from internal/config/settings_schema.json.
+// Regenerate with: python3 tools/settings-swift/generate.py
+
+import Foundation
+
+extension SettingsCatalog {
+ /// Every setting the backend stores, paired with the wording the
+ /// previous interface used for it.
+ static let generated: [SettingDefinition] = [
+ SettingDefinition(
+ key: "ai_api_key",
+ pane: .ai,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.ai.aiApiKey",
+ fallbackTitle: "AI API Key",
+ detailKey: "setting.ai.aiApiKeyDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_chat_enabled",
+ pane: .ai,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.ai.aiChatEnabled",
+ fallbackTitle: "AI Chat Enabled",
+ detailKey: "setting.ai.aiChatEnabledDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_chat_profile_id",
+ pane: .ai,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.ai.selectProfileForChat",
+ fallbackTitle: "AI Chat Profile ID",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_custom_headers",
+ pane: .ai,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.ai.aiCustomHeaders",
+ fallbackTitle: "AI Custom Headers",
+ detailKey: "setting.ai.aiCustomHeadersDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_endpoint",
+ pane: .ai,
+ control: .text,
+ defaultValue: "https://api.openai.com/v1/chat/completions",
+ titleKey: "setting.ai.aiEndpoint",
+ fallbackTitle: "AI Endpoint",
+ detailKey: "setting.ai.aiEndpointDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_model",
+ pane: .ai,
+ control: .text,
+ defaultValue: "gpt-4o-mini",
+ titleKey: "setting.ai.aiModel",
+ fallbackTitle: "AI Model",
+ detailKey: "setting.ai.aiModelDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_search_enabled",
+ pane: .ai,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.ai.aiSearchEnabled",
+ fallbackTitle: "AI Search Enabled",
+ detailKey: "setting.ai.aiSearchEnabledDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_search_profile_id",
+ pane: .ai,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.ai.selectProfileForSearch",
+ fallbackTitle: "AI Search Profile ID",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_summary_profile_id",
+ pane: .ai,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.ai.selectProfileForSummary",
+ fallbackTitle: "AI Summary Profile ID",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_summary_prompt",
+ pane: .ai,
+ control: .text,
+ defaultValue: "You are a summarizer. Generate a concise summary of the given text. Output ONLY the summary, nothing else.",
+ titleKey: "setting.content.aiSummaryPrompt",
+ fallbackTitle: "AI Summary Prompt",
+ detailKey: "setting.content.aiSummaryPromptDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_translation_profile_id",
+ pane: .ai,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.ai.selectProfileForTranslation",
+ fallbackTitle: "AI Translation Profile ID",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_translation_prompt",
+ pane: .ai,
+ control: .text,
+ defaultValue: "You are a translator. Translate the given text accurately. Output ONLY the translated text, nothing else.",
+ titleKey: "setting.content.aiTranslationPrompt",
+ fallbackTitle: "AI Translation Prompt",
+ detailKey: "setting.content.aiTranslationPromptDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_usage_limit",
+ pane: .ai,
+ control: .text,
+ defaultValue: "20000",
+ titleKey: "setting.ai.setUsageLimit",
+ fallbackTitle: "AI Usage Limit",
+ detailKey: "setting.ai.setUsageLimitDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "ai_usage_tokens",
+ pane: .ai,
+ control: .text,
+ defaultValue: "0",
+ titleKey: "setting.ai.aiUsageTokens",
+ fallbackTitle: "AI Usage Tokens",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "auto_cleanup_enabled",
+ pane: .storage,
+ control: .toggle,
+ defaultValue: "true",
+ titleKey: "setting.database.autoCleanup",
+ fallbackTitle: "Auto Cleanup Enabled",
+ detailKey: "setting.database.autoCleanupDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "auto_show_all_content",
+ pane: .reading,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.reading.autoShowAllContent",
+ fallbackTitle: "Auto Show All Content",
+ detailKey: "setting.reading.autoShowAllContentDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "baidu_app_id",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.content.baiduAppId",
+ fallbackTitle: "Baidu App ID",
+ detailKey: "setting.content.baiduAppIdDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "baidu_secret_key",
+ pane: .translation,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.content.baiduSecretKey",
+ fallbackTitle: "Baidu Secret Key",
+ detailKey: "setting.content.baiduSecretKeyDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "close_to_tray",
+ pane: .general,
+ control: .toggle,
+ defaultValue: "true",
+ titleKey: "setting.general.closeToTray",
+ fallbackTitle: "Close To Tray",
+ detailKey: "setting.general.closeToTrayDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "content_font_family",
+ pane: .typography,
+ control: .choice,
+ defaultValue: "system",
+ titleKey: "setting.typography.contentFontFamily",
+ fallbackTitle: "Content Font Family",
+ detailKey: "setting.typography.contentFontFamilyDesc",
+ choices: [SettingChoice(value: "system", titleKey: "setting.typography.fontSystem"), SettingChoice(value: "serif", titleKey: "setting.typography.fontSerif"), SettingChoice(value: "monospace", titleKey: "setting.typography.fontMonospace")]
+ ),
+ SettingDefinition(
+ key: "content_font_size",
+ pane: .typography,
+ control: .number,
+ defaultValue: "16",
+ titleKey: "setting.typography.contentFontSize",
+ fallbackTitle: "Content Font Size",
+ detailKey: "setting.typography.contentFontSizeDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "content_line_height",
+ pane: .typography,
+ control: .text,
+ defaultValue: "1.6",
+ titleKey: "setting.typography.contentLineHeight",
+ fallbackTitle: "Content Line Height",
+ detailKey: "setting.typography.contentLineHeightDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "custom_css_file",
+ pane: .customization,
+ control: .text,
+ defaultValue: "",
+ titleKey: nil,
+ fallbackTitle: "Custom CSS File",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "custom_translation_body_template",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.translation.custom.bodyTemplate",
+ fallbackTitle: "Custom Translation Body Template",
+ detailKey: "setting.translation.custom.bodyTemplateDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "custom_translation_enabled",
+ pane: .translation,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: nil,
+ fallbackTitle: "Custom Translation Enabled",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "custom_translation_endpoint",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.translation.custom.endpoint",
+ fallbackTitle: "Custom Translation Endpoint",
+ detailKey: "setting.translation.custom.endpointDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "custom_translation_headers",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.translation.custom.headers",
+ fallbackTitle: "Custom Translation Headers",
+ detailKey: "setting.translation.custom.headersDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "custom_translation_lang_mapping",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.translation.custom.langMapping",
+ fallbackTitle: "Custom Translation Lang Mapping",
+ detailKey: "setting.translation.custom.langMappingDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "custom_translation_method",
+ pane: .translation,
+ control: .choice,
+ defaultValue: "POST",
+ titleKey: "setting.translation.custom.method",
+ fallbackTitle: "Custom Translation Method",
+ detailKey: "setting.translation.custom.methodDesc",
+ choices: [SettingChoice(value: "POST", titleKey: nil), SettingChoice(value: "GET", titleKey: nil)]
+ ),
+ SettingDefinition(
+ key: "custom_translation_name",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: nil,
+ fallbackTitle: "Custom Translation Name",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "custom_translation_response_path",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.translation.custom.responsePath",
+ fallbackTitle: "Custom Translation Response Path",
+ detailKey: "setting.translation.custom.responsePathDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "custom_translation_timeout",
+ pane: .translation,
+ control: .number,
+ defaultValue: "10",
+ titleKey: "setting.translation.custom.timeout",
+ fallbackTitle: "Custom Translation Timeout",
+ detailKey: "setting.translation.custom.timeoutDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "deepl_api_key",
+ pane: .translation,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.content.deeplApiKey",
+ fallbackTitle: "Deepl API Key",
+ detailKey: "setting.content.deeplApiKeyDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "deepl_endpoint",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.content.deeplEndpoint",
+ fallbackTitle: "Deepl Endpoint",
+ detailKey: "setting.content.deeplEndpointDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "default_view_mode",
+ pane: .reading,
+ control: .choice,
+ defaultValue: "rendered",
+ titleKey: "setting.reading.defaultViewMode",
+ fallbackTitle: "Default View Mode",
+ detailKey: "setting.reading.defaultViewModeDesc",
+ choices: [SettingChoice(value: "rendered", titleKey: "setting.reading.viewAsRendered"), SettingChoice(value: "webpage", titleKey: "setting.reading.viewAsWebpage")]
+ ),
+ SettingDefinition(
+ key: "freshrss_api_password",
+ pane: .integrations,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.freshrss.apiPassword",
+ fallbackTitle: "Freshrss API Password",
+ detailKey: "setting.freshrss.apiPasswordDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "freshrss_auto_sync_interval",
+ pane: .integrations,
+ control: .number,
+ defaultValue: "0",
+ titleKey: nil,
+ fallbackTitle: "Freshrss Auto Sync Interval",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "freshrss_enabled",
+ pane: .integrations,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.freshrss.enabled",
+ fallbackTitle: "Freshrss Enabled",
+ detailKey: "setting.freshrss.enabledDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "freshrss_server_url",
+ pane: .integrations,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.freshrss.serverUrl",
+ fallbackTitle: "Freshrss Server URL",
+ detailKey: "setting.freshrss.serverUrlDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "freshrss_sync_on_startup",
+ pane: .integrations,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: nil,
+ fallbackTitle: "Freshrss Sync On Startup",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "freshrss_username",
+ pane: .integrations,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.freshrss.username",
+ fallbackTitle: "Freshrss Username",
+ detailKey: "setting.freshrss.usernameDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "full_text_fetch_enabled",
+ pane: .reading,
+ control: .toggle,
+ defaultValue: "true",
+ titleKey: "setting.feed.enableFullTextFetch",
+ fallbackTitle: "Full Text Fetch Enabled",
+ detailKey: "setting.feed.enableFullTextFetchDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "google_translate_endpoint",
+ pane: .translation,
+ control: .text,
+ defaultValue: "translate.googleapis.com",
+ titleKey: "setting.content.googleTranslateEndpoint",
+ fallbackTitle: "Google Translate Endpoint",
+ detailKey: "setting.content.googleTranslateEndpointDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "hover_mark_as_read",
+ pane: .reading,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.reading.hoverMarkAsRead",
+ fallbackTitle: "Hover Mark As Read",
+ detailKey: "setting.reading.hoverMarkAsReadDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "image_gallery_enabled",
+ pane: .general,
+ control: .toggle,
+ defaultValue: "true",
+ titleKey: "setting.reading.imageGalleryEnabled",
+ fallbackTitle: "Image Gallery Enabled",
+ detailKey: "setting.reading.imageGalleryEnabledDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "language",
+ pane: .general,
+ control: .choice,
+ defaultValue: "en-US",
+ titleKey: "setting.general.language",
+ fallbackTitle: "Language",
+ detailKey: "setting.general.languageDesc",
+ choices: [SettingChoice(value: "en-US", titleKey: nil), SettingChoice(value: "zh-CN", titleKey: nil)]
+ ),
+ SettingDefinition(
+ key: "layout_mode",
+ pane: .customization,
+ control: .choice,
+ defaultValue: "normal",
+ titleKey: "setting.typography.layoutMode",
+ fallbackTitle: "Layout Mode",
+ detailKey: "setting.typography.layoutModeDesc",
+ choices: [SettingChoice(value: "normal", titleKey: nil), SettingChoice(value: "compact", titleKey: nil), SettingChoice(value: "wide", titleKey: nil)]
+ ),
+ SettingDefinition(
+ key: "max_article_age_days",
+ pane: .storage,
+ control: .number,
+ defaultValue: "30",
+ titleKey: "setting.database.maxArticleAge",
+ fallbackTitle: "Max Article Age Days",
+ detailKey: "setting.database.maxArticleAgeDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "max_cache_size_mb",
+ pane: .storage,
+ control: .number,
+ defaultValue: "500",
+ titleKey: "setting.database.maxCacheSize",
+ fallbackTitle: "Max Cache Size MB",
+ detailKey: "setting.database.maxCacheSizeDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "max_concurrent_refreshes",
+ pane: .network,
+ control: .text,
+ defaultValue: "5",
+ titleKey: nil,
+ fallbackTitle: "Max Concurrent Refreshes",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "media_cache_enabled",
+ pane: .storage,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.database.mediaCacheEnabled",
+ fallbackTitle: "Media Cache Enabled",
+ detailKey: "setting.database.mediaCacheEnabledDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "media_cache_max_age_days",
+ pane: .storage,
+ control: .number,
+ defaultValue: "7",
+ titleKey: "setting.database.mediaCacheMaxAge",
+ fallbackTitle: "Media Cache Max Age Days",
+ detailKey: "setting.database.mediaCacheMaxAgeDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "media_cache_max_size_mb",
+ pane: .storage,
+ control: .number,
+ defaultValue: "200",
+ titleKey: "setting.database.mediaCacheMaxSize",
+ fallbackTitle: "Media Cache Max Size MB",
+ detailKey: "setting.database.mediaCacheMaxSizeDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "media_proxy_fallback",
+ pane: .storage,
+ control: .toggle,
+ defaultValue: "true",
+ titleKey: nil,
+ fallbackTitle: "Media Proxy Fallback",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "microsoft_api_key",
+ pane: .translation,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.content.microsoftApiKey",
+ fallbackTitle: "Microsoft API Key",
+ detailKey: "setting.content.microsoftApiKeyDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "microsoft_endpoint",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.content.microsoftEndpoint",
+ fallbackTitle: "Microsoft Endpoint",
+ detailKey: "setting.content.microsoftEndpointDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "microsoft_region",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.content.microsoftRegion",
+ fallbackTitle: "Microsoft Region",
+ detailKey: "setting.content.microsoftRegionDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "notion_api_key",
+ pane: .integrations,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.plugins.notion.apiKey",
+ fallbackTitle: "Notion API Key",
+ detailKey: "setting.plugins.notion.apiKeyDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "notion_enabled",
+ pane: .integrations,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: nil,
+ fallbackTitle: "Notion Enabled",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "notion_page_id",
+ pane: .integrations,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.plugins.notion.pageId",
+ fallbackTitle: "Notion Page ID",
+ detailKey: "setting.plugins.notion.pageIdDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "obsidian_enabled",
+ pane: .integrations,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: nil,
+ fallbackTitle: "Obsidian Enabled",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "obsidian_vault",
+ pane: .integrations,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.plugins.obsidian.vaultName",
+ fallbackTitle: "Obsidian Vault",
+ detailKey: "setting.plugins.obsidian.vaultNameDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "obsidian_vault_path",
+ pane: .integrations,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.plugins.obsidian.vaultPath",
+ fallbackTitle: "Obsidian Vault Path",
+ detailKey: "setting.plugins.obsidian.vaultPathDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "proxy_enabled",
+ pane: .network,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.network.enableProxy",
+ fallbackTitle: "Proxy Enabled",
+ detailKey: "setting.network.enableProxyDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "proxy_host",
+ pane: .network,
+ control: .text,
+ defaultValue: "127.0.0.1",
+ titleKey: "setting.network.proxyHost",
+ fallbackTitle: "Proxy Host",
+ detailKey: "setting.network.proxyHostDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "proxy_password",
+ pane: .network,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.network.proxyPassword",
+ fallbackTitle: "Proxy Password",
+ detailKey: "setting.network.proxyPasswordDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "proxy_port",
+ pane: .network,
+ control: .text,
+ defaultValue: "7890",
+ titleKey: "setting.network.proxyPort",
+ fallbackTitle: "Proxy Port",
+ detailKey: "setting.network.proxyPortDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "proxy_type",
+ pane: .network,
+ control: .choice,
+ defaultValue: "https",
+ titleKey: "setting.network.proxyType",
+ fallbackTitle: "Proxy Type",
+ detailKey: "setting.network.proxyTypeDesc",
+ choices: [SettingChoice(value: "http", titleKey: nil), SettingChoice(value: "https", titleKey: nil), SettingChoice(value: "socks5", titleKey: nil)]
+ ),
+ SettingDefinition(
+ key: "proxy_username",
+ pane: .network,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.network.proxyUsername",
+ fallbackTitle: "Proxy Username",
+ detailKey: "setting.network.proxyUsernameDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "refresh_mode",
+ pane: .general,
+ control: .choice,
+ defaultValue: "fixed",
+ titleKey: "setting.feed.refreshMode",
+ fallbackTitle: "Refresh Mode",
+ detailKey: "setting.feed.refreshModeDesc",
+ choices: [SettingChoice(value: "fixed", titleKey: "setting.feed.fixedInterval"), SettingChoice(value: "smart", titleKey: "setting.feed.intelligentInterval")]
+ ),
+ SettingDefinition(
+ key: "retry_timeout_seconds",
+ pane: .network,
+ control: .number,
+ defaultValue: "60",
+ titleKey: "setting.feed.retryTimeout",
+ fallbackTitle: "Retry Timeout Seconds",
+ detailKey: "setting.feed.retryTimeoutDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "rsshub_api_key",
+ pane: .integrations,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.rsshub.apiKey",
+ fallbackTitle: "Rsshub API Key",
+ detailKey: "setting.rsshub.apiKeyDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "rsshub_enabled",
+ pane: .integrations,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.rsshub.enabled",
+ fallbackTitle: "Rsshub Enabled",
+ detailKey: "setting.rsshub.enabledDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "rsshub_endpoint",
+ pane: .integrations,
+ control: .text,
+ defaultValue: "https://rss.spriple.org",
+ titleKey: "setting.rsshub.endpoint",
+ fallbackTitle: "Rsshub Endpoint",
+ detailKey: "setting.rsshub.endpointDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "shortcuts",
+ pane: .general,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.shortcut.shortcuts",
+ fallbackTitle: "Shortcuts",
+ detailKey: "setting.shortcut.shortcutsDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "shortcuts_enabled",
+ pane: .general,
+ control: .toggle,
+ defaultValue: "true",
+ titleKey: "setting.shortcut.shortcutsEnabled",
+ fallbackTitle: "Shortcuts Enabled",
+ detailKey: "setting.shortcut.shortcutsEnabledDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "show_article_preview_images",
+ pane: .reading,
+ control: .toggle,
+ defaultValue: "true",
+ titleKey: "setting.reading.showArticlePreviewImages",
+ fallbackTitle: "Show Article Preview Images",
+ detailKey: "setting.reading.showArticlePreviewImagesDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "show_floating_toc",
+ pane: .reading,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.reading.showFloatingToc",
+ fallbackTitle: "Show Floating Toc",
+ detailKey: "setting.reading.showFloatingTocDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "show_hidden_articles",
+ pane: .reading,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.reading.showHiddenArticles",
+ fallbackTitle: "Show Hidden Articles",
+ detailKey: "setting.reading.showHiddenArticlesDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "startup_on_boot",
+ pane: .general,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.general.startupOnBoot",
+ fallbackTitle: "Startup On Boot",
+ detailKey: "setting.general.startupOnBootDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "summary_enabled",
+ pane: .summary,
+ control: .toggle,
+ defaultValue: "true",
+ titleKey: "setting.content.enableSummary",
+ fallbackTitle: "Summary Enabled",
+ detailKey: "setting.content.enableSummaryDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "summary_length",
+ pane: .summary,
+ control: .choice,
+ defaultValue: "medium",
+ titleKey: "setting.content.summaryLength",
+ fallbackTitle: "Summary Length",
+ detailKey: "setting.content.summaryLengthDesc",
+ choices: [SettingChoice(value: "short", titleKey: nil), SettingChoice(value: "medium", titleKey: nil), SettingChoice(value: "long", titleKey: nil)]
+ ),
+ SettingDefinition(
+ key: "summary_provider",
+ pane: .summary,
+ control: .choice,
+ defaultValue: "local",
+ titleKey: "setting.content.summaryProvider",
+ fallbackTitle: "Summary Provider",
+ detailKey: "setting.content.summaryProviderDesc",
+ choices: [SettingChoice(value: "local", titleKey: nil), SettingChoice(value: "ai", titleKey: "setting.content.aiSummary")]
+ ),
+ SettingDefinition(
+ key: "summary_trigger_mode",
+ pane: .summary,
+ control: .choice,
+ defaultValue: "manual",
+ titleKey: "setting.content.summaryTriggerMode",
+ fallbackTitle: "Summary Trigger Mode",
+ detailKey: "setting.content.summaryTriggerModeDesc",
+ choices: [SettingChoice(value: "manual", titleKey: nil), SettingChoice(value: "auto", titleKey: nil)]
+ ),
+ SettingDefinition(
+ key: "target_language",
+ pane: .translation,
+ control: .text,
+ defaultValue: "zh",
+ titleKey: "setting.content.targetLanguage",
+ fallbackTitle: "Target Language",
+ detailKey: "setting.content.targetLanguageDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "tencent_region",
+ pane: .translation,
+ control: .text,
+ defaultValue: "ap-guangzhou",
+ titleKey: "setting.content.tencentRegion",
+ fallbackTitle: "Tencent Region",
+ detailKey: "setting.content.tencentRegionDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "tencent_secret_id",
+ pane: .translation,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.content.tencentSecretId",
+ fallbackTitle: "Tencent Secret ID",
+ detailKey: "setting.content.tencentSecretIdDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "tencent_secret_key",
+ pane: .translation,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.content.tencentSecretKey",
+ fallbackTitle: "Tencent Secret Key",
+ detailKey: "setting.content.tencentSecretKeyDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "theme",
+ pane: .general,
+ control: .choice,
+ defaultValue: "auto",
+ titleKey: "setting.general.theme",
+ fallbackTitle: "Theme",
+ detailKey: "setting.general.themeDesc",
+ choices: [SettingChoice(value: "auto", titleKey: nil), SettingChoice(value: "light", titleKey: nil), SettingChoice(value: "dark", titleKey: nil)]
+ ),
+ SettingDefinition(
+ key: "translation_enabled",
+ pane: .translation,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.content.enableTranslation",
+ fallbackTitle: "Translation Enabled",
+ detailKey: "setting.content.enableTranslationDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "translation_only_mode",
+ pane: .translation,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: "setting.content.translationOnlyMode",
+ fallbackTitle: "Translation Only Mode",
+ detailKey: "setting.content.translationOnlyModeDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "translation_provider",
+ pane: .translation,
+ control: .choice,
+ defaultValue: "google",
+ titleKey: "setting.content.translationProvider",
+ fallbackTitle: "Translation Provider",
+ detailKey: "setting.content.translationProviderDesc",
+ choices: [SettingChoice(value: "google", titleKey: "setting.content.googleTranslate"), SettingChoice(value: "deepl", titleKey: nil), SettingChoice(value: "baidu", titleKey: "setting.content.baiduTranslate"), SettingChoice(value: "microsoft", titleKey: "setting.content.microsoftTranslate"), SettingChoice(value: "tencent", titleKey: "setting.content.tencentTranslate"), SettingChoice(value: "ai", titleKey: "setting.content.aiTranslation"), SettingChoice(value: "custom", titleKey: nil)]
+ ),
+ SettingDefinition(
+ key: "ui_font_family",
+ pane: .typography,
+ control: .choice,
+ defaultValue: "system",
+ titleKey: "setting.general.uiFontFamily",
+ fallbackTitle: "UI Font Family",
+ detailKey: "setting.general.uiFontFamilyDesc",
+ choices: [SettingChoice(value: "system", titleKey: "setting.typography.fontSystem"), SettingChoice(value: "serif", titleKey: "setting.typography.fontSerif"), SettingChoice(value: "monospace", titleKey: "setting.typography.fontMonospace")]
+ ),
+ SettingDefinition(
+ key: "ui_font_size",
+ pane: .typography,
+ control: .number,
+ defaultValue: "16",
+ titleKey: "setting.general.uiFontSize",
+ fallbackTitle: "UI Font Size",
+ detailKey: "setting.general.uiFontSizeDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "update_check_enabled",
+ pane: .general,
+ control: .toggle,
+ defaultValue: "true",
+ titleKey: "setting.update.updateCheckEnabled",
+ fallbackTitle: "Update Check Enabled",
+ detailKey: "setting.update.updateCheckEnabledDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "update_interval",
+ pane: .general,
+ control: .number,
+ defaultValue: "30",
+ titleKey: nil,
+ fallbackTitle: "Update Interval",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "zotero_api_key",
+ pane: .integrations,
+ control: .secret,
+ defaultValue: "",
+ titleKey: "setting.plugins.zotero.apiKey",
+ fallbackTitle: "Zotero API Key",
+ detailKey: "setting.plugins.zotero.apiKeyDesc",
+ choices: []
+ ),
+ SettingDefinition(
+ key: "zotero_enabled",
+ pane: .integrations,
+ control: .toggle,
+ defaultValue: "false",
+ titleKey: nil,
+ fallbackTitle: "Zotero Enabled",
+ detailKey: nil,
+ choices: []
+ ),
+ SettingDefinition(
+ key: "zotero_user_id",
+ pane: .integrations,
+ control: .text,
+ defaultValue: "",
+ titleKey: "setting.plugins.zotero.userId",
+ fallbackTitle: "Zotero User ID",
+ detailKey: "setting.plugins.zotero.userIdDesc",
+ choices: []
+ )
+ ]
+}
diff --git a/frontend/Sources/Models/SettingsCatalog.swift b/frontend/Sources/Models/SettingsCatalog.swift
new file mode 100644
index 000000000..4025b4766
--- /dev/null
+++ b/frontend/Sources/Models/SettingsCatalog.swift
@@ -0,0 +1,154 @@
+import Foundation
+
+/// The tabs the settings window is divided into. The names follow the tabs the
+/// previous interface used, plus a connection tab for the server address, which
+/// only a separate client needs.
+enum SettingsPane: String, CaseIterable, Identifiable {
+ case connection
+ case feeds
+ case general
+ case reading
+ case typography
+ case customization
+ case translation
+ case summary
+ case ai
+ case network
+ case storage
+ case integrations
+ case rules
+ case statistics
+ case about
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .connection: t("client.settings.connection")
+ case .feeds: t("modal.feed.manageFeeds")
+ case .general: t("setting.tab.general")
+ case .reading: t("setting.tab.readingAndDisplay")
+ case .typography: t("setting.tab.typography")
+ case .customization: t("setting.tab.customization")
+ case .translation: t("setting.tab.content")
+ case .summary: t("article.summary.articleSummary")
+ case .ai: t("setting.tab.ai")
+ case .network: t("setting.tab.network")
+ case .storage: t("setting.database.cleanDatabaseTitle")
+ case .integrations: t("setting.tab.plugins")
+ case .rules: t("modal.rule.rules")
+ case .statistics: t("setting.statistic.statistics")
+ case .about: t("setting.tab.about")
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .connection: "server.rack"
+ case .feeds: "dot.radiowaves.left.and.right"
+ case .general: "gearshape"
+ case .reading: "text.book.closed"
+ case .typography: "textformat"
+ case .customization: "paintbrush"
+ case .translation: "character.book.closed"
+ case .summary: "text.quote"
+ case .ai: "sparkles"
+ case .network: "network"
+ case .storage: "externaldrive"
+ case .integrations: "puzzlepiece.extension"
+ case .rules: "bolt.badge.clock"
+ case .statistics: "chart.bar"
+ case .about: "info.circle"
+ }
+ }
+
+ /// Panes whose contents are generated from the schema rather than
+ /// hand-built.
+ var isSettingList: Bool {
+ switch self {
+ case .connection, .feeds, .rules, .statistics, .about: false
+ default: true
+ }
+ }
+}
+
+/// One option of a setting whose value comes from a fixed list.
+struct SettingChoice: Identifiable, Hashable {
+ let value: String
+ let titleKey: String?
+
+ var id: String { value }
+
+ var title: String {
+ guard let titleKey else { return value }
+ return t(titleKey)
+ }
+}
+
+/// How a setting's value is entered.
+enum SettingControl {
+ case toggle
+ case text
+ case secret
+ case number
+ case choice
+}
+
+/// One setting, as generated from the backend schema.
+struct SettingDefinition: Identifiable {
+ let key: String
+ let pane: SettingsPane
+ let control: SettingControl
+ let defaultValue: String
+ let titleKey: String?
+ let fallbackTitle: String
+ let detailKey: String?
+ let choices: [SettingChoice]
+
+ var id: String { key }
+
+ var title: String {
+ guard let titleKey else { return fallbackTitle }
+ let translated = t(titleKey)
+ return translated == titleKey ? fallbackTitle : translated
+ }
+
+ var detail: String? {
+ guard let detailKey else { return nil }
+ let translated = t(detailKey)
+ return translated == detailKey ? nil : translated
+ }
+
+ /// True when the value is a password or key that should not be shown.
+ var isSecret: Bool {
+ if case .secret = control { return true }
+ return false
+ }
+}
+
+enum SettingsCatalog {
+ static var definitions: [SettingDefinition] { generated }
+
+ static func definitions(for pane: SettingsPane) -> [SettingDefinition] {
+ generated.filter { $0.pane == pane }
+ }
+
+ /// The keys whose value is a boolean, used when reading and writing.
+ static var boolKeys: Set {
+ Set(generated.compactMap { definition in
+ if case .toggle = definition.control { return definition.key }
+ return nil
+ })
+ }
+
+ /// Finds the settings matching a search, so the window can offer one.
+ static func search(_ query: String) -> [SettingDefinition] {
+ let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !trimmed.isEmpty else { return [] }
+ return generated.filter { definition in
+ definition.key.lowercased().contains(trimmed)
+ || definition.title.lowercased().contains(trimmed)
+ || (definition.detail?.lowercased().contains(trimmed) ?? false)
+ }
+ }
+}
diff --git a/frontend/Sources/Models/System.swift b/frontend/Sources/Models/System.swift
new file mode 100644
index 000000000..fd7de29ff
--- /dev/null
+++ b/frontend/Sources/Models/System.swift
@@ -0,0 +1,416 @@
+import Foundation
+
+/// A blog found by the discovery engine.
+struct DiscoveredBlog: Codable, Hashable, Identifiable {
+ let name: String
+ let homepage: String
+ let rssFeed: String
+ let iconURL: String?
+ let recentArticles: [DiscoveredArticle]
+
+ var id: String { rssFeed.isEmpty ? homepage : rssFeed }
+
+ enum CodingKeys: String, CodingKey {
+ case name, homepage
+ case rssFeed = "rss_feed"
+ case iconURL = "icon_url"
+ case recentArticles = "recent_articles"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+ homepage = try container.decodeIfPresent(String.self, forKey: .homepage) ?? ""
+ rssFeed = try container.decodeIfPresent(String.self, forKey: .rssFeed) ?? ""
+ iconURL = try container.decodeIfPresent(String.self, forKey: .iconURL)?.nilIfBlank
+ recentArticles = try container.decodeIfPresent([DiscoveredArticle].self, forKey: .recentArticles) ?? []
+ }
+}
+
+struct DiscoveredArticle: Codable, Hashable, Identifiable {
+ let title: String
+ let date: String
+
+ var id: String { title + date }
+}
+
+/// How far a discovery run has progressed.
+struct DiscoveryProgress: Codable, Hashable {
+ var stage: String = ""
+ var message: String = ""
+ var detail: String = ""
+ var current: Int = 0
+ var total: Int = 0
+ var feedName: String = ""
+ var foundCount: Int = 0
+
+ enum CodingKeys: String, CodingKey {
+ case stage, message, detail, current, total
+ case feedName = "feed_name"
+ case foundCount = "found_count"
+ }
+
+ init() {}
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ stage = try container.decodeIfPresent(String.self, forKey: .stage) ?? ""
+ message = try container.decodeIfPresent(String.self, forKey: .message) ?? ""
+ detail = try container.decodeIfPresent(String.self, forKey: .detail) ?? ""
+ current = try container.decodeIfPresent(Int.self, forKey: .current) ?? 0
+ total = try container.decodeIfPresent(Int.self, forKey: .total) ?? 0
+ feedName = try container.decodeIfPresent(String.self, forKey: .feedName) ?? ""
+ foundCount = try container.decodeIfPresent(Int.self, forKey: .foundCount) ?? 0
+ }
+
+ /// A fraction between 0 and 1, or nil when the total is unknown.
+ var fraction: Double? {
+ guard total > 0 else { return nil }
+ return min(1, Double(current) / Double(total))
+ }
+}
+
+/// The polled state of a discovery run.
+struct DiscoveryState: Codable, Hashable {
+ var isRunning: Bool = false
+ var isComplete: Bool = false
+ var progress = DiscoveryProgress()
+ var feeds: [DiscoveredBlog] = []
+ var error: String = ""
+
+ enum CodingKeys: String, CodingKey {
+ case progress, feeds, error
+ case isRunning = "is_running"
+ case isComplete = "is_complete"
+ }
+
+ init() {}
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ isRunning = try container.decodeIfPresent(Bool.self, forKey: .isRunning) ?? false
+ isComplete = try container.decodeIfPresent(Bool.self, forKey: .isComplete) ?? false
+ progress = try container.decodeIfPresent(DiscoveryProgress.self, forKey: .progress) ?? DiscoveryProgress()
+ feeds = try container.decodeIfPresent([DiscoveredBlog].self, forKey: .feeds) ?? []
+ error = try container.decodeIfPresent(String.self, forKey: .error) ?? ""
+ }
+}
+
+/// Progress of a feed refresh run, as `/api/progress` reports it.
+struct RefreshProgress: Codable, Equatable {
+ let isRunning: Bool
+ /// Refreshes currently being worked on.
+ var poolTaskCount: Int = 0
+ /// Refreshes waiting for a slot.
+ var queueTaskCount: Int = 0
+ /// Content fetches triggered by opening an article.
+ var articleClickCount: Int = 0
+ /// Feeds that failed, keyed by identifier.
+ var errors: [String: String] = [:]
+
+ enum CodingKeys: String, CodingKey {
+ case errors
+ case isRunning = "is_running"
+ case poolTaskCount = "pool_task_count"
+ case queueTaskCount = "queue_task_count"
+ case articleClickCount = "article_click_count"
+ }
+
+ init(
+ isRunning: Bool,
+ poolTaskCount: Int = 0,
+ queueTaskCount: Int = 0,
+ articleClickCount: Int = 0,
+ errors: [String: String] = [:]
+ ) {
+ self.isRunning = isRunning
+ self.poolTaskCount = poolTaskCount
+ self.queueTaskCount = queueTaskCount
+ self.articleClickCount = articleClickCount
+ self.errors = errors
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ isRunning = try container.decodeIfPresent(Bool.self, forKey: .isRunning) ?? false
+ poolTaskCount = try container.decodeIfPresent(Int.self, forKey: .poolTaskCount) ?? 0
+ queueTaskCount = try container.decodeIfPresent(Int.self, forKey: .queueTaskCount) ?? 0
+ articleClickCount = try container.decodeIfPresent(Int.self, forKey: .articleClickCount) ?? 0
+ errors = try container.decodeIfPresent([String: String].self, forKey: .errors) ?? [:]
+ }
+
+ /// How much work is outstanding altogether.
+ var outstandingCount: Int {
+ poolTaskCount + queueTaskCount + articleClickCount
+ }
+}
+
+/// The result of asking the backend whether a newer release exists.
+struct UpdateInfo: Codable, Equatable {
+ let currentVersion: String
+ let latestVersion: String
+ let hasUpdate: Bool
+ let platform: String
+ let arch: String
+ let isPortable: Bool
+ let downloadURL: String?
+ let assetName: String?
+ let assetSize: Int?
+ let error: String?
+
+ enum CodingKeys: String, CodingKey {
+ case platform, arch, error
+ case currentVersion = "current_version"
+ case latestVersion = "latest_version"
+ case hasUpdate = "has_update"
+ case isPortable = "is_portable"
+ case downloadURL = "download_url"
+ case assetName = "asset_name"
+ case assetSize = "asset_size"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ currentVersion = try container.decodeIfPresent(String.self, forKey: .currentVersion) ?? ""
+ latestVersion = try container.decodeIfPresent(String.self, forKey: .latestVersion) ?? ""
+ hasUpdate = try container.decodeIfPresent(Bool.self, forKey: .hasUpdate) ?? false
+ platform = try container.decodeIfPresent(String.self, forKey: .platform) ?? ""
+ arch = try container.decodeIfPresent(String.self, forKey: .arch) ?? ""
+ isPortable = try container.decodeIfPresent(Bool.self, forKey: .isPortable) ?? false
+ downloadURL = try container.decodeIfPresent(String.self, forKey: .downloadURL)?.nilIfBlank
+ assetName = try container.decodeIfPresent(String.self, forKey: .assetName)?.nilIfBlank
+ assetSize = try container.decodeIfPresent(Int.self, forKey: .assetSize)
+ error = try container.decodeIfPresent(String.self, forKey: .error)?.nilIfBlank
+ }
+}
+
+/// Reading statistics for one period.
+struct StatisticsSummary: Codable, Equatable {
+ let period: String
+ let startDate: String
+ let endDate: String
+ let totals: [String: Int]
+ let dailyData: [String: [String: Int]]
+ let canNavigate: Bool
+ let hasPrevious: Bool
+ let hasNext: Bool
+ let displayLabel: String
+
+ enum CodingKeys: String, CodingKey {
+ case period, totals
+ case startDate = "start_date"
+ case endDate = "end_date"
+ case dailyData = "daily_data"
+ case canNavigate = "can_navigate"
+ case hasPrevious = "has_previous"
+ case hasNext = "has_next"
+ case displayLabel = "display_label"
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ period = try container.decodeIfPresent(String.self, forKey: .period) ?? ""
+ startDate = try container.decodeIfPresent(String.self, forKey: .startDate) ?? ""
+ endDate = try container.decodeIfPresent(String.self, forKey: .endDate) ?? ""
+ totals = try container.decodeIfPresent([String: Int].self, forKey: .totals) ?? [:]
+ dailyData = try container.decodeIfPresent([String: [String: Int]].self, forKey: .dailyData) ?? [:]
+ canNavigate = try container.decodeIfPresent(Bool.self, forKey: .canNavigate) ?? false
+ hasPrevious = try container.decodeIfPresent(Bool.self, forKey: .hasPrevious) ?? false
+ hasNext = try container.decodeIfPresent(Bool.self, forKey: .hasNext) ?? false
+ displayLabel = try container.decodeIfPresent(String.self, forKey: .displayLabel) ?? ""
+ }
+}
+
+/// How many articles have cached content.
+struct ContentCacheInfo: Codable, Equatable {
+ let cachedArticles: Int
+
+ enum CodingKeys: String, CodingKey {
+ case cachedArticles = "cached_articles"
+ }
+
+ init(cachedArticles: Int) {
+ self.cachedArticles = cachedArticles
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ cachedArticles = try container.decodeIfPresent(Int.self, forKey: .cachedArticles) ?? 0
+ }
+}
+
+/// How much disk the media cache uses.
+struct MediaCacheInfo: Codable, Equatable {
+ let cacheSizeMB: Double
+
+ enum CodingKeys: String, CodingKey {
+ case cacheSizeMB = "cache_size_mb"
+ }
+
+ init(cacheSizeMB: Double) {
+ self.cacheSizeMB = cacheSizeMB
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ cacheSizeMB = try container.decodeIfPresent(Double.self, forKey: .cacheSizeMB) ?? 0
+ }
+}
+
+/// What the FreshRSS integration reports about pending synchronisation.
+struct FreshRSSStatus: Codable, Equatable {
+ let pendingChanges: Int
+ let failedItems: Int
+ let lastSyncTime: String?
+
+ enum CodingKeys: String, CodingKey {
+ case pendingChanges = "pending_changes"
+ case failedItems = "failed_items"
+ case lastSyncTime = "last_sync_time"
+ }
+
+ init(pendingChanges: Int, failedItems: Int, lastSyncTime: String?) {
+ self.pendingChanges = pendingChanges
+ self.failedItems = failedItems
+ self.lastSyncTime = lastSyncTime
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ pendingChanges = try container.decodeIfPresent(Int.self, forKey: .pendingChanges) ?? 0
+ failedItems = try container.decodeIfPresent(Int.self, forKey: .failedItems) ?? 0
+ lastSyncTime = try container.decodeIfPresent(String.self, forKey: .lastSyncTime)?.nilIfBlank
+ }
+}
+
+struct TitleTranslationResponse: Codable, Equatable {
+ let translatedTitle: String
+ let limitReached: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case translatedTitle = "translated_title"
+ case limitReached = "limit_reached"
+ }
+
+ init(translatedTitle: String, limitReached: Bool) {
+ self.translatedTitle = translatedTitle
+ self.limitReached = limitReached
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ translatedTitle = try container.decodeIfPresent(String.self, forKey: .translatedTitle) ?? ""
+ limitReached = try container.decodeIfPresent(Bool.self, forKey: .limitReached) ?? false
+ }
+}
+
+struct TextTranslationResponse: Codable, Equatable {
+ let translatedText: String
+ let html: String
+
+ enum CodingKeys: String, CodingKey {
+ case translatedText = "translated_text"
+ case html
+ }
+
+ init(translatedText: String, html: String) {
+ self.translatedText = translatedText
+ self.html = html
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ translatedText = try container.decodeIfPresent(String.self, forKey: .translatedText) ?? ""
+ html = try container.decodeIfPresent(String.self, forKey: .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"
+ }
+}
+
+/// The window geometry the backend remembers between launches.
+struct WindowState: Codable, Equatable {
+ var x: Int
+ var y: Int
+ var width: Int
+ var height: Int
+ var maximized: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case x, y, width, height, maximized
+ }
+
+ init(x: Int, y: Int, width: Int, height: Int, maximized: Bool = false) {
+ self.x = x
+ self.y = y
+ self.width = width
+ self.height = height
+ self.maximized = maximized
+ }
+
+ /// The values arrive as strings, so each one is read leniently.
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ func number(_ key: CodingKeys, default fallback: Int) -> Int {
+ if let value = try? container.decode(Int.self, forKey: key) { return value }
+ if let text = try? container.decode(String.self, forKey: key), let value = Int(text) {
+ return value
+ }
+ return fallback
+ }
+ x = number(.x, default: 0)
+ y = number(.y, default: 0)
+ width = number(.width, default: 1_280)
+ height = number(.height, default: 780)
+ if let value = try? container.decode(Bool.self, forKey: .maximized) {
+ maximized = value
+ } else if let text = try? container.decode(String.self, forKey: .maximized) {
+ maximized = text == "true" || text == "1"
+ } else {
+ maximized = false
+ }
+ }
+
+ var jsonBody: [String: Any] {
+ ["x": x, "y": y, "width": width, "height": height, "maximized": maximized]
+ }
+}
+
+/// The custom fetch scripts the backend has available.
+struct ScriptList: Codable, Equatable {
+ let scripts: [String]
+ let scriptsDir: String
+
+ enum CodingKeys: String, CodingKey {
+ case scripts
+ case scriptsDir = "scripts_dir"
+ }
+
+ init(scripts: [String], scriptsDir: String) {
+ self.scripts = scripts
+ self.scriptsDir = scriptsDir
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ scripts = try container.decodeIfPresent([String].self, forKey: .scripts) ?? []
+ scriptsDir = try container.decodeIfPresent(String.self, forKey: .scriptsDir) ?? ""
+ }
+}
diff --git a/frontend/Sources/MrRSSApp.swift b/frontend/Sources/MrRSSApp.swift
new file mode 100644
index 000000000..70392bcad
--- /dev/null
+++ b/frontend/Sources/MrRSSApp.swift
@@ -0,0 +1,37 @@
+import SwiftUI
+
+@main
+struct MrRSSApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ @StateObject private var viewModel = AppViewModel(autoLoad: false)
+ @StateObject private var localization = Localization.shared
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView(viewModel: viewModel)
+ .preferredColorScheme(viewModel.preferredColorScheme)
+ // Interface strings are read through `t(_:)` rather than through
+ // the environment, so the tree is rebuilt when the language changes.
+ .id(localization.language)
+ .environmentObject(localization)
+ .persistingWindowState(with: viewModel)
+ .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/Sources/Services/API/APIClient.swift b/frontend/Sources/Services/API/APIClient.swift
new file mode 100644
index 000000000..8e6b4e036
--- /dev/null
+++ b/frontend/Sources/Services/API/APIClient.swift
@@ -0,0 +1,353 @@
+import Foundation
+
+/// Everything the interface needs from the backend. `APIService` is the live
+/// implementation; tests use `StubAPIClient`, which fails any call a test has
+/// not explicitly prepared.
+protocol APIClient: AnyObject {
+ var baseURL: URL { get }
+
+ // Connection
+ func checkConnection() async throws
+ func fetchVersion() async throws -> String
+
+ // Feeds
+ func fetchFeeds() async throws -> [Feed]
+ func addFeed(_ draft: FeedDraft) async throws
+ func updateFeed(_ draft: FeedDraft) 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 refreshFeed(id: Int) async throws
+ func fetchRefreshProgress() async throws -> RefreshProgress
+ func testIMAPConnection(_ draft: FeedDraft) async throws -> String
+
+ // Discovery
+ func startDiscovery(feedID: Int) async throws
+ func fetchDiscoveryProgress() async throws -> DiscoveryState
+ func clearDiscovery() async throws
+ func startDiscoverAll() async throws
+ func fetchDiscoverAllProgress() async throws -> DiscoveryState
+ func clearDiscoverAll() async throws
+
+ // RSSHub
+ func testRSSHubConnection() async throws -> String
+ func transformRSSHubURL(_ url: String) async throws -> String
+
+ // Articles
+ func fetchArticles(
+ feedID: Int?,
+ category: String?,
+ filter: String,
+ page: Int,
+ limit: Int
+ ) async throws -> [Article]
+ func fetchImageArticles(page: Int, limit: Int) async throws -> [Article]
+ func filterArticles(conditions: [FilterCondition], page: Int, limit: Int) async throws -> FilteredArticles
+ func setArticleRead(id: Int, read: Bool) async throws
+ func toggleFavorite(id: Int) async throws
+ func toggleReadLater(id: Int) async throws
+ func toggleHidden(id: Int) async throws
+ func markRelative(id: Int, direction: String, feedID: Int?, category: String?) async throws -> Int
+ func markAllRead(feedID: Int?, category: String?) async throws
+ func clearReadLater() async throws
+ func fetchArticleContent(id: Int) async throws -> ArticleContent
+ func reloadArticleContent(id: Int) async throws -> ArticleContent
+ func fetchFullArticle(id: Int) async throws -> ArticleContent
+ func extractImages(id: Int) async throws -> ArticleImages
+ func fetchUnreadCounts() async throws -> UnreadCounts
+ func fetchFilterCounts() async throws -> FilterCounts
+
+ // Article export
+ func exportArticle(id: Int, destination: ArticleExportDestination) async throws -> String
+
+ // Translation and summaries
+ 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
+
+ // Tags
+ func fetchTags() async throws -> [Tag]
+ func createTag(name: String, color: String) async throws -> Tag
+ func updateTag(_ tag: Tag) async throws
+ func deleteTag(id: Int) async throws
+ func reorderTag(id: Int, newPosition: Int) async throws
+
+ // Saved filters
+ func fetchSavedFilters() async throws -> [SavedFilter]
+ func createSavedFilter(name: String, conditions: [FilterCondition]) async throws -> SavedFilter
+ func updateSavedFilter(_ filter: SavedFilter) async throws
+ func deleteSavedFilter(id: Int) async throws
+ func reorderSavedFilters(_ filters: [SavedFilter]) async throws
+
+ // Rules
+ func applyRule(_ rule: AutomationRule) async throws -> RuleApplicationResult
+
+ // Settings
+ func fetchSettings() async throws -> [String: String]
+ func updateSettings(_ settings: [String: String]) async throws
+
+ // AI
+ func fetchAIUsage() async throws -> AIUsage
+ func resetAIUsage() async throws
+ func fetchAIProfiles() async throws -> [AIProfile]
+ func saveAIProfile(_ profile: AIProfile) async throws -> AIProfile
+ func deleteAIProfile(id: Int) async throws
+ func setDefaultAIProfile(id: Int) async throws
+ func testAIProfiles() async throws -> [AIProfileTestResult]
+ func aiSearch(query: String) async throws -> AISearchResponse
+ func sendChatMessage(_ request: ChatRequest) async throws -> ChatResponse
+ func fetchChatSessions(articleID: Int) async throws -> [ChatSession]
+ func createChatSession(articleID: Int, title: String) async throws -> ChatSession
+ func fetchChatMessages(sessionID: Int) async throws -> [ChatMessage]
+ func deleteChatSession(id: Int) async throws
+ func deleteAllChatSessions() async throws
+
+ // Maintenance and system
+ func fetchStatistics(period: String, offset: Int) async throws -> StatisticsSummary
+ func fetchAllTimeStatistics() async throws -> [String: Int]
+ func fetchContentCacheInfo() async throws -> ContentCacheInfo
+ func cleanupArticles() async throws
+ func cleanupContentCache() async throws
+ func fetchMediaCacheInfo() async throws -> MediaCacheInfo
+ func cleanupMediaCache() async throws
+ func checkForUpdates() async throws -> UpdateInfo
+ func fetchFreshRSSStatus() async throws -> FreshRSSStatus
+ func syncFreshRSS() async throws
+ func syncFreshRSSFeed(id: Int) async throws
+ func exportOPML() async throws -> Data
+ func importOPML(data: Data, filename: String) async throws
+ func openInBrowser(url: String) async throws -> String?
+
+ // Window and scripts
+ func fetchWindowState() async throws -> WindowState
+ func saveWindowState(_ state: WindowState) async throws
+ func fetchScripts() async throws -> ScriptList
+ func uploadCustomCSS(data: Data, filename: String) async throws
+ func deleteCustomCSS() async throws
+ func fetchCustomCSS() async throws -> String
+}
+
+/// Where an article can be sent from the reading view.
+enum ArticleExportDestination: String, CaseIterable, Identifiable {
+ case obsidian
+ case notion
+ case zotero
+
+ var id: String { rawValue }
+
+ var endpoint: String { "articles/export/\(rawValue)" }
+
+ var localizedTitle: String {
+ switch self {
+ case .obsidian: t("setting.plugins.obsidian.exportTo")
+ case .notion: t("setting.plugins.notion.exportTo")
+ case .zotero: t("setting.plugins.zotero.exportTo")
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .obsidian: "square.stack.3d.up"
+ case .notion: "note.text"
+ case .zotero: "books.vertical"
+ }
+ }
+
+ var localizedSuccess: String {
+ switch self {
+ case .obsidian: t("setting.plugins.obsidian.exported")
+ case .notion: t("setting.plugins.notion.exported")
+ case .zotero: t("setting.plugins.zotero.exported")
+ }
+ }
+
+ var localizedFailure: String {
+ switch self {
+ case .obsidian: t("setting.plugins.obsidian.exportFailed")
+ case .notion: t("setting.plugins.notion.exportFailed")
+ case .zotero: t("setting.plugins.zotero.exportFailed")
+ }
+ }
+
+ var localizedProgress: String {
+ switch self {
+ case .obsidian: t("setting.plugins.obsidian.exporting")
+ case .notion: t("setting.plugins.notion.exporting")
+ case .zotero: t("setting.plugins.zotero.exporting")
+ }
+ }
+}
+
+/// The payload the add and edit feed forms send.
+struct FeedDraft: Equatable {
+ var id: Int?
+ var url: String = ""
+ var title: String = ""
+ var category: String = ""
+ var scriptPath: String = ""
+ var hideFromTimeline: Bool = false
+ var proxyURL: String = ""
+ var proxyEnabled: Bool = false
+ var refreshInterval: Int = 0
+ var isImageMode: Bool = false
+ var type: String = ""
+ var xPathItem: String = ""
+ var xPathItemTitle: String = ""
+ var xPathItemContent: String = ""
+ var xPathItemURI: String = ""
+ var xPathItemAuthor: String = ""
+ var xPathItemTimestamp: String = ""
+ var xPathItemTimeFormat: String = ""
+ var xPathItemThumbnail: String = ""
+ var xPathItemCategories: String = ""
+ var xPathItemUID: String = ""
+ var articleViewMode: String = "global"
+ var autoExpandContent: String = "global"
+ var emailAddress: String = ""
+ var emailIMAPServer: String = ""
+ var emailIMAPPort: Int = 993
+ var emailUsername: String = ""
+ var emailPassword: String = ""
+ var emailFolder: String = "INBOX"
+ var tags: [Int] = []
+
+ init(id: Int? = nil, url: String = "", title: String = "", category: String = "") {
+ self.id = id
+ self.url = url
+ self.title = title
+ self.category = category
+ }
+
+ /// Builds a draft that round-trips an existing feed through the edit form.
+ init(feed: Feed, tags: [Int] = []) {
+ id = feed.id
+ url = feed.url
+ title = feed.title
+ category = feed.category
+ scriptPath = feed.scriptPath
+ hideFromTimeline = feed.hideFromTimeline
+ proxyURL = feed.proxyURL
+ proxyEnabled = feed.proxyEnabled
+ refreshInterval = feed.refreshInterval
+ isImageMode = feed.isImageMode
+ type = feed.type
+ xPathItem = feed.xPathItem
+ xPathItemTitle = feed.xPathItemTitle
+ xPathItemContent = feed.xPathItemContent
+ xPathItemURI = feed.xPathItemURI
+ xPathItemAuthor = feed.xPathItemAuthor
+ xPathItemTimestamp = feed.xPathItemTimestamp
+ xPathItemTimeFormat = feed.xPathItemTimeFormat
+ xPathItemThumbnail = feed.xPathItemThumbnail
+ xPathItemCategories = feed.xPathItemCategories
+ xPathItemUID = feed.xPathItemUID
+ articleViewMode = feed.articleViewMode
+ autoExpandContent = feed.autoExpandContent
+ emailAddress = feed.emailAddress
+ emailIMAPServer = feed.emailIMAPServer
+ emailIMAPPort = feed.emailIMAPPort
+ emailUsername = feed.emailUsername
+ emailPassword = feed.emailPassword
+ emailFolder = feed.emailFolder
+ self.tags = tags
+ }
+
+ /// The JSON body shared by `/feeds/add` and `/feeds/update`.
+ var jsonBody: [String: Any] {
+ var body: [String: Any] = [
+ "url": url,
+ "title": title,
+ "category": category,
+ "script_path": scriptPath,
+ "hide_from_timeline": hideFromTimeline,
+ "proxy_url": proxyURL,
+ "proxy_enabled": proxyEnabled,
+ "refresh_interval": refreshInterval,
+ "is_image_mode": isImageMode,
+ "type": type,
+ "xpath_item": xPathItem,
+ "xpath_item_title": xPathItemTitle,
+ "xpath_item_content": xPathItemContent,
+ "xpath_item_uri": xPathItemURI,
+ "xpath_item_author": xPathItemAuthor,
+ "xpath_item_timestamp": xPathItemTimestamp,
+ "xpath_item_time_format": xPathItemTimeFormat,
+ "xpath_item_thumbnail": xPathItemThumbnail,
+ "xpath_item_categories": xPathItemCategories,
+ "xpath_item_uid": xPathItemUID,
+ "article_view_mode": articleViewMode,
+ "auto_expand_content": autoExpandContent,
+ "email_address": emailAddress,
+ "email_imap_server": emailIMAPServer,
+ "email_imap_port": emailIMAPPort,
+ "email_username": emailUsername,
+ "email_password": emailPassword,
+ "email_folder": emailFolder,
+ "tags": tags
+ ]
+ if let id {
+ body["id"] = id
+ }
+ return body
+ }
+}
+
+/// A page of results from the saved-filter endpoint.
+struct FilteredArticles: Codable, Equatable {
+ let articles: [Article]
+ let total: Int
+ let page: Int
+ let limit: Int
+ let hasMore: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case articles, total, page, limit
+ case hasMore = "has_more"
+ }
+
+ init(articles: [Article], total: Int, page: Int, limit: Int, hasMore: Bool) {
+ self.articles = articles
+ self.total = total
+ self.page = page
+ self.limit = limit
+ self.hasMore = hasMore
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ articles = try container.decodeIfPresent([Article].self, forKey: .articles) ?? []
+ total = try container.decodeIfPresent(Int.self, forKey: .total) ?? 0
+ page = try container.decodeIfPresent(Int.self, forKey: .page) ?? 1
+ limit = try container.decodeIfPresent(Int.self, forKey: .limit) ?? 0
+ hasMore = try container.decodeIfPresent(Bool.self, forKey: .hasMore) ?? false
+ }
+}
+
+/// One turn of an AI conversation, as the chat endpoint expects it.
+struct ChatRequest: Encodable {
+ struct Turn: Encodable {
+ let role: String
+ let content: String
+ }
+
+ var messages: [Turn]
+ var sessionID: Int?
+ var articleID: Int?
+ var articleTitle: String?
+ var articleURL: String?
+ var articleContent: String?
+ var isFirstMessage: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case messages
+ case sessionID = "session_id"
+ case articleID = "article_id"
+ case articleTitle = "article_title"
+ case articleURL = "article_url"
+ case articleContent = "article_content"
+ case isFirstMessage = "is_first_message"
+ }
+}
diff --git a/frontend/Sources/Services/API/APIService+AI.swift b/frontend/Sources/Services/API/APIService+AI.swift
new file mode 100644
index 000000000..82cdd50c7
--- /dev/null
+++ b/frontend/Sources/Services/API/APIService+AI.swift
@@ -0,0 +1,99 @@
+import Foundation
+
+extension APIService {
+ // MARK: - Usage
+
+ func fetchAIUsage() async throws -> AIUsage {
+ try await get("ai-usage")
+ }
+
+ func resetAIUsage() async throws {
+ try await post("ai-usage/reset")
+ }
+
+ // MARK: - Profiles
+
+ func fetchAIProfiles() async throws -> [AIProfile] {
+ try await get("ai/profiles")
+ }
+
+ func saveAIProfile(_ profile: AIProfile) async throws -> AIProfile {
+ var body: [String: Any] = [
+ "name": profile.name,
+ "endpoint": profile.endpoint,
+ "model": profile.model,
+ "custom_headers": profile.customHeaders,
+ "is_default": profile.isDefault
+ ]
+ if !profile.apiKey.isEmpty {
+ body["api_key"] = profile.apiKey
+ }
+
+ if profile.id > 0 {
+ body["id"] = profile.id
+ let data = try await send("ai/profiles/\(profile.id)", method: "PUT", jsonBody: body)
+ return (try? decode(data) as AIProfile) ?? profile
+ }
+ return try await postDecoding("ai/profiles", jsonBody: body)
+ }
+
+ func deleteAIProfile(id: Int) async throws {
+ try await send("ai/profiles/\(id)", method: "DELETE")
+ }
+
+ func setDefaultAIProfile(id: Int) async throws {
+ try await post("ai/profiles/\(id)/default")
+ }
+
+ func testAIProfiles() async throws -> [AIProfileTestResult] {
+ try await postDecoding("ai/profiles/test-all")
+ }
+
+ // MARK: - Search
+
+ func aiSearch(query: String) async throws -> AISearchResponse {
+ struct Request: Encodable { let query: String }
+ return try await postJSON("ai/search", body: Request(query: query))
+ }
+
+ // MARK: - Chat
+
+ func sendChatMessage(_ request: ChatRequest) async throws -> ChatResponse {
+ try await postJSON("ai-chat", body: request)
+ }
+
+ func fetchChatSessions(articleID: Int) async throws -> [ChatSession] {
+ try await get(
+ "ai/chat/sessions",
+ queryItems: [URLQueryItem(name: "article_id", value: String(articleID))]
+ )
+ }
+
+ func createChatSession(articleID: Int, title: String) async throws -> ChatSession {
+ try await postDecoding(
+ "ai/chat/session/create",
+ jsonBody: ["article_id": articleID, "title": title]
+ )
+ }
+
+ func fetchChatMessages(sessionID: Int) async throws -> [ChatMessage] {
+ try await get(
+ "ai/chat/messages",
+ queryItems: [URLQueryItem(name: "session_id", value: String(sessionID))]
+ )
+ }
+
+ func deleteChatSession(id: Int) async throws {
+ try await send(
+ "ai/chat/session",
+ method: "DELETE",
+ queryItems: [URLQueryItem(name: "session_id", value: String(id))]
+ )
+ }
+
+ /// Deletes every stored conversation. The backend clears all sessions at
+ /// once rather than only those for one article.
+ func deleteAllChatSessions() async throws {
+ try await send("ai/chat/sessions/delete-all", method: "DELETE")
+ }
+}
diff --git a/frontend/Sources/Services/API/APIService+Articles.swift b/frontend/Sources/Services/API/APIService+Articles.swift
new file mode 100644
index 000000000..ca87cdf63
--- /dev/null
+++ b/frontend/Sources/Services/API/APIService+Articles.swift
@@ -0,0 +1,250 @@
+import Foundation
+
+extension APIService {
+ // MARK: - Listing
+
+ 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 fetchImageArticles(page: Int, limit: Int) async throws -> [Article] {
+ try await get(
+ "articles/images",
+ queryItems: [
+ URLQueryItem(name: "page", value: String(page)),
+ URLQueryItem(name: "limit", value: String(limit))
+ ]
+ )
+ }
+
+ func filterArticles(
+ conditions: [FilterCondition],
+ page: Int,
+ limit: Int
+ ) async throws -> FilteredArticles {
+ struct Request: Encodable {
+ let conditions: [FilterCondition]
+ let page: Int
+ let limit: Int
+ }
+ return try await postJSON(
+ "articles/filter",
+ body: Request(conditions: conditions, page: page, limit: limit)
+ )
+ }
+
+ // MARK: - State changes
+
+ 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 toggleReadLater(id: Int) async throws {
+ try await post("articles/toggle-read-later", queryItems: [URLQueryItem(name: "id", value: String(id))])
+ }
+
+ func toggleHidden(id: Int) async throws {
+ try await post("articles/toggle-hide", queryItems: [URLQueryItem(name: "id", value: String(id))])
+ }
+
+ /// Marks every article published before or after the given one as read, and
+ /// returns how many were changed. The feed or category scopes the change to
+ /// what the reader is currently looking at.
+ func markRelative(
+ id: Int,
+ direction: String,
+ feedID: Int?,
+ category: String?
+ ) async throws -> Int {
+ struct Response: Decodable {
+ let count: Int?
+ let marked: Int?
+ let affected: Int?
+ }
+
+ var queryItems = [
+ URLQueryItem(name: "id", value: String(id)),
+ URLQueryItem(name: "direction", value: direction)
+ ]
+ if let feedID {
+ queryItems.append(URLQueryItem(name: "feed_id", value: String(feedID)))
+ } else if let category, !category.isEmpty {
+ queryItems.append(URLQueryItem(name: "category", value: category))
+ }
+
+ let response: Response = try await postDecoding(
+ "articles/mark-relative",
+ queryItems: queryItems
+ )
+ return response.count ?? response.marked ?? response.affected ?? 0
+ }
+
+ func markAllRead(feedID: Int?, category: String?) async throws {
+ var queryItems: [URLQueryItem] = []
+ if let feedID {
+ queryItems.append(URLQueryItem(name: "feed_id", value: String(feedID)))
+ }
+ if let category, !category.isEmpty {
+ queryItems.append(URLQueryItem(name: "category", value: category))
+ }
+ try await post("articles/mark-all-read", queryItems: queryItems)
+ }
+
+ func clearReadLater() async throws {
+ try await post("articles/clear-read-later")
+ }
+
+ // MARK: - Content
+
+ func fetchArticleContent(id: Int) async throws -> ArticleContent {
+ try await get("articles/content", queryItems: [URLQueryItem(name: "id", value: String(id))])
+ }
+
+ func reloadArticleContent(id: Int) async throws -> ArticleContent {
+ try await postDecoding(
+ "articles/reload-content",
+ queryItems: [URLQueryItem(name: "id", value: String(id))]
+ )
+ }
+
+ func fetchFullArticle(id: Int) async throws -> ArticleContent {
+ try await postDecoding(
+ "articles/fetch-full",
+ queryItems: [URLQueryItem(name: "id", value: String(id))]
+ )
+ }
+
+ /// Unlike the other article actions, this one is a plain read.
+ func extractImages(id: Int) async throws -> ArticleImages {
+ try await get(
+ "articles/extract-images",
+ queryItems: [URLQueryItem(name: "id", value: String(id))]
+ )
+ }
+
+ // MARK: - Counts
+
+ func fetchUnreadCounts() async throws -> UnreadCounts {
+ try await get("articles/unread-counts")
+ }
+
+ func fetchFilterCounts() async throws -> FilterCounts {
+ try await get("articles/filter-counts")
+ }
+
+ // MARK: - Export
+
+ func exportArticle(id: Int, destination: ArticleExportDestination) async throws -> String {
+ struct Response: Decodable {
+ let success: Bool?
+ let message: String?
+ let error: String?
+ let path: String?
+ }
+ let response: Response = try await postDecoding(
+ destination.endpoint,
+ jsonBody: ["article_id": id]
+ )
+ if let error = response.error, !error.isEmpty {
+ throw APIError.server(statusCode: 200, message: error)
+ }
+ return response.message ?? response.path ?? destination.localizedSuccess
+ }
+
+ // MARK: - Translation and summaries
+
+ 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")
+ }
+}
diff --git a/frontend/Sources/Services/API/APIService+Feeds.swift b/frontend/Sources/Services/API/APIService+Feeds.swift
new file mode 100644
index 000000000..be0a929bc
--- /dev/null
+++ b/frontend/Sources/Services/API/APIService+Feeds.swift
@@ -0,0 +1,112 @@
+import Foundation
+
+extension APIService {
+ // MARK: - Feeds
+
+ func fetchFeeds() async throws -> [Feed] {
+ try await get("feeds")
+ }
+
+ func addFeed(_ draft: FeedDraft) async throws {
+ try await post("feeds/add", jsonBody: draft.jsonBody)
+ }
+
+ func updateFeed(_ draft: FeedDraft) async throws {
+ try await post("feeds/update", jsonBody: draft.jsonBody)
+ }
+
+ func deleteFeed(id: Int) async throws {
+ try await post("feeds/delete", queryItems: [URLQueryItem(name: "id", value: String(id))])
+ }
+
+ func updateFeedCategory(id: Int, category: String) async throws {
+ try await post("feeds/category", jsonBody: ["id": id, "category": category])
+ }
+
+ 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")
+ }
+
+ func refreshFeed(id: Int) async throws {
+ try await post("feeds/refresh", queryItems: [URLQueryItem(name: "id", value: String(id))])
+ }
+
+ func fetchRefreshProgress() async throws -> RefreshProgress {
+ try await get("progress")
+ }
+
+ func testIMAPConnection(_ draft: FeedDraft) async throws -> String {
+ struct Response: Decodable {
+ let success: Bool?
+ let message: String?
+ let error: String?
+ }
+
+ let body: [String: Any] = [
+ "email_imap_server": draft.emailIMAPServer,
+ "email_imap_port": draft.emailIMAPPort,
+ "email_username": draft.emailUsername,
+ "email_password": draft.emailPassword,
+ "email_folder": draft.emailFolder
+ ]
+ let response: Response = try await postDecoding("feeds/test-imap", jsonBody: body)
+ if let error = response.error, !error.isEmpty {
+ throw APIError.server(statusCode: 200, message: error)
+ }
+ return response.message ?? t("common.connectionSuccessful")
+ }
+
+ // MARK: - Discovery
+
+ func startDiscovery(feedID: Int) async throws {
+ try await post("feeds/discover/start", jsonBody: ["feed_id": feedID])
+ }
+
+ func fetchDiscoveryProgress() async throws -> DiscoveryState {
+ try await get("feeds/discover/progress")
+ }
+
+ func clearDiscovery() async throws {
+ try await post("feeds/discover/clear")
+ }
+
+ func startDiscoverAll() async throws {
+ try await post("feeds/discover-all/start")
+ }
+
+ func fetchDiscoverAllProgress() async throws -> DiscoveryState {
+ try await get("feeds/discover-all/progress")
+ }
+
+ func clearDiscoverAll() async throws {
+ try await post("feeds/discover-all/clear")
+ }
+
+ // MARK: - RSSHub
+
+ func testRSSHubConnection() async throws -> String {
+ struct Response: Decodable {
+ let success: Bool?
+ let message: String?
+ let error: String?
+ }
+ let response: Response = try await postDecoding("rsshub/test-connection")
+ if let error = response.error, !error.isEmpty {
+ throw APIError.server(statusCode: 200, message: error)
+ }
+ return response.message ?? t("setting.rsshub.connectionSuccessful")
+ }
+
+ func transformRSSHubURL(_ url: String) async throws -> String {
+ struct Response: Decodable {
+ let url: String?
+ let transformed_url: String?
+ }
+ let response: Response = try await postDecoding("rsshub/transform-url", jsonBody: ["url": url])
+ return response.transformed_url ?? response.url ?? url
+ }
+}
diff --git a/frontend/Sources/Services/API/APIService+Organization.swift b/frontend/Sources/Services/API/APIService+Organization.swift
new file mode 100644
index 000000000..b5f168a00
--- /dev/null
+++ b/frontend/Sources/Services/API/APIService+Organization.swift
@@ -0,0 +1,101 @@
+import Foundation
+
+extension APIService {
+ // MARK: - Tags
+
+ func fetchTags() async throws -> [Tag] {
+ try await get("tags")
+ }
+
+ func createTag(name: String, color: String) async throws -> Tag {
+ try await postDecoding("tags", jsonBody: ["name": name, "color": color])
+ }
+
+ func updateTag(_ tag: Tag) async throws {
+ try await post(
+ "tags/update",
+ jsonBody: [
+ "id": tag.id,
+ "name": tag.name,
+ "color": tag.color,
+ "position": tag.position
+ ]
+ )
+ }
+
+ func deleteTag(id: Int) async throws {
+ try await post("tags/delete", jsonBody: ["id": id])
+ }
+
+ func reorderTag(id: Int, newPosition: Int) async throws {
+ try await post("tags/reorder", jsonBody: ["id": id, "new_position": newPosition])
+ }
+
+ // MARK: - Saved filters
+
+ func fetchSavedFilters() async throws -> [SavedFilter] {
+ try await get("saved-filters")
+ }
+
+ func createSavedFilter(name: String, conditions: [FilterCondition]) async throws -> SavedFilter {
+ let encoded = try JSONEncoder().encode(conditions)
+ return try await postDecoding(
+ "saved-filters",
+ jsonBody: [
+ "name": name,
+ "conditions": String(data: encoded, encoding: .utf8) ?? "[]"
+ ]
+ )
+ }
+
+ func updateSavedFilter(_ filter: SavedFilter) async throws {
+ let encoded = try JSONEncoder().encode(filter.conditions)
+ try await send(
+ "saved-filters/filter",
+ method: "PUT",
+ queryItems: [URLQueryItem(name: "id", value: String(filter.id))],
+ jsonBody: [
+ "id": filter.id,
+ "name": filter.name,
+ "conditions": String(data: encoded, encoding: .utf8) ?? "[]"
+ ]
+ )
+ }
+
+ func deleteSavedFilter(id: Int) async throws {
+ try await send(
+ "saved-filters/filter",
+ method: "DELETE",
+ queryItems: [URLQueryItem(name: "id", value: String(id))]
+ )
+ }
+
+ /// Sends the whole list in its new order, which is what the endpoint reads.
+ func reorderSavedFilters(_ filters: [SavedFilter]) async throws {
+ let ordered = filters.enumerated().map { index, filter in
+ SavedFilter(
+ id: filter.id,
+ name: filter.name,
+ conditions: filter.conditions,
+ position: index
+ )
+ }
+ try await sendJSONReturningData("saved-filters/reorder", body: ordered)
+ }
+
+ // MARK: - Rules
+
+ func applyRule(_ rule: AutomationRule) async throws -> RuleApplicationResult {
+ try await postJSON("rules/apply", body: rule)
+ }
+
+ // MARK: - Settings
+
+ func fetchSettings() async throws -> [String: String] {
+ try await get("settings")
+ }
+
+ func updateSettings(_ settings: [String: String]) async throws {
+ try await sendJSONReturningData("settings", body: settings)
+ }
+}
diff --git a/frontend/Sources/Services/API/APIService+System.swift b/frontend/Sources/Services/API/APIService+System.swift
new file mode 100644
index 000000000..f73163a20
--- /dev/null
+++ b/frontend/Sources/Services/API/APIService+System.swift
@@ -0,0 +1,123 @@
+import Foundation
+
+extension APIService {
+ // MARK: - Statistics
+
+ func fetchStatistics(period: String, offset: Int) async throws -> StatisticsSummary {
+ try await get(
+ "statistics",
+ queryItems: [
+ URLQueryItem(name: "period", value: period),
+ URLQueryItem(name: "offset", value: String(offset))
+ ]
+ )
+ }
+
+ func fetchAllTimeStatistics() async throws -> [String: Int] {
+ try await get("statistics/all-time")
+ }
+
+ // MARK: - Maintenance
+
+ func fetchContentCacheInfo() async throws -> ContentCacheInfo {
+ try await get("articles/content-cache-info")
+ }
+
+ func cleanupArticles() async throws {
+ try await post("articles/cleanup")
+ }
+
+ func cleanupContentCache() async throws {
+ try await post("articles/cleanup-content")
+ }
+
+ func fetchMediaCacheInfo() async throws -> MediaCacheInfo {
+ try await get("media/info")
+ }
+
+ func cleanupMediaCache() async throws {
+ try await post("media/cleanup")
+ }
+
+ // MARK: - Updates
+
+ func checkForUpdates() async throws -> UpdateInfo {
+ try await get("check-updates")
+ }
+
+ // MARK: - FreshRSS
+
+ func fetchFreshRSSStatus() async throws -> FreshRSSStatus {
+ try await get("freshrss/status")
+ }
+
+ func syncFreshRSS() async throws {
+ try await post("freshrss/sync")
+ }
+
+ func syncFreshRSSFeed(id: Int) async throws {
+ try await post(
+ "freshrss/sync-feed",
+ queryItems: [URLQueryItem(name: "feed_id", value: String(id))]
+ )
+ }
+
+ // MARK: - OPML
+
+ func exportOPML() async throws -> Data {
+ try await getData("opml/export")
+ }
+
+ func importOPML(data: Data, filename: String) async throws {
+ _ = try await upload(
+ "opml/import",
+ data: data,
+ contentType: filename.lowercased().hasSuffix(".json") ? "application/json" : "text/xml",
+ queryItems: [URLQueryItem(name: "filename", value: filename)]
+ )
+ }
+
+ // MARK: - Window state
+
+ func fetchWindowState() async throws -> WindowState {
+ try await get("window/state")
+ }
+
+ func saveWindowState(_ state: WindowState) async throws {
+ try await post("window/save", jsonBody: state.jsonBody)
+ }
+
+ // MARK: - Scripts and custom styling
+
+ func fetchScripts() async throws -> ScriptList {
+ try await get("scripts/list")
+ }
+
+ func uploadCustomCSS(data: Data, filename: String) async throws {
+ _ = try await upload(
+ "custom-css/upload",
+ data: data,
+ contentType: "text/css",
+ queryItems: [URLQueryItem(name: "filename", value: filename)]
+ )
+ }
+
+ func deleteCustomCSS() async throws {
+ try await post("custom-css/delete")
+ }
+
+ func fetchCustomCSS() async throws -> String {
+ let data = try await getData("custom-css")
+ return String(data: data, encoding: .utf8) ?? ""
+ }
+
+ // MARK: - Browser
+
+ /// Asks the backend to open a link. It answers with the URL to open, which
+ /// the client hands to the system browser.
+ func openInBrowser(url: String) async throws -> String? {
+ struct Response: Decodable { let redirect: String? }
+ let response: Response = try await postDecoding("browser/open", jsonBody: ["url": url])
+ return response.redirect
+ }
+}
diff --git a/frontend/Sources/Services/API/APIService.swift b/frontend/Sources/Services/API/APIService.swift
new file mode 100644
index 000000000..6339c8768
--- /dev/null
+++ b/frontend/Sources/Services/API/APIService.swift
@@ -0,0 +1,255 @@
+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)
+ case notStubbed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL:
+ return t("client.error.invalidServerAddress")
+ case .invalidResponse:
+ return t("client.error.invalidResponse")
+ case .server(let statusCode, let message):
+ return message.isEmpty
+ ? t("client.error.httpStatus", ["status": statusCode])
+ : "\(t("client.error.httpStatus", ["status": statusCode])): \(message)"
+ case .decoding(let message):
+ return "\(t("client.error.unreadableResponse")): \(message)"
+ case .notStubbed(let name):
+ return "The test stub does not implement \(name)."
+ }
+ }
+}
+
+/// Talks to the MrRSS HTTP API. Domain-specific calls live in the
+/// `APIService+âĶ` extensions next to this file.
+final class APIService: APIClient {
+ static let shared = APIService()
+
+ private(set) var baseURL: URL
+ 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)
+ }
+ }
+
+ // MARK: - Transport
+
+ 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))
+ return try decode(data)
+ }
+
+ func getData(_ endpoint: String, queryItems: [URLQueryItem] = []) async throws -> Data {
+ let url = try makeURL(endpoint: endpoint, queryItems: queryItems)
+ return try await data(for: URLRequest(url: url))
+ }
+
+ @discardableResult
+ func post(
+ _ endpoint: String,
+ queryItems: [URLQueryItem] = [],
+ jsonBody: [String: Any]? = nil
+ ) async throws -> Data {
+ 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)
+ }
+ return try await data(for: request)
+ }
+
+ func postDecoding(
+ _ endpoint: String,
+ queryItems: [URLQueryItem] = [],
+ jsonBody: [String: Any]? = nil
+ ) async throws -> T {
+ let data = try await post(endpoint, queryItems: queryItems, jsonBody: jsonBody)
+ return try decode(data)
+ }
+
+ func postJSON(
+ _ endpoint: String,
+ body: Body,
+ method: String = "POST"
+ ) async throws -> Response {
+ let responseData = try await sendJSONReturningData(endpoint, body: body, method: method)
+ return try decode(responseData)
+ }
+
+ @discardableResult
+ func sendJSONReturningData(
+ _ endpoint: String,
+ body: Body,
+ method: String = "POST"
+ ) async throws -> Data {
+ 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)
+ return try await data(for: request)
+ }
+
+ @discardableResult
+ func send(
+ _ endpoint: String,
+ method: String,
+ queryItems: [URLQueryItem] = [],
+ jsonBody: [String: Any]? = nil
+ ) async throws -> Data {
+ let url = try makeURL(endpoint: endpoint, queryItems: queryItems)
+ var request = URLRequest(url: url)
+ request.httpMethod = method
+ if let jsonBody {
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONSerialization.data(withJSONObject: jsonBody)
+ }
+ return try await data(for: request)
+ }
+
+ func upload(
+ _ endpoint: String,
+ data payload: Data,
+ contentType: String,
+ queryItems: [URLQueryItem] = []
+ ) async throws -> Data {
+ let url = try makeURL(endpoint: endpoint, queryItems: queryItems)
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue(contentType, forHTTPHeaderField: "Content-Type")
+ request.httpBody = payload
+ return try await data(for: request)
+ }
+
+ /// Decodes a response, treating a literal `null` body as an empty collection
+ /// because several endpoints return `null` instead of `[]`.
+ func decode(_ data: Data) throws -> T {
+ 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)
+ }
+ }
+
+ 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
+ }
+
+ @discardableResult
+ 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 = APIService.errorMessage(from: data)
+ throw APIError.server(statusCode: response.statusCode, message: message)
+ }
+ return data
+ }
+
+ /// Pulls the human-readable part out of an error body, which the backend
+ /// sends either as `{"error": "âĶ"}` or as plain text.
+ static func errorMessage(from data: Data) -> String {
+ if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ for key in ["error", "message", "detail"] {
+ if let value = object[key] as? String, !value.isEmpty {
+ return value
+ }
+ }
+ }
+ return String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ }
+
+ // MARK: - Connection
+
+ func checkConnection() async throws {
+ _ = try await getData("version")
+ }
+
+ func fetchVersion() async throws -> String {
+ struct Response: Decodable { let version: String }
+ let response: Response = try await get("version")
+ return response.version
+ }
+}
+
+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/Sources/Services/AppDelegate.swift b/frontend/Sources/Services/AppDelegate.swift
new file mode 100644
index 000000000..5080c5ef7
--- /dev/null
+++ b/frontend/Sources/Services/AppDelegate.swift
@@ -0,0 +1,111 @@
+import AppKit
+import Foundation
+
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ private var process: Process?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ AppDelegate.shortenToolTipDelay()
+ presentAsForegroundApplication()
+ startBundledBackendIfNeeded()
+ }
+
+ /// The system waits a long time before showing a tooltip, which is a poor
+ /// fit for a toolbar of icons where the tooltip is how a button explains
+ /// itself. Registered as a default rather than set outright, so anyone who
+ /// has chosen their own delay keeps it.
+ static func shortenToolTipDelay(in defaults: UserDefaults = .standard) {
+ defaults.register(defaults: [toolTipDelayKey: toolTipDelayMilliseconds])
+ }
+
+ /// Long enough not to flicker while the pointer crosses the toolbar, short
+ /// enough to answer a deliberate hover.
+ static let toolTipDelayMilliseconds = 300
+
+ /// AppKit reads the delay, in milliseconds, from this default.
+ static let toolTipDelayKey = "NSInitialToolTipDelay"
+
+ func applicationWillTerminate(_ notification: Notification) {
+ 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
+ }
+
+ /// Where the bundled backend keeps its database, logs and scripts.
+ ///
+ /// The backend resolves `data` against its working directory, so the
+ /// directory below is what it is started in.
+ static var supportDirectory: URL {
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent(supportDirectoryName, isDirectory: true)
+ }
+
+ static let supportDirectoryName = "MrRSS"
+
+ 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 = AppDelegate.supportDirectory
+
+ 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/Sources/Services/KeyboardShortcuts.swift b/frontend/Sources/Services/KeyboardShortcuts.swift
new file mode 100644
index 000000000..6f0f6ad22
--- /dev/null
+++ b/frontend/Sources/Services/KeyboardShortcuts.swift
@@ -0,0 +1,145 @@
+import AppKit
+import Foundation
+
+/// The actions a key can trigger, using the same bindings the previous
+/// interface shipped with.
+enum ShortcutAction: String, CaseIterable, Identifiable {
+ case nextArticle
+ case previousArticle
+ case toggleReadStatus
+ case toggleFavoriteStatus
+ case toggleReadLaterStatus
+ case openInBrowser
+ case toggleContentView
+ case refreshFeeds
+ case markAllRead
+ case addFeed
+ case toggleUnreadFilter
+ case toggleFavoritesFilter
+ case toggleReadLaterFilter
+ case goToAllArticles
+ case goToUnread
+ case goToFavorites
+ case goToReadLater
+
+ var id: String { rawValue }
+
+ /// The default binding, written the way the previous interface stored it.
+ var defaultBinding: String {
+ switch self {
+ case .nextArticle: "j"
+ case .previousArticle: "k"
+ case .toggleReadStatus: "r"
+ case .toggleFavoriteStatus: "s"
+ case .toggleReadLaterStatus: "l"
+ case .openInBrowser: "o"
+ case .toggleContentView: "v"
+ case .refreshFeeds: "Shift+r"
+ case .markAllRead: "Shift+a"
+ case .addFeed: "a"
+ case .toggleUnreadFilter: "Alt+r"
+ case .toggleFavoritesFilter: "Alt+s"
+ case .toggleReadLaterFilter: "Alt+l"
+ case .goToAllArticles: "1"
+ case .goToUnread: "2"
+ case .goToFavorites: "3"
+ case .goToReadLater: "4"
+ }
+ }
+
+ var localizedTitle: String {
+ switch self {
+ case .nextArticle: t("article.navigation.nextArticle")
+ case .previousArticle: t("article.navigation.previousArticle")
+ case .toggleReadStatus: t("shortcut.toggle.readStatus")
+ case .toggleFavoriteStatus: t("article.toolbar.addToFavorite")
+ case .toggleReadLaterStatus: t("shortcut.toggle.readLaterStatus")
+ case .openInBrowser: t("article.action.openInBrowserShortcut")
+ case .toggleContentView: t("shortcut.toggle.contentView")
+ case .refreshFeeds: t("article.action.refreshFeedsShortcut")
+ case .markAllRead: t("article.action.markAllReadShortcut")
+ case .addFeed: t("sidebar.activity.addFeed")
+ case .toggleUnreadFilter: t("shortcut.toggle.unreadFilter")
+ case .toggleFavoritesFilter: t("shortcut.toggle.favoritesFilter")
+ case .toggleReadLaterFilter: t("shortcut.toggle.readLaterFilter")
+ case .goToAllArticles: t("article.navigation.goToAllArticles")
+ case .goToUnread: t("article.navigation.goToUnread")
+ case .goToFavorites: t("article.navigation.goToFavorites")
+ case .goToReadLater: t("article.navigation.goToReadLater")
+ }
+ }
+}
+
+/// Turns key presses into actions. Bindings come from the settings the backend
+/// stores, falling back to the defaults above.
+struct KeyboardShortcutTable {
+ private var bindings: [String: ShortcutAction]
+
+ /// Whether key handling is switched on at all.
+ let isEnabled: Bool
+
+ init(settings: [String: String] = [:]) {
+ // The backend keeps every binding in one JSON object under `shortcuts`,
+ // keyed by the same action names the previous interface used.
+ var stored: [String: String] = [:]
+ if let raw = settings["shortcuts"], let data = raw.data(using: .utf8),
+ let decoded = try? JSONDecoder().decode([String: String].self, from: data) {
+ stored = decoded
+ }
+
+ var table: [String: ShortcutAction] = [:]
+ for action in ShortcutAction.allCases {
+ let value = stored[action.rawValue]
+ let binding = (value?.isEmpty == false ? value! : action.defaultBinding)
+ table[KeyboardShortcutTable.normalize(binding)] = action
+ }
+ bindings = table
+
+ let enabled = settings["shortcuts_enabled"]
+ isEnabled = enabled == nil || enabled == "true" || enabled == "1"
+ }
+
+ func action(for event: NSEvent) -> ShortcutAction? {
+ bindings[KeyboardShortcutTable.combination(for: event)]
+ }
+
+ func action(forBinding binding: String) -> ShortcutAction? {
+ bindings[KeyboardShortcutTable.normalize(binding)]
+ }
+
+ /// Builds the same textual form the previous interface used, so stored
+ /// bindings keep working.
+ static func combination(for event: NSEvent) -> String {
+ var parts = ""
+ if event.modifierFlags.contains(.control) { parts += "Ctrl+" }
+ if event.modifierFlags.contains(.option) { parts += "Alt+" }
+ if event.modifierFlags.contains(.shift) { parts += "Shift+" }
+ if event.modifierFlags.contains(.command) { parts += "Meta+" }
+
+ var key = event.charactersIgnoringModifiers ?? ""
+ switch event.keyCode {
+ case 123: key = "ArrowLeft"
+ case 124: key = "ArrowRight"
+ case 125: key = "ArrowDown"
+ case 126: key = "ArrowUp"
+ case 36: key = "Enter"
+ case 53: key = "Escape"
+ case 49: key = "Space"
+ default:
+ if key.count == 1 { key = key.lowercased() }
+ }
+
+ return normalize(parts + key)
+ }
+
+ private static func normalize(_ binding: String) -> String {
+ var components = binding.split(separator: "+").map(String.init)
+ guard let key = components.popLast() else { return binding }
+ let order = ["Ctrl", "Alt", "Shift", "Meta"]
+ let modifiers = order.filter { modifier in
+ components.contains { $0.caseInsensitiveCompare(modifier) == .orderedSame }
+ }
+ let normalizedKey = key.count == 1 ? key.lowercased() : key
+ return (modifiers + [normalizedKey]).joined(separator: "+")
+ }
+}
diff --git a/frontend/Sources/ViewModels/AppViewModel+Articles.swift b/frontend/Sources/ViewModels/AppViewModel+Articles.swift
new file mode 100644
index 000000000..9b56864a6
--- /dev/null
+++ b/frontend/Sources/ViewModels/AppViewModel+Articles.swift
@@ -0,0 +1,330 @@
+import AppKit
+import Foundation
+
+extension AppViewModel {
+ // MARK: - Presentation
+
+ /// The list as it should be shown, after the chosen ordering is applied.
+ var displayedArticles: [Article] {
+ switch sortOrder {
+ case .newestFirst:
+ return articles.sorted { lhs, rhs in
+ (lhs.publishedDate ?? .distantPast) > (rhs.publishedDate ?? .distantPast)
+ }
+ case .oldestFirst:
+ return articles.sorted { lhs, rhs in
+ (lhs.publishedDate ?? .distantPast) < (rhs.publishedDate ?? .distantPast)
+ }
+ case .unreadFirst:
+ return articles.sorted { lhs, rhs in
+ if lhs.isRead != rhs.isRead { return !lhs.isRead }
+ return (lhs.publishedDate ?? .distantPast) > (rhs.publishedDate ?? .distantPast)
+ }
+ case .byTitle:
+ return articles.sorted { lhs, rhs in
+ lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending
+ }
+ }
+ }
+
+ /// The heading shown above the article list.
+ var articleListTitle: String {
+ switch selection {
+ case .filter(let filter):
+ return filter.title
+ case .folder(let name):
+ return name
+ case .feed(let id):
+ return feeds.first(where: { $0.id == id })?.title ?? t("sidebar.feedList.articles")
+ case .savedFilter(let id):
+ return savedFilters.first(where: { $0.id == id })?.name ?? t("modal.filter.filter")
+ case .none:
+ return t("sidebar.feedList.articles")
+ }
+ }
+
+ /// The feed a given article came from, used for the list subtitle and for
+ /// per-feed reading preferences.
+ func feed(for article: Article) -> Feed? {
+ feeds.first(where: { $0.id == article.feedID })
+ }
+
+ /// The badge to show next to one feed, honouring the current activity.
+ func badgeCount(for feedID: Int, activity: ArticleFilter) -> Int {
+ let keyPath = showOnlyUnread ? activity.unreadCountsKeyPath : activity.countsKeyPath
+ guard let keyPath else { return unreadCounts.feedCounts[feedID] ?? 0 }
+ return filterCounts[keyPath: keyPath][feedID] ?? 0
+ }
+
+ /// The total for one activity across every feed.
+ func totalCount(for activity: ArticleFilter) -> Int {
+ switch activity {
+ case .all: return unreadCounts.total
+ case .unread: return unreadCounts.total
+ case .favorites: return filterCounts.total(for: \.favorites)
+ case .readLater: return filterCounts.total(for: \.readLater)
+ case .imageGallery: return filterCounts.total(for: \.images)
+ }
+ }
+
+ /// The tags assigned to one feed.
+ func tags(forFeed feedID: Int) -> [Tag] {
+ let ids = Set(feedTags[feedID] ?? [])
+ return tags.filter { ids.contains($0.id) }
+ }
+
+ // MARK: - Article actions
+
+ func toggleReadLater(_ article: Article) {
+ mutateArticle(article.id, apply: { $0.isReadLater.toggle() }) { [weak self] in
+ try await self?.api.toggleReadLater(id: article.id)
+ }
+ }
+
+ func toggleHidden(_ article: Article) {
+ let willHide = !article.isHidden
+ mutateArticle(article.id, apply: { $0.isHidden.toggle() }) { [weak self] in
+ try await self?.api.toggleHidden(id: article.id)
+ // A hidden article leaves the list unless hidden articles are shown.
+ // Removing it only after the server agrees keeps the rollback in
+ // `mutateArticle` able to find the row.
+ guard let self, willHide, !boolSetting("show_hidden_articles") else { return }
+ articles.removeAll { $0.id == article.id }
+ if selectedArticleID == article.id {
+ selectedArticleID = nil
+ }
+ }
+ }
+
+ /// Marks everything in one feed or one folder as read.
+ func markRead(feedID: Int? = nil, category: String? = nil) async {
+ do {
+ try await api.markAllRead(feedID: feedID, category: category)
+ statusMessage = t("article.action.markedAllAsRead")
+ if let feedID {
+ for index in articles.indices where articles[index].feedID == feedID {
+ articles[index].isRead = true
+ }
+ } else if category != nil {
+ reloadArticles()
+ }
+ await refreshCounts()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ /// Marks everything in the current activity as read.
+ func markAllRead() async {
+ let query = articleQuery
+ do {
+ if query.usesConditions || query.usesImageGallery {
+ // These listings have no server-side bulk action, so the visible
+ // articles are marked one by one.
+ let unread = articles.filter { !$0.isRead }
+ guard !unread.isEmpty else {
+ statusMessage = t("article.action.noArticlesToMark")
+ return
+ }
+ for article in unread {
+ try await api.setArticleRead(id: article.id, read: true)
+ }
+ statusMessage = t("article.action.markedNArticlesAsRead", ["count": unread.count])
+ } else {
+ try await api.markAllRead(feedID: query.feedID, category: query.category)
+ statusMessage = t("article.action.markedAllAsRead")
+ }
+ for index in articles.indices {
+ articles[index].isRead = true
+ }
+ await refreshCounts()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ /// Marks everything published before or after one article as read, scoped to
+ /// the feed or folder currently being read.
+ func markRelative(to article: Article, direction: MarkDirection) async {
+ let query = articleQuery
+ do {
+ let count = try await api.markRelative(
+ id: article.id,
+ direction: direction.rawValue,
+ feedID: query.feedID,
+ category: query.category
+ )
+ statusMessage = t("article.action.markedNArticlesAsRead", ["count": count])
+ applyRelativeReadState(from: article, direction: direction)
+ await refreshCounts()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func clearReadLater() async {
+ do {
+ try await api.clearReadLater()
+ statusMessage = t("common.toast.clearedReadLater")
+ reloadArticles()
+ await refreshCounts()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func exportArticle(_ article: Article, to destination: ArticleExportDestination) async {
+ statusMessage = destination.localizedProgress
+ do {
+ statusMessage = try await api.exportArticle(id: article.id, destination: destination)
+ } catch {
+ errorMessage = "\(destination.localizedFailure): \(error.localizedDescription)"
+ }
+ }
+
+ /// Opens the article in the system browser. The backend is asked first so
+ /// its own link handling still applies.
+ func openInBrowser(_ article: Article) {
+ guard !article.url.isEmpty else { return }
+ Task { [weak self] in
+ guard let self else { return }
+ // The backend answers with the address to open, and falls back to
+ // the article's own when it has nothing to add.
+ let redirect = try? await api.openInBrowser(url: article.url)
+ let target = redirect.flatMap { $0 } ?? article.url
+ guard let url = URL(string: target) else { return }
+ NSWorkspace.shared.open(url)
+ }
+ }
+
+ // MARK: - Article content
+
+ func reloadArticleContent(id: Int) async throws -> ArticleContent {
+ try await api.reloadArticleContent(id: id)
+ }
+
+ func fetchFullArticle(id: Int) async throws -> ArticleContent {
+ try await api.fetchFullArticle(id: id)
+ }
+
+ /// The images the backend can pull out of one article.
+ func articleImages(id: Int) async -> [String] {
+ ((try? await api.extractImages(id: id))?.images ?? []).filter { !$0.isEmpty }
+ }
+
+ // MARK: - AI search
+
+ /// Runs the AI-assisted search and shows the hits in place of the list.
+ func runAISearch(_ query: String) async {
+ let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ clearAISearch()
+ return
+ }
+
+ isSearching = true
+ defer { isSearching = false }
+
+ do {
+ let response = try await api.aiSearch(query: trimmed)
+ if !response.success, let error = response.error {
+ errorMessage = error
+ return
+ }
+ searchHits = response.articles
+ articles = response.articles.map(\.article)
+ searchTerms = response.searchTerms
+ statusMessage = t("aiSearch.foundResults", ["count": response.totalCount])
+ } catch {
+ errorMessage = "\(t("aiSearch.searchFailed")) \(error.localizedDescription)"
+ }
+ }
+
+ /// Puts the ordinary list back.
+ func clearAISearch() {
+ guard !searchHits.isEmpty || searchTerms != nil else { return }
+ searchHits = []
+ searchTerms = nil
+ reloadArticles()
+ }
+
+ /// Why one article matched the search, when it came from one.
+ func searchHit(for articleID: Int) -> AISearchHit? {
+ searchHits.first { $0.id == articleID }
+ }
+
+ // MARK: - Loading side data
+
+ func refreshCounts() async {
+ async let unread = try? await api.fetchUnreadCounts()
+ async let filters = try? await api.fetchFilterCounts()
+ let (loadedUnread, loadedFilters) = await (unread, filters)
+ if let loadedUnread { unreadCounts = loadedUnread }
+ if let loadedFilters { filterCounts = loadedFilters }
+ }
+
+ func loadSavedFilters() async {
+ savedFilters = (try? await api.fetchSavedFilters())?
+ .sorted { $0.position < $1.position } ?? []
+ }
+
+ func loadTags() async {
+ tags = (try? await api.fetchTags())?.sorted { $0.position < $1.position } ?? []
+ refreshFeedTagAssignments()
+ }
+
+ /// The feed listing already carries each feed's tags, so the assignments are
+ /// read from what was loaded rather than asked for one feed at a time.
+ func refreshFeedTagAssignments() {
+ feedTags = Dictionary(
+ uniqueKeysWithValues: feeds
+ .filter { !$0.tags.isEmpty }
+ .map { ($0.id, $0.tags.map(\.id)) }
+ )
+ }
+
+ // MARK: - Helpers
+
+ /// Applies a change straight away and rolls it back if the server rejects it.
+ private func mutateArticle(
+ _ id: Int,
+ apply change: (inout Article) -> Void,
+ request: @escaping () async throws -> Void
+ ) {
+ guard let index = articles.firstIndex(where: { $0.id == id }) else { return }
+ let previous = articles[index]
+ change(&articles[index])
+
+ Task { [weak self] in
+ guard let self else { return }
+ do {
+ try await request()
+ } catch {
+ if let currentIndex = articles.firstIndex(where: { $0.id == id }) {
+ articles[currentIndex] = previous
+ }
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+
+ /// Mirrors what the server just did. It works on publication time rather
+ /// than on the order the list happens to be in, so the two agree however
+ /// the reader has sorted the list.
+ private func applyRelativeReadState(from article: Article, direction: MarkDirection) {
+ guard let pivot = article.publishedDate else { return }
+ for index in articles.indices {
+ guard let published = articles[index].publishedDate else { continue }
+ let isAffected = direction == .above ? published > pivot : published < pivot
+ if isAffected {
+ articles[index].isRead = true
+ }
+ }
+ }
+}
+
+/// Which side of an article a bulk "mark as read" applies to.
+enum MarkDirection: String {
+ case above
+ case below
+}
diff --git a/frontend/Sources/ViewModels/AppViewModel+Feeds.swift b/frontend/Sources/ViewModels/AppViewModel+Feeds.swift
new file mode 100644
index 000000000..53c874e8c
--- /dev/null
+++ b/frontend/Sources/ViewModels/AppViewModel+Feeds.swift
@@ -0,0 +1,156 @@
+import Foundation
+
+extension AppViewModel {
+ /// Adds or updates a subscription and reloads what changed.
+ ///
+ /// Pass `reloading: false` when saving several feeds in a row, and reload
+ /// once afterwards, so a bulk subscription does not refetch per feed.
+ @discardableResult
+ func saveFeed(_ draft: FeedDraft, isEditing: Bool, reloading: Bool = true) async -> Bool {
+ do {
+ if isEditing {
+ try await api.updateFeed(draft)
+ statusMessage = t("modal.feed.feedUpdatedSuccess")
+ } else {
+ try await api.addFeed(draft)
+ statusMessage = t("modal.feed.feedAddedSuccess")
+ }
+ if reloading {
+ await reloadAfterFeedChange()
+ }
+ return true
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ /// Reloads everything a change to the subscriptions affects.
+ func reloadAfterFeedChange() async {
+ refreshFeeds()
+ reloadArticles()
+ await loadTags()
+ }
+
+ func refreshFeed(_ feed: Feed) async {
+ do {
+ try await api.refreshFeed(id: feed.id)
+ statusMessage = t("modal.feed.feedRefreshStarted")
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func testIMAPConnection(_ draft: FeedDraft) async throws -> String {
+ try await api.testIMAPConnection(draft)
+ }
+
+ // MARK: - Tags
+
+ @discardableResult
+ func createTag(name: String, color: String) async -> Bool {
+ do {
+ _ = try await api.createTag(name: name, color: color)
+ statusMessage = t("modal.tag.tagCreated")
+ await loadTags()
+ return true
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ @discardableResult
+ func updateTag(_ tag: Tag) async -> Bool {
+ do {
+ try await api.updateTag(tag)
+ await loadTags()
+ return true
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ func deleteTag(_ tag: Tag) async {
+ do {
+ try await api.deleteTag(id: tag.id)
+ await loadTags()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ /// The feeds carrying one tag.
+ func feeds(taggedWith tagID: Int) -> [Feed] {
+ feeds.filter { feedTags[$0.id]?.contains(tagID) == true }
+ }
+
+ // MARK: - Saved filters
+
+ @discardableResult
+ func createSavedFilter(name: String, conditions: [FilterCondition]) async -> Bool {
+ do {
+ _ = try await api.createSavedFilter(name: name, conditions: conditions)
+ statusMessage = t("sidebar.savedFilters.filterSaved")
+ await loadSavedFilters()
+ return true
+ } catch {
+ errorMessage = "\(t("sidebar.savedFilters.saveFailed")): \(error.localizedDescription)"
+ return false
+ }
+ }
+
+ @discardableResult
+ func updateSavedFilter(_ filter: SavedFilter) async -> Bool {
+ do {
+ try await api.updateSavedFilter(filter)
+ statusMessage = t("sidebar.savedFilters.filterUpdated")
+ await loadSavedFilters()
+ if selection == .savedFilter(filter.id) {
+ reloadArticles()
+ }
+ return true
+ } catch {
+ errorMessage = "\(t("sidebar.savedFilters.updateFailed")): \(error.localizedDescription)"
+ return false
+ }
+ }
+
+ func deleteSavedFilter(_ filter: SavedFilter) async {
+ do {
+ try await api.deleteSavedFilter(id: filter.id)
+ statusMessage = t("sidebar.savedFilters.filterDeleted")
+ if selection == .savedFilter(filter.id) {
+ selection = .filter(.all)
+ }
+ await loadSavedFilters()
+ } catch {
+ errorMessage = "\(t("sidebar.savedFilters.deleteFailed")): \(error.localizedDescription)"
+ }
+ }
+
+ // MARK: - OPML
+
+ func exportOPML(to url: URL) async {
+ do {
+ let data = try await api.exportOPML()
+ try data.write(to: url)
+ statusMessage = t("modal.opml.exportSuccess")
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func importOPML(from url: URL) async {
+ do {
+ let data = try Data(contentsOf: url)
+ try await api.importOPML(data: data, filename: url.lastPathComponent)
+ statusMessage = t("client.opml.importSuccess")
+ refreshFeeds()
+ reloadArticles()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/frontend/Sources/ViewModels/AppViewModel+Shortcuts.swift b/frontend/Sources/ViewModels/AppViewModel+Shortcuts.swift
new file mode 100644
index 000000000..7b7cb3723
--- /dev/null
+++ b/frontend/Sources/ViewModels/AppViewModel+Shortcuts.swift
@@ -0,0 +1,104 @@
+import AppKit
+import Foundation
+
+extension AppViewModel {
+ /// The bindings currently in force, taken from the stored settings.
+ var shortcutTable: KeyboardShortcutTable {
+ KeyboardShortcutTable(settings: settings)
+ }
+
+ /// Runs whatever the key press means. Returns false when nothing matched so
+ /// the event can continue on its way.
+ @discardableResult
+ func handleKeyPress(_ event: NSEvent) -> Bool {
+ let table = shortcutTable
+ guard table.isEnabled, let action = table.action(for: event) else { return false }
+ perform(action)
+ return true
+ }
+
+ func perform(_ action: ShortcutAction) {
+ switch action {
+ case .nextArticle:
+ selectRelativeArticle(offset: 1)
+ case .previousArticle:
+ selectRelativeArticle(offset: -1)
+ case .toggleReadStatus:
+ guard let article = currentArticle else { return }
+ setArticleRead(article, read: !article.isRead)
+ case .toggleFavoriteStatus:
+ guard let article = currentArticle else { return }
+ toggleFavorite(article)
+ case .toggleReadLaterStatus:
+ guard let article = currentArticle else { return }
+ toggleReadLater(article)
+ case .openInBrowser:
+ guard let article = currentArticle else { return }
+ openInBrowser(article)
+ case .toggleContentView:
+ requestedViewModeToggle += 1
+ case .refreshFeeds:
+ refreshFromSources()
+ case .markAllRead:
+ Task { await markAllRead() }
+ case .addFeed:
+ isPresentingAddFeed = true
+ case .toggleUnreadFilter:
+ showOnlyUnread.toggle()
+ case .toggleFavoritesFilter:
+ selection = selection == .filter(.favorites) ? .filter(.all) : .filter(.favorites)
+ case .toggleReadLaterFilter:
+ selection = selection == .filter(.readLater) ? .filter(.all) : .filter(.readLater)
+ case .goToAllArticles:
+ selection = .filter(.all)
+ case .goToUnread:
+ selection = .filter(.unread)
+ case .goToFavorites:
+ selection = .filter(.favorites)
+ case .goToReadLater:
+ selection = .filter(.readLater)
+ }
+ }
+
+ /// True when there is an article before the current one.
+ var hasPreviousArticle: Bool {
+ guard let current = selectedArticleID,
+ let index = displayedArticles.firstIndex(where: { $0.id == current }) else {
+ return false
+ }
+ return index > 0
+ }
+
+ /// True when there is an article after the current one, or more to load.
+ var hasNextArticle: Bool {
+ guard let current = selectedArticleID,
+ let index = displayedArticles.firstIndex(where: { $0.id == current }) else {
+ return !displayedArticles.isEmpty
+ }
+ return index + 1 < displayedArticles.count || hasMoreArticles
+ }
+
+ /// The article the reading pane is showing.
+ var currentArticle: Article? {
+ article(withID: selectedArticleID)
+ }
+
+ /// Moves the selection through the list as it is currently ordered.
+ func selectRelativeArticle(offset: Int) {
+ let ordered = displayedArticles
+ guard !ordered.isEmpty else { return }
+
+ guard let current = selectedArticleID,
+ let index = ordered.firstIndex(where: { $0.id == current }) else {
+ selectArticle(ordered[0])
+ return
+ }
+
+ let target = index + offset
+ guard ordered.indices.contains(target) else {
+ if target >= ordered.count { loadMore() }
+ return
+ }
+ selectArticle(ordered[target])
+ }
+}
diff --git a/frontend/Sources/ViewModels/AppViewModel.swift b/frontend/Sources/ViewModels/AppViewModel.swift
new file mode 100644
index 000000000..a9a76adf9
--- /dev/null
+++ b/frontend/Sources/ViewModels/AppViewModel.swift
@@ -0,0 +1,1030 @@
+import Foundation
+import SwiftUI
+
+enum ArticleFilter: String, CaseIterable, Identifiable {
+ case all
+ case unread
+ case favorites
+ case readLater
+ case imageGallery
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .all: t("sidebar.activity.allArticles")
+ case .unread: t("sidebar.activity.unreadArticles")
+ case .favorites: t("sidebar.activity.favorites")
+ case .readLater: t("sidebar.activity.readLater")
+ case .imageGallery: t("sidebar.activity.imageGallery")
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .all: "rectangle.stack"
+ case .unread: "circle.fill"
+ case .favorites: "star.fill"
+ case .readLater: "clock.fill"
+ case .imageGallery: "photo.on.rectangle.angled"
+ }
+ }
+
+ /// The value `/api/articles` expects for this activity.
+ var queryValue: String {
+ switch self {
+ case .all: "all"
+ case .unread: "unread"
+ case .favorites: "favorites"
+ case .readLater: "readLater"
+ case .imageGallery: "all"
+ }
+ }
+
+ /// Which per-feed count in `/api/articles/filter-counts` belongs to this activity.
+ var countsKeyPath: KeyPath? {
+ switch self {
+ case .all: nil
+ case .unread: \FilterCounts.unread
+ case .favorites: \FilterCounts.favorites
+ case .readLater: \FilterCounts.readLater
+ case .imageGallery: \FilterCounts.images
+ }
+ }
+
+ /// The count to show when only unread items are being counted.
+ var unreadCountsKeyPath: KeyPath? {
+ switch self {
+ case .all, .unread: \FilterCounts.unread
+ case .favorites: \FilterCounts.favoritesUnread
+ case .readLater: \FilterCounts.readLaterUnread
+ case .imageGallery: \FilterCounts.imagesUnread
+ }
+ }
+}
+
+/// How the article list is ordered.
+enum ArticleSortOrder: String, CaseIterable, Identifiable {
+ case newestFirst
+ case oldestFirst
+ case unreadFirst
+ case byTitle
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .newestFirst: t("sidebar.sort.latest")
+ case .oldestFirst: t("client.sort.oldestFirst")
+ case .unreadFirst: t("client.sort.unreadFirst")
+ case .byTitle: t("sidebar.sort.byName")
+ }
+ }
+}
+
+/// How each article is presented in the list.
+enum ArticleListLayout: String, CaseIterable, Identifiable {
+ case compact
+ case comfortable
+ case cards
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .compact: t("client.layout.compact")
+ case .comfortable: t("client.layout.comfortable")
+ case .cards: t("client.layout.cards")
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .compact: "list.bullet"
+ case .comfortable: "list.dash"
+ case .cards: "square.grid.2x2"
+ }
+ }
+}
+
+enum SidebarItem: Hashable {
+ case filter(ArticleFilter)
+ case folder(String)
+ case feed(Int)
+ case savedFilter(Int)
+}
+
+enum ConnectionState: Equatable {
+ case connecting
+ case connected
+ case disconnected
+
+ var title: String {
+ switch self {
+ case .connecting: t("client.connection.connecting")
+ case .connected: t("client.connection.connected")
+ case .disconnected: t("client.connection.offline")
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .connecting: .orange
+ case .connected: .green
+ case .disconnected: .red
+ }
+ }
+}
+
+/// What the article list should ask the backend for.
+struct ArticleQuery: Equatable {
+ var filter: ArticleFilter?
+ var feedID: Int?
+ var category: String?
+ var conditions: [FilterCondition]
+
+ init(
+ filter: ArticleFilter? = nil,
+ feedID: Int? = nil,
+ category: String? = nil,
+ conditions: [FilterCondition] = []
+ ) {
+ self.filter = filter
+ self.feedID = feedID
+ self.category = category
+ self.conditions = conditions
+ }
+
+ /// True when the request goes to the saved-filter endpoint instead of the
+ /// plain listing endpoint.
+ var usesConditions: Bool { !conditions.isEmpty }
+
+ /// True when the request should come from the multimedia listing.
+ var usesImageGallery: Bool { filter == .imageGallery }
+}
+
+@MainActor
+final class AppViewModel: ObservableObject {
+ @Published var feeds: [Feed] = []
+ @Published private(set) var folders: [String] = []
+ @Published var articles: [Article] = []
+ @Published var unreadCounts = UnreadCounts.empty
+ @Published 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 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
+
+ /// Per-feed counts for each activity, used for the sidebar badges.
+ @Published var filterCounts = FilterCounts.empty
+ /// Saved filters, which appear in the sidebar under their own heading.
+ @Published var savedFilters: [SavedFilter] = []
+ /// Tags, which are assigned to feeds in the feed editor.
+ @Published var tags: [Tag] = []
+ /// The tag identifiers assigned to each feed.
+ @Published var feedTags: [Int: [Int]] = [:]
+ /// How far the running refresh has progressed.
+ @Published var refreshProgress = RefreshProgress(isRunning: false)
+
+ /// Restrict the current activity to unread items only.
+ @Published var showOnlyUnread = false {
+ didSet {
+ guard showOnlyUnread != oldValue else { return }
+ defaults.set(showOnlyUnread, forKey: Self.showOnlyUnreadKey)
+ reloadArticles()
+ }
+ }
+
+ /// How the list is ordered. Ordering is applied on this Mac so switching is
+ /// instant and does not refetch.
+ @Published var sortOrder: ArticleSortOrder = .newestFirst {
+ didSet {
+ guard sortOrder != oldValue else { return }
+ defaults.set(sortOrder.rawValue, forKey: Self.sortOrderKey)
+ }
+ }
+
+ /// The hits of the most recent AI search, empty when the ordinary list is shown.
+ @Published var searchHits: [AISearchHit] = []
+ /// The terms the AI expanded the query into, shown alongside the results.
+ @Published var searchTerms: String?
+ /// True while a search is running.
+ @Published var isSearching = false
+
+ /// Raised when a shortcut asks the reading pane to switch between the
+ /// rendered article and the original page.
+ @Published var requestedViewModeToggle = 0
+ /// Drives the add-subscription sheet, which a shortcut can also open.
+ @Published var isPresentingAddFeed = false
+
+ /// How much of each article the list shows.
+ @Published var listLayout: ArticleListLayout = .comfortable {
+ didSet {
+ guard listLayout != oldValue else { return }
+ defaults.set(listLayout.rawValue, forKey: Self.listLayoutKey)
+ }
+ }
+
+ @Published var selection: SidebarItem? = .filter(.all) {
+ didSet {
+ guard selection != oldValue else { return }
+ selectedArticleID = nil
+ reloadArticles()
+ }
+ }
+
+ static let showOnlyUnreadKey = "MrRSS.showOnlyUnread"
+ static let sortOrderKey = "MrRSS.sortOrder"
+ static let listLayoutKey = "MrRSS.listLayout"
+
+ private var page = 0
+ private var hasMore = true
+ private let limit: Int
+ /// Not private so the feature extensions in the neighbouring files can
+ /// reach the backend.
+ private(set) 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 settingsSaveTask: Task?
+ private var articleRequestID = UUID()
+
+ 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
+ showOnlyUnread = defaults.bool(forKey: Self.showOnlyUnreadKey)
+ sortOrder = ArticleSortOrder(rawValue: defaults.string(forKey: Self.sortOrderKey) ?? "")
+ ?? .newestFirst
+ listLayout = ArticleListLayout(rawValue: defaults.string(forKey: Self.listLayoutKey) ?? "")
+ ?? .comfortable
+ refreshFolders()
+
+ if autoLoad {
+ refreshAll()
+ }
+ }
+
+ func refreshAll() {
+ refreshFeeds()
+ reloadArticles()
+ Task { [weak self] in
+ guard let self else { return }
+ await loadSettings()
+ await refreshCounts()
+ await loadSavedFilters()
+ await loadTags()
+ }
+ }
+
+ 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 = AppViewModel.ordered(loadedFeeds)
+ refreshFolders()
+ refreshFeedTagAssignments()
+ 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()
+ refreshProgress = progress
+ if !progress.isRunning { break }
+ try await Task.sleep(for: .seconds(1))
+ }
+
+ try Task.checkCancellation()
+ refreshProgress = RefreshProgress(isRunning: false)
+ 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 = t("client.feed.invalidURL")
+ return false
+ }
+
+ do {
+ try await api.addFeed(
+ FeedDraft(
+ url: url.trimmingCharacters(in: .whitespacesAndNewlines),
+ title: title.trimmingCharacters(in: .whitespacesAndNewlines),
+ category: category.trimmingCharacters(in: .whitespacesAndNewlines)
+ )
+ )
+ refreshAll()
+ return true
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ /// Removes a subscription.
+ ///
+ /// Pass `reloading: false` when removing several in a row, and reload once
+ /// afterwards, so a bulk removal does not refetch per feed.
+ func deleteFeed(_ feed: Feed, reloading: Bool = true) async {
+ do {
+ try await api.deleteFeed(id: feed.id)
+ if selection == .feed(feed.id) {
+ selection = .filter(.all)
+ }
+ if reloading {
+ refreshAll()
+ }
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+
+ // 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 = t("client.folder.nameRequired")
+ return false
+ }
+ guard !folders.contains(trimmed) else {
+ errorMessage = t("client.folder.alreadyExists", ["name": trimmed])
+ 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()
+ }
+
+ /// 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)
+ }
+ }
+
+ /// 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
+ }
+ }
+
+ /// 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)
+ } 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 }
+ guard !folders.contains(trimmed) else {
+ errorMessage = t("client.folder.alreadyExists", ["name": trimmed])
+ 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()
+ page = 0
+ hasMore = true
+ fetchPage(1, replacing: true)
+ }
+
+ func selectArticle(_ article: Article) {
+ selectedArticleID = article.id
+ markReadOnOpen(article)
+ }
+
+ /// Opening an article marks it read, which is what the previous interface
+ /// did. Articles kept for later are left alone.
+ func markReadOnOpen(_ article: Article) {
+ guard !article.isRead, !article.isReadLater else { return }
+ setArticleRead(article, read: true)
+ }
+
+ /// True while more pages remain for the current selection.
+ var hasMoreArticles: Bool { hasMore }
+
+ 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 = t("client.server.invalidAddress")
+ 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()
+ applyLanguageSetting()
+ aiUsage = try? await api.fetchAIUsage()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ isLoadingSettings = false
+ }
+
+ /// Replaces the loaded settings outright. Tests use it to set up a state
+ /// the backend would normally provide.
+ func updateSettingsForTesting(_ newSettings: [String: String]) {
+ settings = newSettings
+ }
+
+ func setting(_ key: String, default defaultValue: String = "") -> String {
+ settings[key] ?? defaultValue
+ }
+
+ func updateSetting(_ key: String, value: String) {
+ guard settings[key] != value else { return }
+ settings[key] = value
+ scheduleSettingsSave()
+ }
+
+ 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) {
+ updateSetting(key, value: value ? "true" : "false")
+ }
+
+ /// Saves shortly after the last change, as the previous interface did, so a
+ /// reader does not have to remember to press anything.
+ private func scheduleSettingsSave() {
+ settingsSaveTask?.cancel()
+ settingsSaveTask = Task { [weak self] in
+ try? await Task.sleep(for: .milliseconds(500))
+ guard !Task.isCancelled else { return }
+ await self?.saveSettings()
+ }
+ }
+
+ @discardableResult
+ func saveSettings() async -> Bool {
+ guard !isSavingSettings else { return false }
+ isSavingSettings = true
+ do {
+ try await api.updateSettings(settings)
+ statusMessage = t("client.settings.saved")
+ decodeRules()
+ // The language and the theme change what is on screen, so apply them
+ // as soon as they are saved.
+ applyLanguageSetting()
+ 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 = t("client.rule.applied", ["count": result.affected])
+ 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 = t("client.ai.limitReachedFallback")
+ }
+ 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 = t("client.maintenance.cleared")
+ reloadArticles()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func resetAIUsage() async {
+ do {
+ try await api.resetAIUsage()
+ aiUsage = try await api.fetchAIUsage()
+ statusMessage = t("client.ai.usageReset")
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ var preferredColorScheme: ColorScheme? {
+ switch setting("theme", default: "auto") {
+ case "light": .light
+ case "dark": .dark
+ default: nil
+ }
+ }
+
+ func clearStatusMessage() {
+ statusMessage = nil
+ }
+
+ static let didAdoptSystemLanguageKey = "MrRSS.didAdoptSystemLanguage"
+
+ /// Follows the language stored on the server, which is where the previous
+ /// interface kept it too.
+ ///
+ /// The stored default is English. On a Mac set to another language that
+ /// would leave a first-time reader with an interface they did not ask for,
+ /// so the system language is adopted once and written back to the server.
+ /// Whatever the reader chooses afterwards is left alone.
+ func applyLanguageSetting() {
+ var stored = settings["language"]
+
+ if !defaults.bool(forKey: Self.didAdoptSystemLanguageKey) {
+ defaults.set(true, forKey: Self.didAdoptSystemLanguageKey)
+
+ let system = AppLanguage.systemDefault
+ if stored == AppLanguage.schemaDefault.rawValue, system != .english {
+ stored = system.rawValue
+ updateSetting("language", value: system.rawValue)
+ }
+ }
+
+ Localization.shared.setLanguage(AppLanguage.from(settingValue: stored))
+ }
+
+ 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 loaded = try await load(query: query, page: targetPage)
+ try Task.checkCancellation()
+ guard requestID == articleRequestID else { return }
+
+ if replacing {
+ articles = loaded.articles
+ } else {
+ let existingIDs = Set(articles.map(\.id))
+ articles.append(contentsOf: loaded.articles.filter { !existingIDs.contains($0.id) })
+ }
+ page = targetPage
+ // Whether another page exists follows what the server returned,
+ // not what survived the unread restriction: a full page of read
+ // articles filters down to nothing and would otherwise look
+ // like the end of the list.
+ hasMore = loaded.receivedCount == 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
+ }
+ }
+ }
+
+ /// One page of results, together with how many the server actually sent.
+ private struct LoadedPage {
+ let articles: [Article]
+ let receivedCount: Int
+ }
+
+ /// Runs one page of whichever request the current selection needs.
+ private func load(query: ArticleQuery, page: Int) async throws -> LoadedPage {
+ if query.usesConditions {
+ let response = try await api.filterArticles(
+ conditions: query.conditions,
+ page: page,
+ limit: limit
+ )
+ return restrictingUnread(response.articles)
+ }
+
+ if query.usesImageGallery {
+ return restrictingUnread(try await api.fetchImageArticles(page: page, limit: limit))
+ }
+
+ let filterValue = query.filter?.queryValue ?? (showOnlyUnread ? "unread" : "")
+ let articles = try await api.fetchArticles(
+ feedID: query.feedID,
+ category: query.category,
+ filter: filterValue,
+ page: page,
+ limit: limit
+ )
+ return restrictingUnread(articles)
+ }
+
+ /// The plain listing understands "unread only" through its filter, but the
+ /// multimedia and saved-filter endpoints do not, so the restriction is
+ /// applied here for those.
+ private func restrictingUnread(_ articles: [Article]) -> LoadedPage {
+ guard showOnlyUnread else {
+ return LoadedPage(articles: articles, receivedCount: articles.count)
+ }
+ return LoadedPage(
+ articles: articles.filter { !$0.isRead },
+ receivedCount: articles.count
+ )
+ }
+
+ /// The request the current sidebar selection maps to.
+ var articleQuery: ArticleQuery {
+ switch selection {
+ case .filter(let filter):
+ return ArticleQuery(filter: filter)
+ case .folder(let name):
+ return ArticleQuery(category: name)
+ case .feed(let id):
+ return ArticleQuery(feedID: id)
+ case .savedFilter(let id):
+ let conditions = savedFilters.first(where: { $0.id == id })?.conditions ?? []
+ return ArticleQuery(conditions: conditions)
+ case .none:
+ return ArticleQuery(filter: .all)
+ }
+ }
+}
diff --git a/frontend/Sources/Views/ArticleChatView.swift b/frontend/Sources/Views/ArticleChatView.swift
new file mode 100644
index 000000000..b3fc165d4
--- /dev/null
+++ b/frontend/Sources/Views/ArticleChatView.swift
@@ -0,0 +1,218 @@
+import SwiftUI
+
+/// Discusses one article with the configured AI provider, keeping the same
+/// stored conversations the previous interface used.
+struct ArticleChatView: View {
+ let article: Article
+ let articleContent: String
+ @ObservedObject var viewModel: AppViewModel
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var sessions: [ChatSession] = []
+ @State private var sessionID: Int?
+ @State private var messages: [ChatMessage] = []
+ @State private var draft = ""
+ @State private var isSending = false
+ @State private var errorMessage: String?
+ @State private var expandedThinking: Set = []
+
+ var body: some View {
+ VStack(spacing: 0) {
+ header
+ Divider()
+ transcript
+ Divider()
+ composer
+ }
+ .frame(width: 620, height: 620)
+ .task { await load() }
+ }
+
+ private var header: some View {
+ HStack(spacing: 10) {
+ Label(t("article.chat.aiChat"), systemImage: "bubble.left.and.text.bubble.right")
+ .font(.headline)
+
+ Spacer()
+
+ if !sessions.isEmpty {
+ Menu {
+ ForEach(sessions) { session in
+ Button(session.title.isEmpty ? article.title : session.title) {
+ Task { await open(session) }
+ }
+ }
+ } label: {
+ Label(t("article.chat.switchSession"), systemImage: "clock.arrow.circlepath")
+ }
+ .menuStyle(.borderlessButton)
+ .frame(width: 40)
+ }
+
+ Button(t("article.chat.newChat")) {
+ sessionID = nil
+ messages = []
+ }
+
+ Button(t("common.close")) { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ }
+ .padding(16)
+ }
+
+ @ViewBuilder
+ private var transcript: some View {
+ if messages.isEmpty {
+ ContentUnavailableView {
+ Label(t("article.chat.aiChat"), systemImage: "sparkles")
+ } description: {
+ Text(t("article.chat.aiChatWelcome"))
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 14) {
+ ForEach(messages) { message in
+ bubble(for: message).id(message.id)
+ }
+ if let errorMessage {
+ Text(errorMessage)
+ .font(.callout)
+ .foregroundStyle(.red)
+ }
+ }
+ .padding(16)
+ }
+ .onChange(of: messages.count) { _, _ in
+ guard let last = messages.last else { return }
+ withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
+ }
+ }
+ }
+ }
+
+ private func bubble(for message: ChatMessage) -> some View {
+ VStack(alignment: message.isAssistant ? .leading : .trailing, spacing: 6) {
+ Text(message.content)
+ .textSelection(.enabled)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 9)
+ .background(
+ message.isAssistant
+ ? AnyShapeStyle(.quaternary)
+ : AnyShapeStyle(Color.accentColor.opacity(0.18)),
+ in: RoundedRectangle(cornerRadius: 10, style: .continuous)
+ )
+
+ if let thinking = message.thinking {
+ Button(
+ expandedThinking.contains(message.id)
+ ? t("article.chat.hideThinking")
+ : t("article.chat.showThinking")
+ ) {
+ if expandedThinking.contains(message.id) {
+ expandedThinking.remove(message.id)
+ } else {
+ expandedThinking.insert(message.id)
+ }
+ }
+ .font(.caption)
+ .buttonStyle(.plain)
+ .foregroundStyle(.tint)
+
+ if expandedThinking.contains(message.id) {
+ Text(thinking)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: message.isAssistant ? .leading : .trailing)
+ }
+
+ private var composer: some View {
+ HStack(spacing: 10) {
+ TextField(t("article.chat.aiChatInputPlaceholder"), text: $draft, axis: .vertical)
+ .textFieldStyle(.roundedBorder)
+ .lineLimit(1...4)
+ .onSubmit { Task { await send() } }
+
+ if isSending {
+ ProgressView().controlSize(.small)
+ } else {
+ Button {
+ Task { await send() }
+ } label: {
+ Image(systemName: "paperplane.fill")
+ }
+ .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(16)
+ }
+
+ private func load() async {
+ sessions = (try? await viewModel.api.fetchChatSessions(articleID: article.id)) ?? []
+ if let latest = sessions.first {
+ await open(latest)
+ }
+ }
+
+ private func open(_ session: ChatSession) async {
+ sessionID = session.id
+ messages = (try? await viewModel.api.fetchChatMessages(sessionID: session.id)) ?? []
+ }
+
+ private func send() async {
+ let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty, !isSending else { return }
+
+ isSending = true
+ errorMessage = nil
+ draft = ""
+
+ // Show what was typed straight away, then replace the placeholder
+ // identifier when the exchange is stored.
+ let localID = (messages.map(\.id).max() ?? 0) + 1
+ messages.append(
+ ChatMessage(id: localID, sessionID: sessionID ?? 0, role: "user", content: text)
+ )
+
+ let turns = messages.map { ChatRequest.Turn(role: $0.role, content: $0.content) }
+ let request = ChatRequest(
+ messages: turns,
+ sessionID: sessionID,
+ articleID: article.id,
+ articleTitle: article.title,
+ articleURL: article.url,
+ articleContent: articleContent,
+ isFirstMessage: sessionID == nil
+ )
+
+ do {
+ let response = try await viewModel.api.sendChatMessage(request)
+ sessionID = response.sessionID ?? sessionID
+ messages.append(
+ ChatMessage(
+ id: localID + 1,
+ sessionID: sessionID ?? 0,
+ role: "assistant",
+ content: response.response,
+ html: response.html,
+ thinking: response.thinking
+ )
+ )
+ if !response.historySaved {
+ errorMessage = t("article.chat.historySaveFailed")
+ }
+ sessions = (try? await viewModel.api.fetchChatSessions(articleID: article.id)) ?? sessions
+ } catch {
+ errorMessage = "\(t("article.chat.aiChatError")) \(error.localizedDescription)"
+ }
+
+ isSending = false
+ }
+}
diff --git a/frontend/Sources/Views/ArticleDetailView.swift b/frontend/Sources/Views/ArticleDetailView.swift
new file mode 100644
index 000000000..19a210893
--- /dev/null
+++ b/frontend/Sources/Views/ArticleDetailView.swift
@@ -0,0 +1,634 @@
+import AppKit
+import SwiftUI
+
+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?
+ @State private var viewMode: ArticleViewMode = .rendered
+ @State private var isShowingFindBar = false
+ @State private var findQuery = ""
+ @State private var galleryImages: [String] = []
+ @State private var isShowingGallery = false
+ @State private var isShowingChat = false
+ @State private var scrollTarget: 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()
+ if isShowingFindBar {
+ findBar
+ Divider()
+ }
+ contentView
+ }
+ .frame(width: geometry.size.width, height: geometry.size.height)
+ .clipped()
+ }
+ .background(Color(nsColor: .textBackgroundColor))
+ .toolbar { toolbarContent }
+ .task(id: article.id) { await prepare() }
+ .onChange(of: viewModel.requestedViewModeToggle) { _, _ in
+ viewMode = viewMode == .rendered ? .webpage : .rendered
+ }
+ .sheet(isPresented: $isShowingGallery) {
+ ImageGalleryView(images: galleryImages, title: article.title)
+ }
+ .sheet(isPresented: $isShowingChat) {
+ ArticleChatView(
+ article: article,
+ articleContent: plainText(from: articleContent.content),
+ viewModel: viewModel
+ )
+ }
+ .alert(t("common.errors.unknownError"), isPresented: Binding(
+ get: { operationError != nil },
+ set: { if !$0 { operationError = nil } }
+ )) {
+ Button(t("client.action.dismiss"), role: .cancel) { operationError = nil }
+ } message: {
+ Text(operationError ?? "")
+ }
+ .alert("MrRSS", isPresented: Binding(
+ get: { operationNotice != nil },
+ set: { if !$0 { operationNotice = nil } }
+ )) {
+ Button(t("common.confirm"), role: .cancel) { operationNotice = nil }
+ } message: {
+ Text(operationNotice ?? "")
+ }
+ }
+
+ // MARK: - Header
+
+ 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 = viewModel.feed(for: article)?.title ?? article.feedTitle {
+ Text(feedTitle)
+ .fontWeight(.medium)
+ .foregroundStyle(.tint)
+ }
+
+ if let author = article.author {
+ Text("·").foregroundStyle(.secondary)
+ Text(author).foregroundStyle(.secondary)
+ }
+
+ Text(ArticleDateFormatter.fullDescription(for: article.publishedAt))
+ .foregroundStyle(.secondary)
+
+ Spacer()
+ }
+ .font(.subheadline)
+ }
+ .padding(.horizontal, 28)
+ .padding(.top, 24)
+ .padding(.bottom, 20)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.bar)
+ }
+
+ private var findBar: some View {
+ HStack(spacing: 8) {
+ Image(systemName: "magnifyingglass")
+ .foregroundStyle(.secondary)
+ TextField(t("common.findInPage.findInPagePlaceholder"), text: $findQuery)
+ .textFieldStyle(.plain)
+ Button {
+ isShowingFindBar = false
+ findQuery = ""
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 8)
+ .background(.bar)
+ }
+
+ // MARK: - Content
+
+ @ViewBuilder
+ private var contentView: some View {
+ if isLoading {
+ ProgressView(t("article.content.loadingContent"))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if let loadError {
+ ContentUnavailableView {
+ Label(t("common.errors.fetchingArticleContent"), systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(loadError)
+ } actions: {
+ Button(t("client.action.retry")) {
+ Task { await loadContent() }
+ }
+ }
+ } else {
+ VStack(spacing: 0) {
+ if let summaryResult, !summaryResult.summary.isEmpty {
+ SummaryCard(result: summaryResult)
+ Divider()
+ }
+
+ if let audioURL = article.audioURL, let url = URL(string: audioURL) {
+ AudioPlayerBar(url: url)
+ Divider()
+ }
+
+ if let videoURL = article.videoURL, let url = URL(string: videoURL) {
+ VideoPlayerBar(url: url)
+ Divider()
+ }
+
+ if translatedContent != nil {
+ Picker("", selection: $displayTranslation) {
+ Text(t("setting.reading.showOriginal")).tag(false)
+ Text(t("article.summary.translatedSummary")).tag(true)
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ .frame(maxWidth: 280)
+ .padding(.vertical, 8)
+ }
+
+ WebView(
+ source: webSource,
+ typography: typography,
+ findQuery: findQuery,
+ scrollTarget: scrollTarget
+ )
+ .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
+ .clipped()
+ .overlay(alignment: .topTrailing) {
+ if showsTableOfContents {
+ TableOfContentsPanel(entries: tableOfContents) { entry in
+ scrollTarget = entry.id
+ }
+ .padding(.top, 16)
+ .padding(.trailing, 16)
+ .frame(maxHeight: 360)
+ }
+ }
+ }
+ }
+ }
+
+ /// Drives the reading-mode switch.
+ private var webpageModeBinding: Binding {
+ Binding(
+ get: { viewMode == .webpage },
+ set: { viewMode = $0 ? .webpage : .rendered }
+ )
+ }
+
+ private var webSource: WebViewSource {
+ if viewMode == .webpage, let url = URL(string: article.url) {
+ return .url(url)
+ }
+ let markup = showsTableOfContents
+ ? ArticleTableOfContents.anchored(displayedContent)
+ : displayedContent
+ return .html(markup, baseURL: URL(string: articleContent.feedURL ?? article.url))
+ }
+
+ /// The headings of the article currently being read.
+ private var tableOfContents: [TableOfContentsEntry] {
+ ArticleTableOfContents.entries(in: displayedContent)
+ }
+
+ /// The list is only worth showing for a rendered article with real structure.
+ private var showsTableOfContents: Bool {
+ viewMode == .rendered
+ && viewModel.boolSetting("show_floating_toc")
+ && tableOfContents.count > 2
+ }
+
+ private var typography: WebViewTypography {
+ WebViewTypography(
+ fontFamily: viewModel.setting("content_font_family", default: "system"),
+ fontSize: Int(viewModel.setting("content_font_size", default: "16")) ?? 16,
+ lineHeight: viewModel.setting("content_line_height", default: "1.6")
+ )
+ }
+
+ private var displayedContent: String {
+ if displayTranslation, let translatedContent {
+ if !translatedContent.html.isEmpty { return translatedContent.html }
+ return "
"
+
+ let entries = ArticleTableOfContents.entries(in: html)
+
+ XCTAssertEqual(entries.first?.text, "Section title")
+ }
+
+ func testHeadingsWithoutTextAreSkipped() {
+ let html = "
Real
"
+
+ let entries = ArticleTableOfContents.entries(in: html)
+
+ XCTAssertEqual(entries.map(\.text), ["Real"])
+ }
+
+ func testAnchorsMatchTheListedIdentifiers() {
+ let html = "
One
Two
"
+
+ let entries = ArticleTableOfContents.entries(in: html)
+ let anchored = ArticleTableOfContents.anchored(html)
+
+ for entry in entries {
+ XCTAssertTrue(
+ anchored.contains("id=\"\(entry.id)\""),
+ "\(entry.id) should be anchored in the rendered markup"
+ )
+ }
+ XCTAssertTrue(anchored.contains("class=\"x\""), "existing attributes should survive")
+ }
+}
+
+@MainActor
+final class WebViewScriptingTests: XCTestCase {
+ /// The rendered document has page scripting switched off. This checks that
+ /// the application can still run a script itself, which is how the table of
+ /// contents scrolls the article.
+ func testTheHostCanRunScriptsWhilePageScriptingIsOff() async throws {
+ let configuration = WKWebViewConfiguration()
+ let preferences = WKWebpagePreferences()
+ preferences.allowsContentJavaScript = false
+ configuration.defaultWebpagePreferences = preferences
+
+ let webView = WKWebView(frame: .init(x: 0, y: 0, width: 400, height: 400), configuration: configuration)
+ webView.loadHTMLString("
", 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 waitUntil("the articles to load") { !viewModel.articles.isEmpty }
+ 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)
+ // The detail pane builds its web view once the article content has
+ // loaded, which is the point the layout is worth measuring.
+ try await waitUntil("the detail pane to be built") {
+ firstDescendant(of: WKWebView.self, in: hostingView) != nil
+ }
+ window.layoutIfNeeded()
+ hostingView.layoutSubtreeIfNeeded()
+
+ 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 waitUntil("the detail pane to be built") {
+ firstDescendant(of: WKWebView.self, in: hostingView) != nil
+ }
+ 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
+ }
+}
+
+final class WebViewSourceTests: XCTestCase {
+ func testTypographyFollowsTheReadingSettings() {
+ let typography = WebViewTypography(
+ fontFamily: "serif",
+ fontSize: 19,
+ lineHeight: "1.8"
+ )
+
+ let document = HTMLDocument.build(from: "
Body
", typography: typography)
+
+ XCTAssertTrue(document.contains("19px/1.8"), "the size and leading should reach the document")
+ XCTAssertTrue(document.contains("New York"), "the serif stack should be used")
+ }
+
+ func testTheDefaultTypographyKeepsTheSystemStack() {
+ let document = HTMLDocument.build(from: "
Body
")
+
+ XCTAssertTrue(document.contains("16px/1.6"))
+ XCTAssertTrue(document.contains("-apple-system"))
+ }
+
+ func testALiveSourceIsDistinguishedFromRenderedText() {
+ let live = WebViewSource.url(URL(string: "https://example.com")!)
+ let rendered = WebViewSource.html("