Merge pull request #4916 from RSSNext/release/desktop/1.4.0

release(desktop): Release v1.4.0
This commit is contained in:
DIYgod 2026-03-12 21:25:30 +08:00 committed by GitHub
commit 36f733be41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
501 changed files with 32878 additions and 1853 deletions

View File

@ -0,0 +1,24 @@
{
"permissions": {
"allow": [
"Bash(gh pr view:*)",
"Bash(pnpm bump:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(ls:*)",
"Bash(npx:*)",
"Bash(git stash:*)",
"Bash(git show:*)",
"Bash(git -C /Users/diygod/Code/Projects/Folo status --short)",
"Bash(git -C /Users/diygod/Code/Projects/Folo add apps/mobile/changelog/next.md)",
"Bash(git -C /Users/diygod/Code/Projects/Folo commit:*)",
"Bash(git checkout:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(git rm:*)",
"Bash(git push:*)",
"Bash(gh api:*)",
"Bash(pnpm exec vv:*)"
]
}
}

View File

@ -60,7 +60,21 @@ Perform a regular desktop release. This skill handles the full release workflow
5. Keep `NEXT_VERSION` as the placeholder - it will be replaced by `apply-changelog.ts` during bump.
## Step 3: Evaluate mainHash
## Step 3: Commit changelog updates before bump
`nbump` requires a clean working tree. Commit changelog edits before running bump.
1. Stage the changelog update:
```bash
git add apps/desktop/changelog/next.md
```
2. Commit it on `dev`:
```bash
git commit -m "docs(desktop): prepare release changelog"
```
3. If there are no changes to commit, continue without creating an extra commit.
## Step 4: Evaluate mainHash
This is critical for determining whether users need a full app update or can use the lightweight renderer hot update.
@ -86,14 +100,18 @@ Present your analysis to the user with:
- Your recommendation (update or skip mainHash)
- Ask for explicit confirmation
## Step 4: Save old mainHash and execute bump
## Step 5: Save old mainHash and execute bump
1. Save the current mainHash from `apps/desktop/package.json` for later comparison.
2. Change directory to `apps/desktop/` and run the bump:
2. Verify working tree is clean before bump:
```bash
git status --short
```
3. Change directory to `apps/desktop/` and run the bump:
```bash
cd apps/desktop && pnpm bump
```
3. This command will:
4. This command will:
- Pull latest changes
- Apply changelog (rename next.md to {version}.md, create new next.md)
- Recalculate mainHash and write to package.json
@ -103,11 +121,11 @@ Present your analysis to the user with:
- Create branch `release/desktop/{NEW_VERSION}`
- Push branch and create PR to `main`
## Step 5: Restore mainHash if skipping update
## Step 6: Restore mainHash if skipping update
If Step 3 decided mainHash should NOT be updated, restore the old value now. The bump has already committed, pushed, and created the PR on a new release branch, so we amend the commit and force push. This is safe because the release branch was just created.
If Step 4 decided mainHash should NOT be updated, restore the old value now. The bump has already committed, pushed, and created the PR on a new release branch, so we amend the commit and force push. This is safe because the release branch was just created.
1. Change back to the repo root first (Step 4 left the working directory at `apps/desktop/`):
1. Change back to the repo root first (Step 5 left the working directory at `apps/desktop/`):
```bash
cd ../..
```
@ -122,9 +140,9 @@ If Step 3 decided mainHash should NOT be updated, restore the old value now. The
git push --force origin release/desktop/{NEW_VERSION}
```
If Step 3 decided mainHash SHOULD be updated, skip this step entirely — the bump already wrote the correct new value.
If Step 4 decided mainHash SHOULD be updated, skip this step entirely — the bump already wrote the correct new value.
## Step 6: Verify
## Step 7: Verify
1. Confirm the PR was created successfully by checking the output.
2. Report the new version number and PR URL to the user.

View File

@ -0,0 +1,175 @@
---
name: mobile-e2e
description: Run apps/mobile Maestro end-to-end tests in this repo. Use when an agent needs to validate mobile auth flows on iOS Simulator or Android Emulator. Current maintained coverage is register, sign out, and sign in.
disable-model-invocation: true
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
---
# Mobile E2E
Run the mobile Maestro tests for `apps/mobile`.
## Files that matter
- Runner: `apps/mobile/e2e/run-maestro.sh`
- iOS auth flow: `apps/mobile/e2e/flows/ios/auth.yaml`
- Android auth flow: `apps/mobile/e2e/flows/android/core.yaml`
- Shared auth flows: `apps/mobile/e2e/flows/shared/*.yaml`
- Artifacts: `apps/mobile/e2e/artifacts/`
## Always do first
From repo root:
```bash
cd apps/mobile
pnpm run e2e:doctor
pnpm run typecheck
```
## iOS
Use a simulator `.app` build, not an Expo development client.
### Preferred simulator
Prefer the **latest installed iOS runtime** and a **latest-generation iPhone simulator**.
When multiple simulators are available, bias toward the newest iPhone model on the newest installed iOS version.
### Boot simulator
```bash
xcrun simctl boot <IOS_UDID>
xcrun simctl bootstatus <IOS_UDID> -b
open -a Simulator --args -CurrentDeviceUDID <IOS_UDID>
```
### App bundle
`run-maestro.sh` can resolve the app bundle from one of these sources:
- `MAESTRO_IOS_APP_PATH`
- a local `build-*.tar.gz` in `apps/mobile`
- an existing `DerivedData/.../Release-iphonesimulator/Folo.app`
If none of those exist, build one first.
### Build simulator app when missing
If `Folo.app` is not available yet:
```bash
cd apps/mobile/ios
pod install
xcodebuild -workspace Folo.xcworkspace \
-scheme Folo \
-configuration Release \
-sdk iphonesimulator \
-destination 'id=<IOS_UDID>' \
build
```
### Apple Silicon simulator optimization
When running on an Apple Silicon Mac and building only for the simulator used in the current run, prefer compiling only the active `arm64` simulator architecture:
```bash
xcodebuild ... \
ONLY_ACTIVE_ARCH=YES \
ARCHS=arm64
```
Use this optimization only for local self-test / e2e simulator builds tied to the current machine. Do not use it when you need a universal simulator app for other machines or when running on Intel Macs.
Expected output pattern:
```bash
~/Library/Developer/Xcode/DerivedData/.../Build/Products/Release-iphonesimulator/Folo.app
```
### Run iOS auth flow
```bash
cd apps/mobile
MAESTRO_IOS_DEVICE_ID=<IOS_UDID> \
MAESTRO_IOS_APP_PATH=<PATH_TO_Folo.app> \
pnpm run e2e:ios
```
## Android
Use a **release APK**, not an Expo development build.
### Java
Use Android Studio bundled JBR:
```bash
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
export PATH="$JAVA_HOME/bin:$PATH"
```
### Android SDK
```bash
export ANDROID_HOME="$HOME/Library/Android/sdk"
export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk"
```
If `apps/mobile/android/local.properties` is missing, create it with:
```bash
echo "sdk.dir=$HOME/Library/Android/sdk" > apps/mobile/android/local.properties
```
### Build release APK
If `apps/mobile/android` does not exist locally, generate it first with Expo prebuild / run-android tooling.
Then build the release APK:
```bash
cd apps/mobile/android
./gradlew app:assembleRelease --console=plain
```
Expected APK path:
```bash
apps/mobile/android/app/build/outputs/apk/release/app-release.apk
```
### Install to emulator
```bash
adb -s emulator-5554 install -r apps/mobile/android/app/build/outputs/apk/release/app-release.apk
```
### Run Android auth flow
Start a booted emulator first, then:
```bash
cd apps/mobile
pnpm run e2e:android
```
## Result checks
Successful auth validation means:
- register flow finishes
- sign-out reaches `login-screen`
- login flow makes `login-screen` disappear
## Debugging output
Inspect these folders after a run:
```bash
apps/mobile/e2e/artifacts/ios/
apps/mobile/e2e/artifacts/android/
```
For a one-off focused run, invoke Maestro directly against a single flow and a custom debug directory.

View File

@ -64,13 +64,31 @@ Perform a regular mobile release. This skill handles the full release workflow f
5. Keep `NEXT_VERSION` as the placeholder - it will be replaced by `apply-changelog.ts` during bump.
## Step 3: Execute bump
## Step 3: Commit changelog updates before bump
1. Change directory to `apps/mobile/` and run the bump:
`nbump` requires a clean working tree. Commit changelog edits before running bump.
1. Stage the changelog update:
```bash
git add apps/mobile/changelog/next.md
```
2. Commit it on `dev`:
```bash
git commit -m "docs(mobile): prepare release changelog"
```
3. If there are no changes to commit, continue without creating an extra commit.
## Step 4: Execute bump
1. Verify working tree is clean before bump:
```bash
git status --short
```
2. Change directory to `apps/mobile/` and run the bump:
```bash
cd apps/mobile && pnpm bump
```
2. This is an interactive `nbump` command that prompts for version selection. It will:
3. This is an interactive `nbump` command that prompts for version selection. It will:
- Pull latest changes
- Apply changelog (rename next.md to {version}.md, create new next.md from template)
- Format package.json with eslint + prettier
@ -82,7 +100,7 @@ Perform a regular mobile release. This skill handles the full release workflow f
- Create branch `release/mobile/{NEW_VERSION}`
- Push branch and create PR to `mobile-main`
## Step 4: Verify
## Step 5: Verify
1. Confirm the PR was created successfully by checking the output.
2. Report the new version number and PR URL to the user.

View File

@ -0,0 +1,433 @@
---
name: mobile-self-test
description: Self-test a mobile feature change or bug fix after implementation in `apps/mobile`. Use this whenever the user asks to verify a mobile change, run simulator acceptance, smoke-test a mobile PR, or provide screenshot proof for a mobile fix. This skill decides between prod vs local API mode, starts the local follow-server when needed, builds a release app, uses Maestro only to bootstrap registration for non-auth work, then switches to screenshot-driven visual validation and returns screenshot evidence.
disable-model-invocation: true
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
---
# Mobile Self Test
Validate a mobile change after implementation.
This skill extends `../mobile-e2e/SKILL.md`. Read that skill first for the baseline doctor checks, iOS simulator boot rules, Java/Android SDK setup, and Maestro artifact conventions. Then apply the extra rules in this skill.
## Files that matter
- Reference skill: `../mobile-e2e/SKILL.md`
- Runner: `apps/mobile/e2e/run-maestro.sh`
- iOS register flow: `apps/mobile/e2e/flows/ios/register.yaml`
- Android register flow: `apps/mobile/e2e/flows/android/register.yaml`
- Shared auth flows: `apps/mobile/e2e/flows/shared/*.yaml`
- Expo config: `apps/mobile/app.config.ts`
- Build profiles: `apps/mobile/eas.json`
- Mobile artifacts: `apps/mobile/e2e/artifacts/`
- Local server repo: `/Users/diygod/Code/Projects/follow-server`
## Default assumptions
- Prefer **iOS simulator** unless the user explicitly asks for Android or the change is Android-specific.
- Default to **prod API mode** when the user did not specify a mode.
- Default to **local API mode** when the task also involves local server changes, backend debugging, or modified files in `/Users/diygod/Code/Projects/follow-server`.
- Keep `EXPO_PUBLIC_E2E_LANGUAGE=en` unless the user explicitly wants another language. The existing Maestro flows assume English UI.
## Simulator and emulator isolation
This section overrides the shared device-selection guidance from `../mobile-e2e/SKILL.md`.
Self-test runs must be isolated because other agents may be using simulators or emulators on the same machine.
- Always create a dedicated temporary simulator or emulator for the current run.
- Never reuse `booted`, an already-running simulator, or a generic Android serial such as `emulator-5554`.
- Record the temporary device name and identifier immediately after creation, then use only that stored identifier for build, install, launch, screenshots, and Maestro.
- Register cleanup before booting the device so the temporary simulator or emulator is deleted even if the test fails midway.
- If cleanup fails, report the leftover device name and identifier in the final response.
## Decide API mode first
Use this decision order:
1. If the user explicitly asks for `prod` or `local`, obey that.
2. Otherwise, if the task depends on local backend changes or local server behavior, use `local`.
3. Otherwise, use `prod`.
Map the chosen mode into the release build:
- `prod` mode: `EXPO_PUBLIC_E2E_ENV_PROFILE=prod`
- `local` mode: `EXPO_PUBLIC_E2E_ENV_PROFILE=local`
Do not silently reuse a build from the other mode. Rebuild the release app when switching between `prod` and `local`.
## Always do first
From repo root:
```bash
cd apps/mobile
pnpm run e2e:doctor
pnpm run typecheck
```
If these fail, stop and report the blocker before attempting simulator work.
## Local server mode
`local` mode requires the local server to be available at `http://localhost:3000`.
Before starting anything, check whether it is already running. Do not start a duplicate server.
```bash
FOLLOW_SERVER_LOG=/tmp/follow-server-dev-core.log
if pgrep -af "pnpm dev:core" >/dev/null 2>&1 || lsof -nP -iTCP:3000 -sTCP:LISTEN >/dev/null 2>&1; then
echo "follow-server already running"
else
(
cd /Users/diygod/Code/Projects/follow-server
nohup pnpm dev:core >"$FOLLOW_SERVER_LOG" 2>&1 &
)
fi
for _ in $(seq 1 60); do
nc -z 127.0.0.1 3000 >/dev/null 2>&1 && break
sleep 2
done
nc -z 127.0.0.1 3000 >/dev/null 2>&1
```
If the task depends on other local surfaces such as `http://localhost:2233`, call that out explicitly instead of pretending the mobile test fully covers it.
## Release build profiles for self-test
Use release-style builds so the test matches user-facing behavior.
- iOS simulator builds: `PROFILE=e2e-ios-simulator`
- Android emulator builds: `PROFILE=e2e-android`
Always pair those with the chosen API mode and language:
```bash
export EXPO_PUBLIC_E2E_ENV_PROFILE=<prod-or-local>
export EXPO_PUBLIC_E2E_LANGUAGE=en
```
## iOS workflow
Do not attach to an existing simulator from `../mobile-e2e/SKILL.md`. Create a dedicated temporary simulator for this run and keep using only its UDID.
### Create a dedicated temporary simulator
Pick the latest available iOS runtime and a recent iPhone device type, then create a temporary simulator.
```bash
IOS_SIM_NAME="CodexSelfTest-$(date +%Y%m%d-%H%M%S)"
IOS_RUNTIME_ID="<latest available iOS runtime identifier from `xcrun simctl list runtimes`>"
IOS_DEVICE_TYPE_ID="<recent iPhone device type identifier from `xcrun simctl list devicetypes`>"
IOS_UDID="$(xcrun simctl create "$IOS_SIM_NAME" "$IOS_DEVICE_TYPE_ID" "$IOS_RUNTIME_ID")"
cleanup_ios_simulator() {
xcrun simctl shutdown "$IOS_UDID" >/dev/null 2>&1 || true
xcrun simctl delete "$IOS_UDID" >/dev/null 2>&1 || true
}
trap cleanup_ios_simulator EXIT
```
Do not switch to another simulator after `IOS_UDID` is created.
### Boot the dedicated simulator
```bash
xcrun simctl boot "$IOS_UDID"
xcrun simctl bootstatus "$IOS_UDID" -b
open -a Simulator --args -CurrentDeviceUDID "$IOS_UDID"
```
If other simulators are already booted, leave them alone and continue using only `IOS_UDID`.
### Build release simulator app
```bash
cd apps/mobile/ios
pod install
PROFILE=e2e-ios-simulator \
EXPO_PUBLIC_E2E_ENV_PROFILE=<prod-or-local> \
EXPO_PUBLIC_E2E_LANGUAGE=en \
xcodebuild -workspace Folo.xcworkspace \
-scheme Folo \
-configuration Release \
-sdk iphonesimulator \
-destination "id=$IOS_UDID" \
clean build
```
On Apple Silicon Macs, when the build is only for the dedicated simulator created for the current self-test run, prefer compiling only the active `arm64` simulator architecture:
```bash
ONLY_ACTIVE_ARCH=YES \
ARCHS=arm64
```
Do not use that optimization when you need a universal simulator bundle for other machines or when the host Mac is Intel.
Expected output pattern:
```bash
~/Library/Developer/Xcode/DerivedData/.../Build/Products/Release-iphonesimulator/Folo.app
```
### Install app on simulator
```bash
xcrun simctl install "$IOS_UDID" <PATH_TO_Folo.app>
xcrun simctl launch "$IOS_UDID" is.follow
```
## Android workflow
Reuse the Java and Android SDK setup from `../mobile-e2e/SKILL.md`.
Do not attach to a shared emulator. Create a dedicated temporary AVD for this run and keep using only its recorded serial.
### Create a dedicated temporary AVD
Create a fresh AVD backed by an installed phone system image.
```bash
ANDROID_AVD_NAME="codex-self-test-$(date +%Y%m%d-%H%M%S)"
ANDROID_AVD_PACKAGE="<installed Android system image package>"
ANDROID_AVD_DEVICE="<phone hardware profile>"
avdmanager create avd -n "$ANDROID_AVD_NAME" -k "$ANDROID_AVD_PACKAGE" -d "$ANDROID_AVD_DEVICE" --force
ANDROID_EMULATOR_PORT=""
for port in 5554 5556 5558 5560 5562 5564; do
if ! lsof -nP -iTCP:$port >/dev/null 2>&1 && ! lsof -nP -iTCP:$((port + 1)) >/dev/null 2>&1; then
ANDROID_EMULATOR_PORT="$port"
break
fi
done
[ -n "$ANDROID_EMULATOR_PORT" ] || {
echo "No free Android emulator port found"
exit 1
}
ANDROID_DEVICE_ID="emulator-$ANDROID_EMULATOR_PORT"
cleanup_android_emulator() {
adb -s "$ANDROID_DEVICE_ID" emu kill >/dev/null 2>&1 || true
avdmanager delete avd -n "$ANDROID_AVD_NAME" >/dev/null 2>&1 || true
}
trap cleanup_android_emulator EXIT
```
### Boot the dedicated emulator
```bash
emulator @"$ANDROID_AVD_NAME" -port "$ANDROID_EMULATOR_PORT" -no-snapshot -wipe-data &
adb -s "$ANDROID_DEVICE_ID" wait-for-device
```
If other emulators are already booted, ignore them and continue using only `ANDROID_DEVICE_ID`.
If `apps/mobile/android` does not exist locally, generate it first.
```bash
cd apps/mobile
pnpm expo prebuild android
```
### Build release APK
```bash
cd apps/mobile/android
PROFILE=e2e-android \
EXPO_PUBLIC_E2E_ENV_PROFILE=<prod-or-local> \
EXPO_PUBLIC_E2E_LANGUAGE=en \
./gradlew clean app:assembleRelease --console=plain
```
Expected APK path:
```bash
apps/mobile/android/app/build/outputs/apk/release/app-release.apk
```
### Install app on emulator
```bash
adb -s "$ANDROID_DEVICE_ID" install -r apps/mobile/android/app/build/outputs/apk/release/app-release.apk
adb -s "$ANDROID_DEVICE_ID" shell monkey -p is.follow -c android.intent.category.LAUNCHER 1
```
## Cleanup is mandatory
Delete the temporary simulator or emulator created for the run before returning control to the user.
### iOS cleanup
```bash
xcrun simctl shutdown "$IOS_UDID" >/dev/null 2>&1 || true
xcrun simctl delete "$IOS_UDID" >/dev/null 2>&1 || true
```
### Android cleanup
```bash
adb -s "$ANDROID_DEVICE_ID" emu kill >/dev/null 2>&1 || true
avdmanager delete avd -n "$ANDROID_AVD_NAME" >/dev/null 2>&1 || true
```
Do not leave temporary devices behind for other agents.
## Choose the auth strategy
This is the core difference from `mobile-e2e`.
### A. Change is **not** related to login or registration
Use the existing automated **registration** flow first to bootstrap a clean logged-in account, then do the real verification visually.
Examples:
- timeline behavior
- subscription management
- onboarding content after auth
- settings pages unrelated to sign-in state
- player, reader, share, discover, profile editing
Generate a unique test account before running the flow:
```bash
export E2E_PASSWORD='Password123!'
export E2E_EMAIL="folo-self-test-$(date +%Y%m%d%H%M%S)@example.com"
```
For non-auth iOS self-tests, bootstrap auth through the standard iOS runner mode after the app has been installed and launched once:
```bash
cd apps/mobile
pnpm run e2e:ios:bootstrap
```
This bootstrap path is the default for `prod` and `local` self-tests. Only skip it when the feature under test is login, registration, sign-out, session restoration, or another auth-specific flow that must be validated visually end-to-end.
#### iOS registration bootstrap
```bash
cd apps/mobile
maestro test --format junit --platform ios --device "$IOS_UDID" \
--debug-output e2e/artifacts/ios/register-bootstrap \
-e E2E_EMAIL="$E2E_EMAIL" \
-e E2E_PASSWORD="$E2E_PASSWORD" \
e2e/flows/ios/register.yaml
```
#### Android registration bootstrap
```bash
cd apps/mobile
maestro test --format junit --platform android --device "$ANDROID_DEVICE_ID" \
--debug-output e2e/artifacts/android/register-bootstrap \
-e E2E_EMAIL="$E2E_EMAIL" \
-e E2E_PASSWORD="$E2E_PASSWORD" \
e2e/flows/android/register.yaml
```
After registration succeeds, continue with screenshot-driven visual testing.
### B. Change **is** related to login, registration, logout, session handling, auth validation, or onboarding gates
Do **not** rely on the existing Maestro auth flows for the actual verification. Use a fully visual/manual run instead so the changed UX itself is what gets tested.
Examples:
- register screen changes
- login screen changes
- credential validation changes
- auth toggle changes
- logout behavior
- auth/session restoration
- onboarding shown or hidden based on auth state
For auth-related work:
- create the test account manually through the UI if needed
- use screenshots after every critical step
- verify success and error states visually
- keep a clean record of the exact screen sequence shown to the user
## Screenshot-driven visual testing
Once the app is in the right state, drive the rest of the validation with the visual toolchain available in the current environment. Screenshots are the source of truth for acceptance.
Create a timestamped artifact folder first:
```bash
REPO_ROOT="$(git rev-parse --show-toplevel)"
ARTIFACT_DIR="$REPO_ROOT/apps/mobile/e2e/artifacts/manual/$(date +%Y%m%d-%H%M%S)-<platform>-<prod-or-local>"
mkdir -p "$ARTIFACT_DIR"
```
Capture screenshots after each meaningful checkpoint.
### iOS screenshot command
```bash
xcrun simctl io "$IOS_UDID" screenshot "$ARTIFACT_DIR/<name>.png"
```
### Android screenshot command
```bash
adb -s "$ANDROID_DEVICE_ID" exec-out screencap -p > "$ARTIFACT_DIR/<name>.png"
```
Minimum screenshot set for a complete self-test:
1. entry screen before the changed flow
2. the changed screen or interaction in progress
3. the final success state or the reproduced bug state
Add more screenshots when the flow has multiple important states.
Do not report success without screenshot evidence.
## What to validate visually
Use the screenshots to confirm at least these points when relevant:
- the correct screen is reached
- the changed control, copy, or layout is visible
- loading, empty, error, and success states look correct
- the operation completes without obvious regressions or blocking dialogs
- the app is talking to the intended environment (`prod` or `local`)
If the UI or behavior is ambiguous, capture another screenshot instead of guessing.
## Final user-facing output
The final response must include:
- API mode used and why it was chosen
- platform and dedicated simulator/emulator name plus identifier used
- cleanup result for the temporary simulator/emulator
- whether the local server was reused or started, plus log path if started
- build command used
- whether auth bootstrap was automated or fully visual
- concise step-by-step result summary
- pass/fail conclusion
- screenshot evidence with absolute file paths
If the client supports local image rendering, attach the key screenshots as images in the final message. Otherwise, list the absolute paths clearly so the user can open them.
## Failure handling
- If doctor, typecheck, build, install, or server startup fails, stop and report the exact failing command.
- If `local` mode cannot reach the local server, do not silently fall back to `prod`.
- If the visual flow cannot be completed because the environment lacks the required interaction tooling, report that limitation clearly and still return the screenshots you captured.

View File

@ -28,7 +28,7 @@ concurrency:
jobs:
build:
name: Build Android apk for device
if: github.secret_source != 'None' && (github.event_name != 'push' || !startsWith(github.event.head_commit.message || '', 'release(mobile):'))
if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(mobile):'))
runs-on: ubuntu-latest
steps:
@ -78,7 +78,7 @@ jobs:
- name: 📤 Upload apk Artifact
if: github.event.inputs.profile != 'production'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: app-android
path: ${{ github.workspace }}/build.apk
@ -86,7 +86,7 @@ jobs:
- name: 📤 Upload aab Artifact
if: github.event.inputs.profile == 'production'
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: aab-android
path: ${{ github.workspace }}/build.aab

View File

@ -23,7 +23,7 @@ on:
# https://docs.github.com/en/enterprise-cloud@latest/actions/writing-workflows/choosing-what-your-workflow-does/control-the-concurrency-of-workflows-and-jobs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-build
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.tag_version == 'true' && 'tag-version' || github.event.inputs.store == 'true' && 'store' || 'manual') || 'build' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
env:
VITE_WEB_URL: ${{ vars.VITE_WEB_URL }}
@ -37,7 +37,7 @@ env:
jobs:
release:
if: github.secret_source != 'None' && (github.event_name != 'push' || !startsWith(github.event.head_commit.message || '', 'release(desktop):'))
if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(desktop):'))
runs-on: ${{ matrix.os }}
env:
PROD: ${{ github.event.inputs.tag_version == 'true' || github.ref_type == 'tag' || github.event.inputs.store == 'true' }}
@ -184,7 +184,7 @@ jobs:
run: pnpm build:render
- name: Upload file (macos-arm64-dmg)
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: runner.os == 'macOS'
with:
name: macos-arm64-dmg
@ -194,7 +194,7 @@ jobs:
retention-days: 90
- name: Upload file (macos-x64-dmg)
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: runner.os == 'macOS'
with:
name: macos-x64-dmg
@ -204,7 +204,7 @@ jobs:
retention-days: 90
- name: Upload file (macos-mas-pkg)
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: runner.os == 'macOS'
with:
name: macos-mas-pkg
@ -213,9 +213,9 @@ jobs:
retention-days: 90
- name: Upload file (windows-x64-exe unsigned)
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
id: upload-unsigned-windows-x64-exe
if: runner.os == 'windows'
if: runner.os == 'windows' && github.event.inputs.store != 'true'
with:
name: windows-x64-exe
path: |
@ -225,7 +225,7 @@ jobs:
- uses: signpath/github-action-submit-signing-request@v2.0
continue-on-error: true
if: runner.os == 'windows' && env.RELEASE == 'true'
if: runner.os == 'windows' && env.RELEASE == 'true' && github.event.inputs.store != 'true'
with:
api-token: "${{ secrets.SIGNPATH_API_TOKEN }}"
organization-id: "8c651516-fdaf-40a1-9fea-001dffde850e"
@ -236,12 +236,12 @@ jobs:
output-artifact-directory: "apps/desktop/out/make/"
- name: Update latest.yml
if: runner.os == 'windows' && env.RELEASE == 'true'
if: runner.os == 'windows' && env.RELEASE == 'true' && github.event.inputs.store != 'true'
run: npx tsx apps/desktop/scripts/update-windows-yml.ts
- name: Upload file (windows-x64-exe signed)
uses: actions/upload-artifact@v6
if: runner.os == 'windows' && env.RELEASE == 'true'
uses: actions/upload-artifact@v7
if: runner.os == 'windows' && env.RELEASE == 'true' && github.event.inputs.store != 'true'
with:
name: windows-x64-exe
path: |
@ -251,7 +251,7 @@ jobs:
overwrite: true
- name: Upload file (windows-x64-appx)
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: runner.os == 'windows'
with:
name: windows-x64-appx
@ -260,7 +260,7 @@ jobs:
retention-days: 90
- name: Upload file (linux-x64-appimage)
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: runner.os == 'linux'
with:
name: linux-x64-appimage
@ -272,7 +272,7 @@ jobs:
- name: Generate artifact attestation
if: env.RELEASE == 'true'
continue-on-error: true
uses: actions/attest-build-provenance@v3
uses: actions/attest-build-provenance@v4
with:
subject-path: |
apps/desktop/out/make/**/Folo-*.dmg
@ -286,6 +286,8 @@ jobs:
- run: npx changelogithub
if: env.RELEASE == 'true'
continue-on-error: true
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Setup Version
if: env.RELEASE == 'true'
@ -297,6 +299,7 @@ jobs:
- name: Prepare Release Notes
if: env.RELEASE == 'true'
id: release_notes
shell: bash
run: |
version="${{ steps.version.outputs.APP_VERSION }}"
changelog_file="apps/desktop/changelog/${version}.md"

View File

@ -60,7 +60,7 @@ jobs:
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: app-ios-development-device
path: apps/mobile/build.ipa
@ -109,7 +109,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: app-ios-development-device
path: apps/mobile/build.ipa
@ -153,7 +153,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: app-ios-development-simulator
path: apps/mobile/build-simulator.ipa

View File

@ -23,7 +23,7 @@ concurrency:
jobs:
check-runner:
if: github.secret_source != 'None' && (github.event_name != 'push' || !startsWith(github.event.head_commit.message || '', 'release(mobile):'))
if: github.secret_source != 'None' && (github.event_name != 'push' || !contains(github.event.head_commit.message || '', 'release(mobile):'))
runs-on: ubuntu-latest
outputs:
runner-label: ${{ steps.set-runner.outputs.runner-label }}
@ -68,7 +68,7 @@ jobs:
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: app-ios
path: apps/mobile/build.ipa
@ -122,7 +122,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.RN_SENTRY_AUTH_TOKEN }}
# Optional: Upload artifact
- name: 📤 Upload IPA
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: app-ios
path: apps/mobile/build.ipa

91
.github/workflows/deploy-cloudflare.yml vendored Normal file
View File

@ -0,0 +1,91 @@
on:
push:
branches: [main, dev]
name: ☁️ Deploy to Cloudflare Workers
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Build & Deploy
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [lts/*]
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
lfs: true
- name: Checkout LFS objects
run: git lfs checkout
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build desktop web (SPA)
run: pnpm exec turbo run Folo#build:web
- name: Build SSR Worker
working-directory: apps/ssr
run: pnpm run build:worker
- name: Copy WASM file
run: cp node_modules/@resvg/resvg-wasm/index_bg.wasm apps/ssr/dist/worker/resvg.wasm
- name: Build Landing Worker
run: pnpm exec turbo run @follow/landing#cf:build
- name: Deploy to Cloudflare (dev)
if: github.ref == 'refs/heads/dev'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/ssr
command: deploy --env dev
- name: Deploy Landing to Cloudflare (dev)
if: github.ref == 'refs/heads/dev'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/landing
command: deploy --env dev --name landing-next-dev --routes landing.dev.folo.is/*
- name: Deploy to Cloudflare (prod)
if: github.ref == 'refs/heads/main'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/ssr
command: deploy
- name: Deploy Landing to Cloudflare (prod)
if: github.ref == 'refs/heads/main'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/landing
command: deploy

View File

@ -13,8 +13,8 @@ jobs:
sync-to-dev:
runs-on: ubuntu-latest
if: |
(github.ref == 'refs/heads/main' && startsWith(github.event.head_commit.message, 'release(desktop):')) ||
(github.ref == 'refs/heads/mobile-main' && startsWith(github.event.head_commit.message, 'release(mobile):'))
(github.ref == 'refs/heads/main' && contains(github.event.head_commit.message || '', 'release(desktop):')) ||
(github.ref == 'refs/heads/mobile-main' && contains(github.event.head_commit.message || '', 'release(mobile):'))
steps:
- name: Checkout repository
uses: actions/checkout@v6

View File

@ -110,7 +110,7 @@ jobs:
echo "build_version=${next_build}" >> "$GITHUB_OUTPUT"
echo "Desktop build version: ${next_build}"
- name: Trigger Desktop Build
- name: Trigger Desktop Tag Version Build
if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main'
uses: actions/github-script@v8
with:
@ -123,11 +123,29 @@ jobs:
ref: 'main',
inputs: {
tag_version: 'true',
store: 'false'
}
});
console.log('Desktop Tag Version build triggered successfully');
- name: Trigger Desktop Store Build
if: needs.create_tag.outputs.platform == 'desktop' && needs.create_tag.outputs.ref_name == 'main'
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const response = await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'build-desktop.yml',
ref: 'main',
inputs: {
tag_version: 'false',
store: 'true',
build_version: '${{ steps.desktop_build_version.outputs.build_version }}'
}
});
console.log('Desktop build triggered successfully');
console.log('Desktop store build triggered successfully');
- name: Trigger Mobile Preview Release Build
if: needs.create_tag.outputs.platform == 'mobile' && needs.create_tag.outputs.ref_name == 'mobile-main'

19
.gitignore vendored
View File

@ -1,6 +1,9 @@
node_modules
dist
out
.next
.open-next
next-env.d.ts
.DS_Store
*.log*
.env
@ -26,6 +29,22 @@ buildServer.json
**/**/generated-routes.ts
apps/desktop/build/appxmanifest.xml
apps/desktop/resources/cli
.claude/settings.local.json
.serena
.wrangler
# Local agent artifacts
.codex/
# E2E outputs
/apps/desktop/e2e/playwright-report/
/apps/desktop/e2e/test-results/
/apps/mobile/e2e/artifacts/
/apps/mobile/report.xml
/report.xml
# Mobile local E2E build artifacts
apps/mobile/build-*.tar.gz

View File

@ -1,6 +1,7 @@
pnpm-lock.yaml
CHANGELOG.md
.context
apps/external/postcss.config.cjs

33
apps/cli/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "@follow/cli",
"type": "module",
"version": "0.1.0",
"private": true,
"description": "Folo CLI for AI agents and power users",
"bin": {
"folo": "./dist/index.js"
},
"files": [
"dist",
"skill.md"
],
"scripts": {
"build": "tsup --config tsup.config.ts && chmod +x dist/index.js",
"dev": "tsx src/index.ts",
"start": "node dist/index.js",
"test": "pnpm run build && vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@follow-app/client-sdk": "catalog:",
"commander": "14.0.1",
"pathe": "2.0.3"
},
"devDependencies": {
"@follow/configs": "workspace:*",
"@types/node": "25.2.3",
"tsup": "8.5.0",
"tsx": "4.21.0",
"typescript": "catalog:"
}
}

174
apps/cli/skill.md Normal file
View File

@ -0,0 +1,174 @@
# Folo CLI Skill
## Trigger Conditions
Use this skill when a user asks to:
- Manage RSS subscriptions
- Browse timeline entries
- Read entry details or readability content
- Mark entries as read/unread
- Search feeds/lists or trending sources
- Import/export OPML
- Check unread counts
## Preconditions
1. Folo CLI is installed and executable as `folo`.
2. Authentication is configured:
- `folo auth login` (recommended, opens browser and auto-logins)
- or `folo auth login --token <session-token>`
- or set `FOLO_TOKEN=<token>`
## Output Contract
Default output is JSON with a stable envelope:
```json
{
"ok": true,
"data": {},
"error": null
}
```
Errors return:
```json
{
"ok": false,
"data": null,
"error": {
"code": "UNAUTHORIZED",
"message": "Token is invalid or expired."
}
}
```
You can switch output mode:
- `--format json` (default)
- `--format table`
- `--format plain`
## Core Workflows
### 1. Timeline Reading
1. Fetch timeline:
- `folo timeline --limit 10`
2. Get entry detail:
- `folo entry get <entryId>`
3. Get readability content:
- `folo entry read <entryId>`
### 2. Subscription Management
1. Discover:
- `folo search discover <keyword>`
2. Add subscription:
- `folo subscription add --feed <url>`
- or `folo subscription add --list <listId>`
3. List subscriptions:
- `folo subscription list`
### 3. Unread Processing
1. Check unread total:
- `folo unread count`
2. List unread subscriptions:
- `folo unread list`
3. Read unread entries:
- `folo timeline --unread-only --limit 20`
4. Mark read:
- `folo entry mark-read <entryId>`
- or batch: `folo entry mark-all-read --view articles`
### 4. Collection Operations
- Add: `folo collection add <entryId>`
- Remove: `folo collection remove <entryId>`
- List: `folo collection list --limit 20`
### 5. OPML Import / Export
- Export:
- `folo opml export --output backup.opml`
- Import:
- `folo opml import feeds.opml`
## Pagination Pattern
`folo timeline` returns:
- `entries`
- `nextCursor`
- `hasNext`
Loop until `hasNext` is `false`:
1. `folo timeline --limit 20`
2. Read `nextCursor`
3. `folo timeline --limit 20 --cursor <nextCursor>`
4. Repeat
## Command Reference
- `folo auth login [--timeout <seconds>] [--token <token>]`
- `folo auth logout`
- `folo auth whoami`
- `folo timeline [--view <type>] [--limit <n>] [--unread-only] [--cursor <datetime>]`
- `folo timeline --feed <feedId> [--limit <n>] [--cursor <datetime>]`
- `folo timeline --list <listId> [--limit <n>] [--cursor <datetime>]`
- `folo timeline --category <name> [--view <type>] [--limit <n>]`
- `folo subscription list [--view <type>] [--category <name>]`
- `folo subscription add --feed <url> [--category <name>] [--view <type>] [--private]`
- `folo subscription add --list <listId> [--category <name>] [--view <type>]`
- `folo subscription remove <id> [--target feed|list|url]`
- `folo subscription update <id> [--target feed|list] [--category <name>] [--title <title>] [--view <type>] [--private|--public]`
- `folo entry get <entryId>`
- `folo entry read <entryId>`
- `folo entry mark-read <entryId>`
- `folo entry mark-unread <entryId>`
- `folo entry mark-all-read [--feed <feedId>] [--list <listId>] [--view <type>]`
- `folo feed get <feedId|feedUrl>`
- `folo feed refresh <feedId>`
- `folo feed analytics <feedId>`
- `folo list ls`
- `folo list get <listId>`
- `folo list create --title <title> [--description <desc>] [--view <type>] [--fee <n>]`
- `folo list update <listId> [--title <title>] [--description <desc>] [--view <type>] [--fee <n>]`
- `folo list delete <listId>`
- `folo list add-feed <listId> --feed <feedId>`
- `folo list remove-feed <listId> --feed <feedId>`
- `folo search discover <keyword> [--type feeds|lists]`
- `folo search rsshub <keyword> [--lang <lang>]`
- `folo search trending [--range 1d|3d|7d|30d] [--view <type>] [--limit <n>] [--language eng|cmn] [--category <keyword>]`
- `folo collection list [--limit <n>] [--cursor <datetime>]`
- `folo collection add <entryId> [--view <type>]`
- `folo collection remove <entryId>`
- `folo opml export [--output <file>]`
- `folo opml import <file> [--items <url1,url2,...>]`
- `folo unread count`
- `folo unread list [--view <type>]`
## Error Recovery
- `UNAUTHORIZED`
- Re-login: `folo auth login`
- or `folo auth login --token <token>`
- Or set `FOLO_TOKEN`
- `HTTP_4xx` / `HTTP_5xx`
- Retry with `--verbose` for request details
- Verify `--api-url` if using non-default endpoint
- `INVALID_ARGUMENT`
- Run `folo <command> --help` to inspect accepted options

61
apps/cli/src/args.test.ts Normal file
View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest"
import { parseFormat, parseISODate, parseNonNegativeInt, parsePositiveInt, parseView } from "./args"
describe("args parsers", () => {
it("parses named view values", () => {
expect(parseView("articles")).toBe(0)
expect(parseView("social")).toBe(1)
expect(parseView("pictures")).toBe(2)
expect(parseView("videos")).toBe(3)
expect(parseView("audio")).toBe(4)
expect(parseView("notifications")).toBe(5)
})
it("parses numeric view values", () => {
expect(parseView("0")).toBe(0)
expect(parseView("5")).toBe(5)
})
it("throws for invalid view values", () => {
expect(() => parseView("foo")).toThrowError(/Invalid view/)
expect(() => parseView("6")).toThrowError(/Invalid view/)
})
it("parses positive integers", () => {
expect(parsePositiveInt("1")).toBe(1)
expect(parsePositiveInt("99")).toBe(99)
})
it("throws for non-positive integers", () => {
expect(() => parsePositiveInt("0")).toThrowError(/positive integer/)
expect(() => parsePositiveInt("-1")).toThrowError(/positive integer/)
})
it("parses non-negative integers", () => {
expect(parseNonNegativeInt("0")).toBe(0)
expect(parseNonNegativeInt("3")).toBe(3)
})
it("throws for negative integers", () => {
expect(() => parseNonNegativeInt("-1")).toThrowError(/non-negative integer/)
})
it("parses ISO datetime", () => {
expect(parseISODate("2026-02-25T10:30:00Z")).toBe("2026-02-25T10:30:00.000Z")
})
it("throws for invalid datetime", () => {
expect(() => parseISODate("not-a-date")).toThrowError(/Invalid datetime/)
})
it("parses output format", () => {
expect(parseFormat("json")).toBe("json")
expect(parseFormat("table")).toBe("table")
expect(parseFormat("plain")).toBe("plain")
})
it("throws for invalid output format", () => {
expect(() => parseFormat("yaml")).toThrowError(/Invalid format/)
})
})

66
apps/cli/src/args.ts Normal file
View File

@ -0,0 +1,66 @@
import type { OutputFormat } from "./output"
const viewMap: Readonly<Record<string, number>> = {
article: 0,
articles: 0,
social: 1,
socialmedia: 1,
picture: 2,
pictures: 2,
video: 3,
videos: 3,
audio: 4,
notification: 5,
notifications: 5,
}
export const viewHelp =
"articles(0) | social(1) | pictures(2) | videos(3) | audio(4) | notifications(5)"
export const parseView = (value: string): number => {
const normalized = value.trim().toLowerCase()
if (/^\d+$/.test(normalized)) {
const parsed = Number.parseInt(normalized, 10)
if (parsed >= 0 && parsed <= 5) {
return parsed
}
}
const mapped = viewMap[normalized]
if (mapped !== undefined) {
return mapped
}
throw new Error(`Invalid view "${value}". Use ${viewHelp}.`)
}
export const parsePositiveInt = (value: string): number => {
const parsed = Number.parseInt(value, 10)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Expected a positive integer, got "${value}".`)
}
return parsed
}
export const parseNonNegativeInt = (value: string): number => {
const parsed = Number.parseInt(value, 10)
if (!Number.isInteger(parsed) || parsed < 0) {
throw new Error(`Expected a non-negative integer, got "${value}".`)
}
return parsed
}
export const parseISODate = (value: string): string => {
const timestamp = Date.parse(value)
if (Number.isNaN(timestamp)) {
throw new TypeError(`Invalid datetime value "${value}".`)
}
return new Date(timestamp).toISOString()
}
export const parseFormat = (value: string): OutputFormat => {
if (value === "json" || value === "table" || value === "plain") {
return value
}
throw new Error(`Invalid format "${value}". Use json, table, or plain.`)
}

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest"
import { DEFAULT_VALUES } from "../../../packages/internal/shared/src/env.common"
import { resolveCLILoginUrl } from "./browser-login"
describe("browser login helpers", () => {
it("maps production API URL using env.common", () => {
const url = resolveCLILoginUrl(DEFAULT_VALUES.PROD.API_URL, "http://127.0.0.1:12345/callback")
const parsed = new URL(url)
expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.PROD.WEB_URL).origin)
expect(parsed.pathname).toBe("/login")
expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback")
})
it("maps dev API URL using env.common", () => {
const url = resolveCLILoginUrl(DEFAULT_VALUES.DEV.API_URL, "http://127.0.0.1:12345/callback")
const parsed = new URL(url)
expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.DEV.WEB_URL).origin)
expect(parsed.pathname).toBe("/login")
expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback")
})
it("maps local API URL using env.common", () => {
const url = resolveCLILoginUrl(DEFAULT_VALUES.LOCAL.API_URL, "http://127.0.0.1:12345/callback")
const parsed = new URL(url)
expect(parsed.origin).toBe(new URL(DEFAULT_VALUES.LOCAL.WEB_URL).origin)
expect(parsed.pathname).toBe("/login")
expect(parsed.searchParams.get("cli_callback")).toBe("http://127.0.0.1:12345/callback")
})
it("falls back to API origin when no mapping exists", () => {
const url = resolveCLILoginUrl("https://api.follow.is", "http://localhost:3456/callback")
const parsed = new URL(url)
expect(parsed.origin).toBe("https://api.follow.is")
expect(parsed.pathname).toBe("/login")
expect(parsed.searchParams.get("cli_callback")).toBe("http://localhost:3456/callback")
})
it("throws for invalid api url", () => {
expect(() => resolveCLILoginUrl("not-a-url", "http://127.0.0.1:3333/callback")).toThrowError(
/Invalid API URL/,
)
})
})

View File

@ -0,0 +1,222 @@
import { spawnSync } from "node:child_process"
import { createServer } from "node:http"
import type { AddressInfo } from "node:net"
import { DEFAULT_VALUES } from "../../../packages/internal/shared/src/env.common"
import { CLIError } from "./output"
const LOCAL_CALLBACK_HOST = "127.0.0.1"
const LOCAL_CALLBACK_PATH = "/callback"
const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000
const mappedWebOrigins: Array<{ apiOrigin: string; webOrigin: string }> = [
{
apiOrigin: new URL(DEFAULT_VALUES.PROD.API_URL).origin,
webOrigin: new URL(DEFAULT_VALUES.PROD.WEB_URL).origin,
},
{
apiOrigin: new URL(DEFAULT_VALUES.DEV.API_URL).origin,
webOrigin: new URL(DEFAULT_VALUES.DEV.WEB_URL).origin,
},
{
apiOrigin: new URL(DEFAULT_VALUES.LOCAL.API_URL).origin,
webOrigin: new URL(DEFAULT_VALUES.LOCAL.WEB_URL).origin,
},
]
const successPageHtml = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Folo CLI Login</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; line-height: 1.5;">
<h1>Folo CLI login complete</h1>
<p>You can close this window and return to your terminal.</p>
</body>
</html>
`
const failurePageHtml = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Folo CLI Login</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; line-height: 1.5;">
<h1>Folo CLI login failed</h1>
<p>Missing token in callback URL. Please retry in terminal.</p>
</body>
</html>
`
const getOpenBrowserCommand = (url: string): { command: string; args: string[] } => {
if (process.platform === "darwin") {
return { command: "open", args: [url] }
}
if (process.platform === "win32") {
return { command: "cmd", args: ["/c", "start", "", url] }
}
return { command: "xdg-open", args: [url] }
}
const openBrowser = (url: string) => {
const { command, args } = getOpenBrowserCommand(url)
const result = spawnSync(command, args, {
stdio: "ignore",
})
if (result.error || result.status !== 0) {
throw new CLIError(
"BROWSER_OPEN_FAILED",
`Failed to open browser automatically. Open this URL manually: ${url}`,
)
}
}
export const resolveCLILoginUrl = (apiUrl: string, callbackUrl: string): string => {
let api: URL
try {
api = new URL(apiUrl)
} catch {
throw new CLIError("INVALID_ARGUMENT", `Invalid API URL: ${apiUrl}`)
}
const mappedWebOrigin = mappedWebOrigins.find((item) => item.apiOrigin === api.origin)?.webOrigin
const webUrl = new URL(mappedWebOrigin ?? api.origin)
webUrl.pathname = "/login"
webUrl.search = ""
webUrl.hash = ""
webUrl.searchParams.set("cli_callback", callbackUrl)
return webUrl.toString()
}
export interface BrowserLoginOptions {
apiUrl: string
timeoutMs?: number
onStatus?: (message: string) => void
}
export interface BrowserLoginResult {
token: string
callbackUrl: string
loginUrl: string
}
export const loginWithBrowser = async (
options: BrowserLoginOptions,
): Promise<BrowserLoginResult> => {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const onStatus = options.onStatus ?? (() => {})
if (timeoutMs <= 0) {
throw new CLIError("INVALID_ARGUMENT", "Browser login timeout must be greater than 0.")
}
const result = await new Promise<BrowserLoginResult>((resolve, reject) => {
let settled = false
let timer: NodeJS.Timeout | undefined
const settle = (handler: () => void) => {
if (settled) {
return
}
settled = true
if (timer) {
clearTimeout(timer)
}
server.close(() => {
handler()
})
}
const server = createServer((req, res) => {
const requestUrl = new URL(
req.url ?? "/",
`http://${req.headers.host ?? LOCAL_CALLBACK_HOST}`,
)
if (requestUrl.pathname !== LOCAL_CALLBACK_PATH) {
res.statusCode = 404
res.end("Not Found")
return
}
const token = requestUrl.searchParams.get("token")
if (!token) {
res.statusCode = 400
res.setHeader("content-type", "text/html; charset=utf-8")
res.end(failurePageHtml)
return
}
res.statusCode = 200
res.setHeader("content-type", "text/html; charset=utf-8")
res.end(successPageHtml)
const callbackAddress = server.address() as AddressInfo | null
const callbackUrl = callbackAddress
? `http://${LOCAL_CALLBACK_HOST}:${callbackAddress.port}${LOCAL_CALLBACK_PATH}`
: ""
const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl)
settle(() => {
resolve({
token,
callbackUrl,
loginUrl,
})
})
})
server.once("error", (error) => {
settle(() => {
reject(
new CLIError("NETWORK_ERROR", `Failed to start local callback server: ${error.message}`),
)
})
})
server.listen(0, LOCAL_CALLBACK_HOST, () => {
const address = server.address() as AddressInfo | null
if (!address) {
settle(() => {
reject(new CLIError("NETWORK_ERROR", "Failed to bind local callback server."))
})
return
}
const callbackUrl = `http://${LOCAL_CALLBACK_HOST}:${address.port}${LOCAL_CALLBACK_PATH}`
const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl)
onStatus(`Open this URL to sign in: ${loginUrl}`)
try {
openBrowser(loginUrl)
onStatus("Browser opened. Waiting for login confirmation...")
} catch (error) {
onStatus((error as Error).message)
onStatus("Waiting for login confirmation...")
}
timer = setTimeout(() => {
settle(() => {
reject(
new CLIError(
"TIMEOUT",
"Timed out waiting for browser login. Please run `folo auth login` again.",
),
)
})
}, timeoutMs)
})
})
return result
}

View File

@ -0,0 +1,101 @@
import type { ExecFileException } from "node:child_process"
import { execFile } from "node:child_process"
import { mkdtempSync } from "node:fs"
import { tmpdir } from "node:os"
import { promisify } from "node:util"
import { resolve } from "pathe"
import { describe, expect, it } from "vitest"
const execFileAsync = promisify(execFile)
const cliPath = resolve(process.cwd(), "dist/index.js")
const testToken = process.env.FOLO_TEST_TOKEN
const isolatedHome = mkdtempSync(resolve(tmpdir(), "folo-cli-test-"))
type CLIExecution = {
code: number
stdout: string
stderr: string
}
const runCLI = async (args: string[]): Promise<CLIExecution> => {
try {
const { stdout, stderr } = await execFileAsync("node", [cliPath, ...args], {
env: {
...process.env,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
FOLO_TOKEN: "",
},
})
return {
code: 0,
stdout,
stderr,
}
} catch (error) {
const execError = error as ExecFileException & {
stdout?: string
stderr?: string
}
return {
code: typeof execError.code === "number" ? execError.code : 1,
stdout: execError.stdout ?? "",
stderr: execError.stderr ?? "",
}
}
}
describe("cli e2e", () => {
it("returns structured unauthorized error without token", async () => {
const result = await runCLI(["timeline", "--limit", "1"])
expect(result.code).not.toBe(0)
const payload = JSON.parse(result.stderr) as {
ok: boolean
data: null
error: { code: string; message: string }
}
expect(payload.ok).toBe(false)
expect(payload.data).toBeNull()
expect(payload.error.code).toBe("UNAUTHORIZED")
})
it.runIf(Boolean(testToken))("can fetch session with test token", async () => {
const result = await runCLI(["--token", testToken!, "auth", "whoami"])
expect(result.code).toBe(0)
const payload = JSON.parse(result.stdout) as {
ok: boolean
data: {
user: { id: string }
session: { id: string }
}
error: null
}
expect(payload.ok).toBe(true)
expect(payload.error).toBeNull()
expect(typeof payload.data.user.id).toBe("string")
expect(typeof payload.data.session.id).toBe("string")
})
it.runIf(Boolean(testToken))("can fetch timeline with test token", async () => {
const result = await runCLI(["--token", testToken!, "timeline", "--limit", "1"])
expect(result.code).toBe(0)
const payload = JSON.parse(result.stdout) as {
ok: boolean
data: {
entries: unknown[]
nextCursor: string | null
hasNext: boolean
}
error: null
}
expect(payload.ok).toBe(true)
expect(Array.isArray(payload.data.entries)).toBe(true)
expect(typeof payload.data.hasNext).toBe("boolean")
})
})

113
apps/cli/src/client.ts Normal file
View File

@ -0,0 +1,113 @@
import { FollowClient } from "@follow-app/client-sdk"
import type { Command } from "commander"
import type { FoloCLIConfig } from "./config"
import { readConfig } from "./config"
import type { OutputFormat } from "./output"
import { CLIError } from "./output"
export const defaultApiURL = "https://api.folo.is"
const readString = (value: unknown): string | undefined => {
return typeof value === "string" && value.length > 0 ? value : undefined
}
const normalizeToken = (token: string | undefined) => {
if (!token || !token.includes("%")) {
return token
}
try {
return decodeURIComponent(token)
} catch {
return token
}
}
export interface GlobalOptions {
format: OutputFormat
apiUrl?: string
token?: string
verbose: boolean
}
export interface ResolvedGlobalOptions extends GlobalOptions {
apiUrl: string
}
export interface CommandContext {
client: FollowClient
options: ResolvedGlobalOptions
config: FoloCLIConfig
token?: string
}
export const getGlobalOptions = (command: Command): GlobalOptions => {
const options = command.optsWithGlobals() as Record<string, unknown>
return {
format:
options.format === "table" || options.format === "plain" || options.format === "json"
? options.format
: "json",
apiUrl: readString(options.apiUrl),
token: readString(options.token),
verbose: Boolean(options.verbose),
}
}
const setupVerboseLogging = (client: FollowClient) => {
client.addRequestInterceptor((ctx) => {
const method = ctx.options.method || "GET"
console.error(`[request] ${method} ${ctx.url}`)
return ctx
})
client.addResponseInterceptor((ctx) => {
const method = ctx.options.method || "GET"
console.error(`[response] ${method} ${ctx.url} -> ${ctx.response.status}`)
return ctx.response
})
}
export const createCommandContext = async (
command: Command,
requireAuth = true,
): Promise<CommandContext> => {
const globalOptions = getGlobalOptions(command)
const config = await readConfig()
const token = normalizeToken(globalOptions.token ?? process.env.FOLO_TOKEN ?? config.token)
const apiUrl = globalOptions.apiUrl ?? config.apiUrl ?? defaultApiURL
if (requireAuth && !token) {
throw new CLIError(
"UNAUTHORIZED",
"Missing token. Run `folo auth login` (browser sign-in) or set FOLO_TOKEN.",
)
}
const client = new FollowClient({
baseURL: apiUrl,
})
if (token) {
client.setAuthToken(token)
client.setHeaders({
Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`,
})
}
if (globalOptions.verbose) {
setupVerboseLogging(client)
}
return {
client,
token,
config,
options: {
...globalOptions,
apiUrl,
},
}
}

30
apps/cli/src/command.ts Normal file
View File

@ -0,0 +1,30 @@
import type { Command } from "commander"
import type { CommandContext } from "./client"
import { createCommandContext, getGlobalOptions } from "./client"
import { normalizeError, printFailure, printSuccess } from "./output"
interface RunCommandOptions {
requireAuth?: boolean
}
export const runCommand = async (
command: Command,
handler: (context: CommandContext) => Promise<unknown>,
options: RunCommandOptions = {},
) => {
const fallbackOptions = getGlobalOptions(command)
const requireAuth = options.requireAuth ?? true
let context: CommandContext | null = null
try {
context = await createCommandContext(command, requireAuth)
const result = await handler(context)
printSuccess(context.options.format, result)
} catch (error) {
const format = context?.options.format ?? fallbackOptions.format
const verbose = context?.options.verbose ?? fallbackOptions.verbose
printFailure(format, normalizeError(error, verbose))
process.exitCode = 1
}
}

View File

@ -0,0 +1,103 @@
import type { Command } from "commander"
import { parsePositiveInt } from "../args"
import { loginWithBrowser } from "../browser-login"
import { getGlobalOptions } from "../client"
import { runCommand } from "../command"
import { clearToken, getConfigPath, updateConfig } from "../config"
import { CLIError } from "../output"
interface AuthLoginOptions {
token?: string
timeout?: number
}
export const registerAuthCommand = (program: Command) => {
const authCommand = program.command("auth").description("Authentication commands")
authCommand
.command("login")
.description("Sign in via browser (or save a provided token) and verify authentication")
.option("--token <token>", "Session token from Folo")
.option(
"--timeout <seconds>",
"Browser login timeout in seconds (default: 180)",
parsePositiveInt,
)
.action(async function (this: Command, options: AuthLoginOptions) {
await runCommand(
this,
async ({ client, options: globalOptions }) => {
let token = options.token ?? getGlobalOptions(this).token
if (!token) {
const timeoutMs = (options.timeout ?? 180) * 1000
const browserLogin = await loginWithBrowser({
apiUrl: globalOptions.apiUrl,
timeoutMs,
onStatus: (message) => {
console.error(`[auth] ${message}`)
},
})
token = browserLogin.token
}
client.setAuthToken(token)
const session = await client.api.auth.getSession()
if (!session.user || !session.session) {
throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.")
}
await updateConfig({
token,
apiUrl: globalOptions.apiUrl,
})
return {
message: "Login successful.",
configPath: getConfigPath(),
user: session.user,
}
},
{ requireAuth: false },
)
})
authCommand
.command("logout")
.description("Clear stored token")
.action(async function (this: Command) {
await runCommand(
this,
async () => {
await clearToken()
return {
message: "Logged out.",
configPath: getConfigPath(),
}
},
{ requireAuth: false },
)
})
authCommand
.command("whoami")
.description("Show current session user")
.action(async function (this: Command) {
await runCommand(this, async ({ client }) => {
const session = await client.api.auth.getSession()
if (!session.user || !session.session) {
throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.")
}
return {
user: session.user,
session: session.session,
role: session.role,
roleEndAt: session.roleEndAt ?? null,
feedSubscriptionLimit: session.feedSubscriptionLimit,
rsshubSubscriptionLimit: session.rsshubSubscriptionLimit,
}
})
})
}

View File

@ -0,0 +1,71 @@
import type { EntryListRequest } from "@follow-app/client-sdk"
import type { Command } from "commander"
import { parseISODate, parsePositiveInt, parseView, viewHelp } from "../args"
import { runCommand } from "../command"
interface CollectionListOptions {
limit: number
cursor?: string
}
interface CollectionAddOptions {
view?: number
}
export const registerCollectionCommand = (program: Command) => {
const collectionCommand = program.command("collection").description("Manage collections")
collectionCommand
.command("list")
.description("List collected entries")
.option("--limit <n>", "Number of entries to fetch", parsePositiveInt, 20)
.option("--cursor <datetime>", "Pagination cursor", parseISODate)
.action(async function (this: Command, options: CollectionListOptions) {
await runCommand(this, async ({ client }) => {
const request: EntryListRequest = {
isCollection: true,
limit: options.limit,
publishedAfter: options.cursor,
}
const response = await client.api.entries.list(request)
const entries = response.data
const nextCursor = entries.at(-1)?.entries.publishedAt ?? null
return {
entries,
nextCursor,
hasNext: Boolean(nextCursor) && entries.length >= options.limit,
}
})
})
collectionCommand
.command("add")
.description("Add entry to collection")
.argument("<entryId>", "Entry ID")
.option("--view <type>", `View type: ${viewHelp}`, parseView)
.action(async function (this: Command, entryId: string, options: CollectionAddOptions) {
await runCommand(this, async ({ client }) => {
const response = await client.api.collections.post({
entryId,
view: options.view,
})
return response.data
})
})
collectionCommand
.command("remove")
.description("Remove entry from collection")
.argument("<entryId>", "Entry ID")
.action(async function (this: Command, entryId: string) {
await runCommand(this, async ({ client }) => {
const response = await client.api.collections.delete({
entryId,
})
return response.data
})
})
}

View File

@ -0,0 +1,83 @@
import type { MarkAllAsReadRequest } from "@follow-app/client-sdk"
import type { Command } from "commander"
import { parseView, viewHelp } from "../args"
import { runCommand } from "../command"
import { CLIError } from "../output"
interface MarkAllReadOptions {
feed?: string
list?: string
view?: number
}
export const registerEntryCommand = (program: Command) => {
const entryCommand = program.command("entry").description("Read and update entries")
entryCommand
.command("get")
.description("Get entry detail")
.argument("<entryId>", "Entry ID")
.action(async function (this: Command, entryId: string) {
await runCommand(this, async ({ client }) => {
const response = await client.api.entries.get({ id: entryId })
return response.data
})
})
entryCommand
.command("read")
.description("Get readability content")
.argument("<entryId>", "Entry ID")
.action(async function (this: Command, entryId: string) {
await runCommand(this, async ({ client }) => {
const response = await client.api.entries.readability({ id: entryId })
return response.data
})
})
entryCommand
.command("mark-read")
.description("Mark entry as read")
.argument("<entryId>", "Entry ID")
.action(async function (this: Command, entryId: string) {
await runCommand(this, async ({ client }) => {
const response = await client.api.reads.markAsRead({ entryIds: [entryId] })
return response.data
})
})
entryCommand
.command("mark-unread")
.description("Mark entry as unread")
.argument("<entryId>", "Entry ID")
.action(async function (this: Command, entryId: string) {
await runCommand(this, async ({ client }) => {
const response = await client.api.reads.markAsUnread({ entryId })
return response.data
})
})
entryCommand
.command("mark-all-read")
.description("Mark entries as read in current scope")
.option("--feed <feedId>", "Mark all entries in a feed as read")
.option("--list <listId>", "Mark all entries in a list as read")
.option("--view <type>", `View type: ${viewHelp}`, parseView)
.action(async function (this: Command, options: MarkAllReadOptions) {
await runCommand(this, async ({ client }) => {
if (options.feed && options.list) {
throw new CLIError("INVALID_ARGUMENT", "Use only one of --feed or --list.")
}
const request: MarkAllAsReadRequest = {
feedId: options.feed,
listId: options.list,
view: options.view,
}
const response = await client.api.reads.markAllAsRead(request)
return response.data
})
})
}

View File

@ -0,0 +1,44 @@
import type { Command } from "commander"
import { runCommand } from "../command"
const isURL = (value: string) => value.startsWith("http://") || value.startsWith("https://")
export const registerFeedCommand = (program: Command) => {
const feedCommand = program.command("feed").description("Manage feed data")
feedCommand
.command("get")
.description("Get feed detail by feed ID or URL")
.argument("<feedIdOrUrl>", "Feed ID or URL")
.action(async function (this: Command, feedIdOrUrl: string) {
await runCommand(this, async ({ client }) => {
const response = await client.api.feeds.get(
isURL(feedIdOrUrl) ? { url: feedIdOrUrl } : { id: feedIdOrUrl },
)
return response.data
})
})
feedCommand
.command("refresh")
.description("Refresh feed")
.argument("<feedId>", "Feed ID")
.action(async function (this: Command, feedId: string) {
await runCommand(this, async ({ client }) => {
const response = await client.api.feeds.refresh({ id: feedId })
return response.data
})
})
feedCommand
.command("analytics")
.description("Get feed analytics")
.argument("<feedId>", "Feed ID")
.action(async function (this: Command, feedId: string) {
await runCommand(this, async ({ client }) => {
const response = await client.api.feeds.analytics({ id: [feedId] })
return response.data
})
})
}

View File

@ -0,0 +1,150 @@
import type { Command } from "commander"
import { parseNonNegativeInt, parseView, viewHelp } from "../args"
import { runCommand } from "../command"
import { CLIError } from "../output"
interface ListCreateOptions {
title: string
description?: string
view?: number
fee?: number
image?: string
}
interface ListUpdateOptions {
title?: string
description?: string
view?: number
fee?: number
image?: string
}
interface ListFeedOptions {
feed: string
}
export const registerListCommand = (program: Command) => {
const listCommand = program.command("list").description("Manage lists")
listCommand
.command("ls")
.description("List my lists")
.action(async function (this: Command) {
await runCommand(this, async ({ client }) => {
const response = await client.api.lists.list({})
return response.data
})
})
listCommand
.command("get")
.description("Get list detail")
.argument("<listId>", "List ID")
.action(async function (this: Command, listId: string) {
await runCommand(this, async ({ client }) => {
const response = await client.api.lists.get({ listId })
return response.data
})
})
listCommand
.command("create")
.description("Create a list")
.requiredOption("--title <title>", "List title")
.option("--description <description>", "List description")
.option("--view <type>", `View type: ${viewHelp}`, parseView)
.option("--fee <amount>", "List fee", parseNonNegativeInt, 0)
.option("--image <url>", "List image URL")
.action(async function (this: Command, options: ListCreateOptions) {
await runCommand(this, async ({ client }) => {
const response = await client.api.lists.create({
title: options.title,
description: options.description ?? null,
view: options.view ?? 0,
fee: options.fee ?? 0,
image: options.image ?? null,
})
return response.data
})
})
listCommand
.command("update")
.description("Update a list")
.argument("<listId>", "List ID")
.option("--title <title>", "List title")
.option("--description <description>", "List description")
.option("--view <type>", `View type: ${viewHelp}`, parseView)
.option("--fee <amount>", "List fee", parseNonNegativeInt)
.option("--image <url>", "List image URL")
.action(async function (this: Command, listId: string, options: ListUpdateOptions) {
await runCommand(this, async ({ client }) => {
if (
options.title === undefined &&
options.description === undefined &&
options.view === undefined &&
options.fee === undefined &&
options.image === undefined
) {
throw new CLIError(
"INVALID_ARGUMENT",
"No update fields provided. Use at least one of --title, --description, --view, --fee, --image.",
)
}
const response = await client.api.lists.update({
listId,
title: options.title,
description: options.description,
view: options.view,
fee: options.fee,
image: options.image,
})
return response.data
})
})
listCommand
.command("delete")
.description("Delete a list")
.argument("<listId>", "List ID")
.action(async function (this: Command, listId: string) {
await runCommand(this, async ({ client }) => {
const response = await client.api.lists.delete({ listId })
return response.data
})
})
listCommand
.command("add-feed")
.description("Add feed to a list")
.argument("<listId>", "List ID")
.requiredOption("--feed <feedId>", "Feed ID")
.action(async function (this: Command, listId: string, options: ListFeedOptions) {
await runCommand(this, async ({ client }) => {
const response = await client.api.lists.addFeeds({
listId,
feedId: options.feed,
})
return response.data
})
})
listCommand
.command("remove-feed")
.description("Remove feed from a list")
.argument("<listId>", "List ID")
.requiredOption("--feed <feedId>", "Feed ID")
.action(async function (this: Command, listId: string, options: ListFeedOptions) {
await runCommand(this, async ({ client }) => {
const response = await client.api.lists.removeFeed({
listId,
feedId: options.feed,
})
return response.data
})
})
}

View File

@ -0,0 +1,84 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"
import type { Command } from "commander"
import { basename, dirname } from "pathe"
import { runCommand } from "../command"
interface OpmlExportOptions {
output?: string
}
interface OpmlImportOptions {
items?: string
}
const parseItems = (value?: string): string[] => {
if (!value) {
return []
}
return value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
export const registerOPMLCommand = (program: Command) => {
const opmlCommand = program.command("opml").description("Import and export OPML")
opmlCommand
.command("export")
.description("Export subscriptions as OPML")
.option("--output <file>", "Write exported OPML to file")
.action(async function (this: Command, options: OpmlExportOptions) {
await runCommand(this, async ({ client }) => {
const response = await client.api.subscriptions.export({
format: "opml",
})
if (!options.output) {
return response
}
await mkdir(dirname(options.output), { recursive: true })
await writeFile(options.output, response.content, "utf8")
return {
output: options.output,
filename: response.filename,
contentType: response.contentType,
bytes: Buffer.byteLength(response.content),
}
})
})
opmlCommand
.command("import")
.description("Import subscriptions from OPML file")
.argument("<file>", "Path to OPML or XML file")
.option("--items <urls>", "Comma-separated feed URLs to import from parsed file")
.action(async function (this: Command, filePath: string, options: OpmlImportOptions) {
await runCommand(this, async ({ client }) => {
const fileBuffer = await readFile(filePath)
const fileName = basename(filePath)
const formData = new FormData()
formData.append(
"file",
new Blob([fileBuffer], {
type: "application/octet-stream",
}),
fileName,
)
const items = parseItems(options.items)
if (items.length > 0) {
formData.append("items", JSON.stringify(items))
}
const response = await client.api.subscriptions.import(formData)
return response.data
})
})
}

View File

@ -0,0 +1,111 @@
import type { Command } from "commander"
import { parsePositiveInt, parseView, viewHelp } from "../args"
import { runCommand } from "../command"
type DiscoverTarget = "feeds" | "lists"
type TrendingRange = "1d" | "3d" | "7d" | "30d"
type TrendingLanguage = "eng" | "cmn"
const parseDiscoverTarget = (value: string): DiscoverTarget => {
if (value === "feeds" || value === "lists") {
return value
}
throw new Error(`Invalid discover type "${value}". Use feeds or lists.`)
}
const parseTrendingRange = (value: string): TrendingRange => {
if (value === "1d" || value === "3d" || value === "7d" || value === "30d") {
return value
}
throw new Error(`Invalid range "${value}". Use one of 1d, 3d, 7d, 30d.`)
}
const parseTrendingLanguage = (value: string): TrendingLanguage => {
if (value === "eng" || value === "cmn") {
return value
}
throw new Error(`Invalid language "${value}". Use eng or cmn.`)
}
interface SearchDiscoverOptions {
type?: DiscoverTarget
}
interface SearchRsshubOptions {
lang?: string
}
interface SearchTrendingOptions {
category?: string
range?: TrendingRange
view?: number
limit?: number
language?: TrendingLanguage
}
export const registerSearchCommand = (program: Command) => {
const searchCommand = program.command("search").description("Discover feeds and lists")
searchCommand
.command("discover")
.description("Discover feeds or lists")
.argument("<keyword>", "Search keyword")
.option("--type <type>", "Discover target: feeds | lists", parseDiscoverTarget)
.action(async function (this: Command, keyword: string, options: SearchDiscoverOptions) {
await runCommand(this, async ({ client }) => {
const response = await client.api.discover.discover({
keyword,
target: options.type,
})
return response.data
})
})
searchCommand
.command("rsshub")
.description("Search RSSHub routes by category keyword")
.argument("<keyword>", "Category keyword")
.option("--lang <lang>", "Language tag")
.action(async function (this: Command, keyword: string, options: SearchRsshubOptions) {
await runCommand(this, async ({ client }) => {
const response = await client.api.discover.rsshub({
categories: keyword,
lang: options.lang,
})
return response.data
})
})
searchCommand
.command("trending")
.description("Get trending feeds")
.option("--category <category>", "Filter by category keyword in title/description")
.option("--range <range>", "Trending range: 1d | 3d | 7d | 30d", parseTrendingRange, "7d")
.option("--view <type>", `View type: ${viewHelp}`, parseView)
.option("--limit <n>", "Result limit", parsePositiveInt, 20)
.option("--language <lang>", "Language: eng | cmn", parseTrendingLanguage)
.action(async function (this: Command, options: SearchTrendingOptions) {
await runCommand(this, async ({ client }) => {
const response = await client.api.trending.getFeeds({
range: options.range,
view: options.view,
limit: options.limit,
language: options.language,
})
const keyword = options.category?.trim().toLowerCase()
const feeds = keyword
? response.data.filter((item) => {
const title = item.feed.title?.toLowerCase() || ""
const description = item.feed.description?.toLowerCase() || ""
return title.includes(keyword) || description.includes(keyword)
})
: response.data
return {
feeds,
}
})
})
}

View File

@ -0,0 +1,202 @@
import type {
SubscriptionCreateRequest,
SubscriptionDeleteRequest,
SubscriptionUpdateRequest,
} from "@follow-app/client-sdk"
import type { Command } from "commander"
import { parseView, viewHelp } from "../args"
import { runCommand } from "../command"
import { CLIError } from "../output"
type SubscriptionTarget = "feed" | "list" | "url"
type UpdateTarget = "feed" | "list"
const parseSubscriptionTarget = (value: string): SubscriptionTarget => {
if (value === "feed" || value === "list" || value === "url") {
return value
}
throw new Error(`Invalid target "${value}". Use feed, list, or url.`)
}
const parseUpdateTarget = (value: string): UpdateTarget => {
if (value === "feed" || value === "list") {
return value
}
throw new Error(`Invalid target "${value}". Use feed or list.`)
}
interface SubscriptionListOptions {
view?: number
category?: string
}
interface SubscriptionAddOptions {
feed?: string
list?: string
category?: string
view?: number
private?: boolean
title?: string
}
interface SubscriptionRemoveOptions {
target: SubscriptionTarget
}
interface SubscriptionUpdateOptions {
category?: string
title?: string
view?: number
private?: boolean
public?: boolean
target: UpdateTarget
}
export const registerSubscriptionCommand = (program: Command) => {
const subscriptionCommand = program.command("subscription").description("Manage subscriptions")
subscriptionCommand
.command("list")
.description("List subscriptions")
.option("--view <type>", `View type: ${viewHelp}`, parseView)
.option("--category <name>", "Filter by category")
.action(async function (this: Command, options: SubscriptionListOptions) {
await runCommand(this, async ({ client }) => {
const response = await client.api.subscriptions.get(
options.view !== undefined ? { view: options.view } : {},
)
const subscriptions = options.category
? response.data.filter((item) => item.category === options.category)
: response.data
return {
subscriptions,
}
})
})
subscriptionCommand
.command("add")
.description("Add a feed or list subscription")
.option("--feed <url>", "Feed URL to subscribe")
.option("--list <listId>", "List ID to subscribe")
.option("--category <name>", "Subscription category")
.option("--view <type>", `View type: ${viewHelp}`, parseView)
.option("--private", "Mark subscription as private", false)
.option("--title <title>", "Custom subscription title")
.action(async function (this: Command, options: SubscriptionAddOptions) {
await runCommand(this, async ({ client }) => {
const selected = [options.feed, options.list].filter(Boolean)
if (selected.length !== 1) {
throw new CLIError("INVALID_ARGUMENT", "Use either --feed or --list when adding.")
}
const request: SubscriptionCreateRequest = {
view: options.view ?? 0,
category: options.category ?? null,
isPrivate: options.private || false,
title: options.title ?? null,
}
if (options.feed) {
request.url = options.feed
request.type = "feed"
}
if (options.list) {
request.listId = options.list
request.type = "list"
}
const response = await client.api.subscriptions.create(request)
return {
feed: response.feed,
list: response.list,
unread: response.unread,
}
})
})
subscriptionCommand
.command("remove")
.description("Remove a subscription target")
.argument("<id>", "Feed ID, list ID, or feed URL")
.option(
"--target <target>",
"Subscription target type: feed | list | url",
parseSubscriptionTarget,
"feed",
)
.action(async function (this: Command, id: string, options: SubscriptionRemoveOptions) {
await runCommand(this, async ({ client }) => {
const request: SubscriptionDeleteRequest = {}
if (options.target === "feed") {
request.feedId = id
} else if (options.target === "list") {
request.listId = id
} else {
request.url = id
}
const response = await client.api.subscriptions.delete(request)
return response.data
})
})
subscriptionCommand
.command("update")
.description("Update a subscription target")
.argument("<id>", "Feed ID or list ID")
.option("--category <name>", "Set category")
.option("--title <title>", "Set custom title")
.option("--view <type>", `View type: ${viewHelp}`, parseView)
.option("--private", "Set subscription private", false)
.option("--public", "Set subscription public", false)
.option("--target <target>", "Target type: feed | list", parseUpdateTarget, "feed")
.action(async function (this: Command, id: string, options: SubscriptionUpdateOptions) {
await runCommand(this, async ({ client }) => {
if (options.private && options.public) {
throw new CLIError(
"INVALID_ARGUMENT",
"Use only one of --private or --public when updating.",
)
}
const request: SubscriptionUpdateRequest = {
category: options.category ?? undefined,
title: options.title ?? undefined,
view: options.view,
}
if (options.private) {
request.isPrivate = true
} else if (options.public) {
request.isPrivate = false
}
if (options.target === "feed") {
request.feedId = id
} else {
request.listId = id
}
if (
request.category === undefined &&
request.title === undefined &&
request.view === undefined &&
request.isPrivate === undefined
) {
throw new CLIError(
"INVALID_ARGUMENT",
"No update fields provided. Use at least one of --category, --title, --view, --private, --public.",
)
}
const response = await client.api.subscriptions.update(request)
return response.data
})
})
}

View File

@ -0,0 +1,90 @@
import type { EntryListRequest } from "@follow-app/client-sdk"
import type { Command } from "commander"
import { parseISODate, parsePositiveInt, parseView, viewHelp } from "../args"
import { runCommand } from "../command"
import { CLIError } from "../output"
type TimelineQuery = EntryListRequest & {
listId?: string
}
interface TimelineOptions {
view?: number
limit: number
unreadOnly?: boolean
cursor?: string
feed?: string
list?: string
category?: string
}
export const registerTimelineCommand = (program: Command) => {
program
.command("timeline")
.description("List timeline entries")
.option("--view <type>", `View type: ${viewHelp}`, parseView)
.option("--limit <n>", "Number of entries to fetch", parsePositiveInt, 20)
.option("--unread-only", "Only unread entries", false)
.option("--cursor <datetime>", "Pagination cursor (publishedAfter)", parseISODate)
.option("--feed <feedId>", "Filter timeline by feed")
.option("--list <listId>", "Filter timeline by list")
.option("--category <name>", "Filter timeline by subscription category")
.action(async function (this: Command, options: TimelineOptions) {
await runCommand(this, async ({ client }) => {
const scopedFilters = [options.feed, options.list, options.category].filter(Boolean)
if (scopedFilters.length > 1) {
throw new CLIError(
"INVALID_ARGUMENT",
"Use only one of --feed, --list, or --category at the same time.",
)
}
const query: TimelineQuery = {
limit: options.limit,
read: options.unreadOnly ? false : undefined,
view: options.view,
publishedAfter: options.cursor,
}
if (options.feed) {
query.feedId = options.feed
}
if (options.list) {
query.listId = options.list
}
if (options.category) {
const subscriptions = await client.api.subscriptions.get(
options.view !== undefined ? { view: options.view } : {},
)
const feedIdList = subscriptions.data
.filter((item) => item.category === options.category)
.map((item) => item.feedId)
.filter((item): item is string => item.length > 0)
if (feedIdList.length === 0) {
return {
entries: [],
nextCursor: null,
hasNext: false,
}
}
query.feedIdList = feedIdList
}
const response = await client.api.entries.list(query)
const entries = response.data
const nextCursor = entries.at(-1)?.entries.publishedAt ?? null
return {
entries,
nextCursor,
hasNext: Boolean(nextCursor) && entries.length >= options.limit,
}
})
})
}

View File

@ -0,0 +1,105 @@
import type {
InboxSubscriptionResponse,
ListSubscriptionResponse,
SubscriptionWithFeed,
} from "@follow-app/client-sdk"
import type { Command } from "commander"
import { parseView, viewHelp } from "../args"
import { runCommand } from "../command"
interface UnreadListOptions {
view?: number
}
const isInboxSubscription = (
value: SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse,
): value is InboxSubscriptionResponse => {
return "inboxes" in value
}
const isListSubscription = (
value: SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse,
): value is ListSubscriptionResponse => {
return "lists" in value
}
const resolveTitle = (
value: SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse,
): string | null => {
if (value.title) {
return value.title
}
if ("feeds" in value) {
return value.feeds.title ?? null
}
if ("lists" in value) {
return value.lists.title ?? null
}
if ("inboxes" in value) {
return value.inboxes.title ?? null
}
return null
}
export const registerUnreadCommand = (program: Command) => {
const unreadCommand = program.command("unread").description("Unread status commands")
unreadCommand
.command("count")
.description("Get total unread count")
.action(async function (this: Command) {
await runCommand(this, async ({ client }) => {
const response = await client.api.reads.getTotalCount()
return response.data
})
})
unreadCommand
.command("list")
.description("List subscriptions with unread entries")
.option("--view <type>", `View type: ${viewHelp}`, parseView)
.action(async function (this: Command, options: UnreadListOptions) {
await runCommand(this, async ({ client }) => {
const [unreadResponse, subscriptionsResponse] = await Promise.all([
client.api.reads.get(options.view !== undefined ? { view: options.view } : {}),
client.api.subscriptions.get(options.view !== undefined ? { view: options.view } : {}),
])
const unreadMap = unreadResponse.data
const items = subscriptionsResponse.data
.map((subscription) => {
const unreadKey = isInboxSubscription(subscription)
? subscription.inboxId
: subscription.feedId
const unreadCount = unreadMap[unreadKey] ?? 0
return {
sourceType: isInboxSubscription(subscription)
? "inbox"
: isListSubscription(subscription)
? "list"
: "feed",
sourceId: isInboxSubscription(subscription)
? subscription.inboxId
: isListSubscription(subscription)
? subscription.listId
: subscription.feedId,
feedId: subscription.feedId,
title: resolveTitle(subscription),
category: subscription.category ?? null,
view: subscription.view,
unreadCount,
isPrivate: subscription.isPrivate,
}
})
.filter((item) => item.unreadCount > 0)
.sort((left, right) => right.unreadCount - left.unreadCount)
return {
total: items.reduce((sum, item) => sum + item.unreadCount, 0),
items,
}
})
})
}

77
apps/cli/src/config.ts Normal file
View File

@ -0,0 +1,77 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "pathe"
export interface FoloCLIConfig {
token?: string
apiUrl?: string
}
const configDir = join(homedir(), ".folo")
const configPath = join(configDir, "config.json")
const normalizeConfig = (config: unknown): FoloCLIConfig => {
if (!config || typeof config !== "object") {
return {}
}
const source = config as Record<string, unknown>
return {
token: typeof source.token === "string" ? source.token : undefined,
apiUrl: typeof source.apiUrl === "string" ? source.apiUrl : undefined,
}
}
export const getConfigPath = () => configPath
export const readConfig = async (): Promise<FoloCLIConfig> => {
try {
const raw = await readFile(configPath, "utf8")
return normalizeConfig(JSON.parse(raw))
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code === "ENOENT") {
return {}
}
throw error
}
}
const ensureConfigDir = async () => {
await mkdir(configDir, { recursive: true })
}
export const writeConfig = async (config: FoloCLIConfig) => {
await ensureConfigDir()
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8")
}
export const updateConfig = async (patch: Partial<FoloCLIConfig>) => {
const current = await readConfig()
const next: FoloCLIConfig = {
...current,
...patch,
}
if (!next.token) {
delete next.token
}
if (!next.apiUrl) {
delete next.apiUrl
}
await writeConfig(next)
return next
}
export const clearToken = async () => {
const current = await readConfig()
if (!current.token) {
return
}
delete current.token
await writeConfig(current)
}

60
apps/cli/src/index.ts Normal file
View File

@ -0,0 +1,60 @@
import { Command } from "commander"
import { parseFormat } from "./args"
import { defaultApiURL } from "./client"
import { registerAuthCommand } from "./commands/auth"
import { registerCollectionCommand } from "./commands/collection"
import { registerEntryCommand } from "./commands/entry"
import { registerFeedCommand } from "./commands/feed"
import { registerListCommand } from "./commands/list"
import { registerOPMLCommand } from "./commands/opml"
import { registerSearchCommand } from "./commands/search"
import { registerSubscriptionCommand } from "./commands/subscription"
import { registerTimelineCommand } from "./commands/timeline"
import { registerUnreadCommand } from "./commands/unread"
import type { OutputFormat } from "./output"
import { normalizeError, printFailure } from "./output"
const program = new Command()
program
.name("folo")
.description("Folo CLI client for structured automation")
.version("0.1.0")
.option("-f, --format <format>", "Output format: json | table | plain", parseFormat, "json")
.option("--api-url <url>", `API base URL (default: ${defaultApiURL})`)
.option("--token <token>", "Override stored token")
.option("--verbose", "Enable verbose request/response logging", false)
registerAuthCommand(program)
registerTimelineCommand(program)
registerSubscriptionCommand(program)
registerEntryCommand(program)
registerFeedCommand(program)
registerListCommand(program)
registerSearchCommand(program)
registerCollectionCommand(program)
registerOPMLCommand(program)
registerUnreadCommand(program)
const resolveRequestedFormat = (argv: string[]): OutputFormat => {
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index]
if ((current === "--format" || current === "-f") && argv[index + 1]) {
try {
return parseFormat(argv[index + 1]!)
} catch {
return "json"
}
}
}
return "json"
}
try {
await program.parseAsync(process.argv)
} catch (error) {
const format = resolveRequestedFormat(process.argv)
printFailure(format, normalizeError(error))
process.exitCode = 1
}

View File

@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { CLIError, normalizeError, printFailure, printSuccess } from "./output"
describe("output helpers", () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("prints JSON success envelope", () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {})
printSuccess("json", { value: 1 })
expect(infoSpy).toHaveBeenCalledTimes(1)
const payload = JSON.parse(infoSpy.mock.calls[0]![0] as string) as {
ok: boolean
data: { value: number }
error: null
}
expect(payload.ok).toBe(true)
expect(payload.data).toEqual({ value: 1 })
expect(payload.error).toBeNull()
})
it("prints JSON failure envelope", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
printFailure("json", { code: "E_TEST", message: "boom" })
expect(errorSpy).toHaveBeenCalledTimes(1)
const payload = JSON.parse(errorSpy.mock.calls[0]![0] as string) as {
ok: boolean
data: null
error: { code: string; message: string }
}
expect(payload.ok).toBe(false)
expect(payload.data).toBeNull()
expect(payload.error).toEqual({ code: "E_TEST", message: "boom" })
})
it("prints ascii table in table mode", () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {})
printSuccess("table", [{ id: "a", count: 1 }])
expect(infoSpy).toHaveBeenCalledTimes(1)
const output = infoSpy.mock.calls[0]![0] as string
expect(output).toContain("| id")
expect(output).toContain("| a")
})
it("normalizes CLIError", () => {
const normalized = normalizeError(new CLIError("E_CODE", "message"))
expect(normalized).toEqual({
code: "E_CODE",
message: "message",
})
})
it("normalizes generic Error", () => {
const normalized = normalizeError(new Error("generic"))
expect(normalized).toEqual({
code: "UNKNOWN_ERROR",
message: "generic",
})
})
})

192
apps/cli/src/output.ts Normal file
View File

@ -0,0 +1,192 @@
import { inspect } from "node:util"
import { FollowAPIError, FollowAuthError } from "@follow-app/client-sdk"
export type OutputFormat = "json" | "table" | "plain"
export interface OutputError {
code: string
message: string
}
export class CLIError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = "CLIError"
this.code = code
}
}
const stringifyJSON = (value: unknown) => {
return JSON.stringify(
value,
(_key, currentValue) => {
if (typeof currentValue === "bigint") {
return currentValue.toString()
}
return currentValue
},
2,
)
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
const toCellValue = (value: unknown): string | number | boolean | null => {
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value as string | number | boolean | null
}
if (value === undefined) {
return ""
}
return stringifyJSON(value)
}
const toTableRow = (value: unknown): Record<string, string | number | boolean | null> => {
if (!isRecord(value)) {
return {
value: toCellValue(value),
}
}
const row: Record<string, string | number | boolean | null> = {}
for (const [key, currentValue] of Object.entries(value)) {
row[key] = toCellValue(currentValue)
}
return row
}
const renderAsciiTable = (
rows: Array<Record<string, string | number | boolean | null>>,
): string => {
if (rows.length === 0) {
return "(empty)"
}
const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))))
const widths = columns.map((column) =>
Math.max(column.length, ...rows.map((row) => String(row[column] ?? "").length)),
)
const formatLine = (values: string[]) => {
return `| ${values.map((value, index) => value.padEnd(widths[index]!)).join(" | ")} |`
}
const border = `+-${widths.map((width) => "-".repeat(width)).join("-+-")}-+`
const header = formatLine(columns)
const body = rows.map((row) => formatLine(columns.map((column) => String(row[column] ?? ""))))
return [border, header, border, ...body, border].join("\n")
}
const renderTable = (data: unknown) => {
const rows = Array.isArray(data) ? data.map(toTableRow) : [toTableRow(data)]
console.info(renderAsciiTable(rows))
}
const renderPlain = (data: unknown) => {
if (Array.isArray(data)) {
console.info(data.map((item) => inspect(item, { depth: null, colors: false })).join("\n"))
return
}
if (typeof data === "string") {
console.info(data)
return
}
if (data === null || data === undefined) {
console.info("")
return
}
console.info(
inspect(data, {
depth: null,
colors: false,
compact: false,
}),
)
}
export const printSuccess = (format: OutputFormat, data: unknown) => {
if (format === "json") {
const payload = {
ok: true as const,
data,
error: null,
}
console.info(stringifyJSON(payload))
return
}
if (format === "table") {
renderTable(data)
return
}
renderPlain(data)
}
export const printFailure = (format: OutputFormat, error: OutputError) => {
if (format === "json") {
const payload = {
ok: false as const,
data: null,
error,
}
console.error(stringifyJSON(payload))
return
}
console.error(`[${error.code}] ${error.message}`)
}
const firstLine = (message: string) => {
const [head] = message.split("\n")
return head?.trim() || message
}
export const normalizeError = (error: unknown, verbose = false): OutputError => {
if (error instanceof CLIError) {
return {
code: error.code,
message: error.message,
}
}
if (error instanceof FollowAuthError) {
return {
code: "UNAUTHORIZED",
message: verbose ? error.message : firstLine(error.message),
}
}
if (error instanceof FollowAPIError) {
return {
code: error.code ?? `HTTP_${error.status}`,
message: verbose ? error.message : firstLine(error.message),
}
}
if (error instanceof Error) {
return {
code: "UNKNOWN_ERROR",
message: error.message,
}
}
return {
code: "UNKNOWN_ERROR",
message: "An unknown error occurred",
}
}

12
apps/cli/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@follow/configs/tsconfig.extend.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "Bundler",
"types": ["node"],
"noEmit": true
},
"include": ["src/**/*.ts", "tsup.config.ts"]
}

16
apps/cli/tsup.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
target: "node18",
outDir: "dist",
clean: true,
sourcemap: false,
dts: false,
splitting: false,
shims: false,
banner: {
js: "#!/usr/bin/env node",
},
})

View File

@ -0,0 +1,7 @@
import { defineProject } from "vitest/config"
export default defineProject({
test: {
environment: "node",
},
})

View File

@ -0,0 +1,19 @@
# What's new in v1.4.0
## Shiny new things
- Added in-app review prompts
## Improvements
- Expanded desktop end-to-end coverage for auth and user flows
## No longer broken
- Removed the unwanted text selection toolbar
- Fixed AI onboarding asset loading by switching the spline asset domain
- Hardened setting sync authentication lifecycle
## Thanks
Special thanks to volunteer contributors for their valuable contributions

View File

@ -0,0 +1,54 @@
import { defineConfig, devices } from "@playwright/test"
import { resolveDesktopE2EEnv } from "./support/env"
const env = resolveDesktopE2EEnv()
export default defineConfig({
testDir: "./tests",
fullyParallel: false,
workers: 1,
timeout: 120_000,
expect: {
timeout: 15_000,
},
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
outputDir: "test-results",
use: {
baseURL: env.webBaseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
serviceWorkers: "block",
},
webServer: {
command: "pnpm run dev:web",
cwd: env.desktopAppDir,
env: {
...process.env,
VITE_API_URL: process.env.FOLO_E2E_WEB_DEV_API_URL ?? env.apiURL,
VITE_WEB_URL: process.env.FOLO_E2E_WEB_DEV_WEB_URL ?? env.webURL,
},
url: env.webDevServerURL,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: "web",
testMatch: /tests\/web\/.*\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
channel: "chromium",
ignoreHTTPSErrors: true,
launchOptions: {
args: ["--disable-web-security"],
},
},
},
{
name: "electron",
testMatch: /tests\/electron\/.*\.spec\.ts/,
},
],
})

View File

@ -0,0 +1,44 @@
import type { Page } from "@playwright/test"
import type { DesktopE2EEnv } from "./env"
export interface TestAccount {
email: string
password: string
}
export const createTestAccount = (name: string): TestAccount => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
return {
email: `folo-e2e-${name}-${suffix}@example.com`,
password: process.env.FOLO_E2E_PASSWORD ?? "Password123!",
}
}
export const tryDeleteCurrentUser = async (page: Page, env: DesktopE2EEnv) => {
return page.evaluate(async ({ apiURL }) => {
try {
const response = await fetch(`${apiURL}/better-auth/delete-user-custom`, {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({}),
})
return {
ok: response.ok,
status: response.status,
text: await response.text(),
}
} catch (error) {
return {
ok: false,
status: -1,
text: error instanceof Error ? error.message : String(error),
}
}
}, env)
}

View File

@ -0,0 +1,588 @@
import type { Locator, Page } from "@playwright/test"
import { expect } from "@playwright/test"
import type { TestAccount } from "./account"
import type { DesktopE2EEnv } from "./env"
import { buildWebAppURL } from "./env"
const ONBOARDING_FEED_URL = "folo://onboarding"
const isVisible = async (locator: Locator) => locator.isVisible().catch(() => false)
const visibleByTestId = (page: Page, testId: string) =>
page.locator(`[data-testid="${testId}"]:visible`).last()
export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => {
await page.addInitScript(
(nextEnv) => {
window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token"
if (!nextEnv) {
return
}
const fixedEnv = {
VITE_API_URL: nextEnv.apiURL,
VITE_EXTERNAL_API_URL: nextEnv.apiURL,
VITE_WEB_URL: nextEnv.webURL,
}
const target =
(globalThis as typeof globalThis & { __followEnv?: Record<string, string> }).__followEnv ??
{}
const proxy = new Proxy(target, {
get(currentTarget, property, receiver) {
if (typeof property === "string" && property in fixedEnv) {
return fixedEnv[property as keyof typeof fixedEnv]
}
return Reflect.get(currentTarget, property, receiver)
},
set(currentTarget, property, value, receiver) {
if (typeof property === "string" && property in fixedEnv) {
return true
}
return Reflect.set(currentTarget, property, value, receiver)
},
ownKeys(currentTarget) {
return Array.from(new Set([...Reflect.ownKeys(currentTarget), ...Object.keys(fixedEnv)]))
},
getOwnPropertyDescriptor(currentTarget, property) {
if (typeof property === "string" && property in fixedEnv) {
return {
configurable: true,
enumerable: true,
writable: false,
value: fixedEnv[property as keyof typeof fixedEnv],
}
}
return Reflect.getOwnPropertyDescriptor(currentTarget, property)
},
})
Object.defineProperty(globalThis, "__followEnv", {
configurable: true,
enumerable: false,
get() {
return proxy
},
set() {},
})
},
env ? { apiURL: env.apiURL, webURL: env.webURL } : undefined,
)
}
export const openWebApp = async (page: Page, env: DesktopE2EEnv, route = "/") => {
await injectRecaptchaToken(page, env)
await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" })
}
export const waitForAuthenticated = async (page: Page) => {
const isAuthenticatedUiReady = async () => {
const profileVisible = await page
.getByTestId("profile-menu-trigger")
.isVisible()
.catch(() => false)
const loginModalVisible = await page
.getByTestId("login-modal")
.isVisible()
.catch(() => false)
return profileVisible && !loginModalVisible
}
await expect.poll(isAuthenticatedUiReady, { timeout: 30_000 }).toBe(true)
}
export const waitForLoggedOut = async (page: Page) => {
await expect
.poll(
async () => {
const loginButtonVisible = await page
.getByTestId("login-button")
.last()
.isVisible()
.catch(() => false)
const loginModalVisible = await page
.getByTestId("login-modal")
.last()
.isVisible()
.catch(() => false)
const loginInputVisible = await page
.getByTestId("login-email-input")
.last()
.isVisible()
.catch(() => false)
const registerInputVisible = await page
.getByTestId("register-email-input")
.last()
.isVisible()
.catch(() => false)
return loginButtonVisible || loginModalVisible || loginInputVisible || registerInputVisible
},
{ timeout: 30_000 },
)
.toBe(true)
}
export const ensureLoginModal = async (page: Page) => {
await expect
.poll(
async () => {
const loginModalVisible = await page
.getByTestId("login-modal")
.last()
.isVisible()
.catch(() => false)
const loginButtonVisible = await page
.getByTestId("login-button")
.last()
.isVisible()
.catch(() => false)
const loginInputVisible = await page
.getByTestId("login-email-input")
.last()
.isVisible()
.catch(() => false)
const registerInputVisible = await page
.getByTestId("register-email-input")
.last()
.isVisible()
.catch(() => false)
return loginModalVisible || loginButtonVisible || loginInputVisible || registerInputVisible
},
{ timeout: 30_000 },
)
.toBe(true)
}
const ensureCredentialForm = async (page: Page, mode: "register" | "login") => {
await ensureLoginModal(page)
const targetInput = visibleByTestId(
page,
mode === "register" ? "register-email-input" : "login-email-input",
)
const loginButton = visibleByTestId(page, "login-button")
const loginModal = visibleByTestId(page, "login-modal")
const activeDialog = page.locator('[role="dialog"]:visible').last()
const credentialProvider = visibleByTestId(page, "login-provider-credential")
const targetForm = visibleByTestId(page, mode === "register" ? "register-form" : "login-form")
const oppositeForm = visibleByTestId(page, mode === "register" ? "login-form" : "register-form")
const oppositeFormSwitcher = visibleByTestId(
page,
mode === "register" ? "login-switch-register" : "register-switch-login",
)
if (await isVisible(targetInput)) {
return
}
if (
(await isVisible(loginButton)) &&
!(await isVisible(loginModal)) &&
!(await isVisible(activeDialog))
) {
await expect(loginButton).toBeVisible({ timeout: 30_000 })
await loginButton.click({ noWaitAfter: true })
}
if (await isVisible(targetInput)) {
return
}
if (!(await isVisible(targetForm)) && !(await isVisible(oppositeForm))) {
await expect(credentialProvider).toBeVisible({ timeout: 30_000 })
await credentialProvider.click({ timeout: 30_000, noWaitAfter: true })
await expect
.poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), {
timeout: 30_000,
})
.toBe(true)
}
if (await isVisible(oppositeForm)) {
await expect(oppositeFormSwitcher).toBeVisible({ timeout: 30_000 })
await oppositeFormSwitcher.click({ timeout: 30_000, noWaitAfter: true })
}
await expect(targetInput).toBeVisible({ timeout: 30_000 })
}
export const registerWithCredential = async (page: Page, account: TestAccount) => {
await ensureCredentialForm(page, "register")
await visibleByTestId(page, "register-email-input").fill(account.email)
await visibleByTestId(page, "register-password-input").fill(account.password)
const confirmPasswordInput = visibleByTestId(page, "register-confirm-password-input")
await confirmPasswordInput.fill(account.password)
const submit = visibleByTestId(page, "register-submit")
await expect(submit).toBeEnabled({ timeout: 30_000 })
await submit.click()
await waitForAuthenticated(page)
}
export const loginWithCredential = async (page: Page, account: TestAccount) => {
await ensureCredentialForm(page, "login")
await visibleByTestId(page, "login-email-input").fill(account.email)
const passwordInput = visibleByTestId(page, "login-password-input")
await passwordInput.fill(account.password)
const submit = visibleByTestId(page, "login-submit")
await expect(submit).toBeEnabled({ timeout: 30_000 })
await submit.click()
await waitForAuthenticated(page)
}
export const logoutFromProfileMenu = async (page: Page) => {
await page.keyboard.press("Escape").catch(() => {})
if (page.url().startsWith("app://")) {
await returnToMainShell(page)
}
await page.getByTestId("profile-menu-trigger").click()
const signOutResponse = page
.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().includes("/better-auth/sign-out"),
{ timeout: 30_000 },
)
.catch(() => null)
await page.getByTestId("profile-menu-logout").click()
await signOutResponse
await waitForLoggedOut(page)
}
const waitForMainShell = async (page: Page) => {
await expect
.poll(
async () => {
const profileVisible = await page
.getByTestId("profile-menu-trigger")
.isVisible()
.catch(() => false)
const timelineVisible = await page
.getByTestId("timeline-tab-articles")
.isVisible()
.catch(() => false)
return profileVisible || timelineVisible
},
{ timeout: 30_000 },
)
.toBe(true)
}
const returnToMainShell = async (page: Page) => {
const discoverInput = page.getByTestId("discover-form-input")
if (await discoverInput.isVisible().catch(() => false)) {
const backButton = page.getByTestId("subview-back")
if (await backButton.isVisible().catch(() => false)) {
await backButton.click()
} else {
await page.keyboard.press("Escape").catch(() => {})
}
if (await discoverInput.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => {})
}
await expect
.poll(async () => discoverInput.isVisible().catch(() => false), { timeout: 15_000 })
.toBe(false)
}
await waitForMainShell(page)
const activeDialog = page.locator('[role="dialog"]:visible').last()
if (await activeDialog.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => {})
if (await activeDialog.isVisible().catch(() => false)) {
const modalClose = activeDialog.getByTestId("modal-close").first()
if (await modalClose.isVisible().catch(() => false)) {
await modalClose.click().catch(() => {})
}
}
await expect
.poll(async () => activeDialog.isVisible().catch(() => false), { timeout: 10_000 })
.toBe(false)
}
}
const waitForSettingsTabContent = async (page: Page, tab: "general" | "feeds") => {
if (tab === "general") {
await expect(page.getByTestId("settings-language-select")).toBeVisible({ timeout: 15_000 })
return
}
await expect
.poll(async () => page.locator('[data-testid^="settings-feed-row-"]').count(), {
timeout: 15_000,
})
.toBeGreaterThan(0)
}
export const openSettings = async (page: Page, tab: "general" | "feeds" = "general") => {
await waitForAuthenticated(page)
const settingsModal = page.locator("#setting-modal").first()
const openSettingsFromMenu = async () => {
await returnToMainShell(page)
const profileTrigger = page.getByTestId("profile-menu-trigger")
await expect(profileTrigger).toBeVisible({ timeout: 15_000 })
await profileTrigger.click()
const preferencesItem = page.getByTestId("profile-menu-preferences")
await expect(preferencesItem).toBeVisible({ timeout: 15_000 })
await preferencesItem.click()
await expect(settingsModal).toBeVisible({ timeout: 15_000 })
}
if (!(await settingsModal.isVisible().catch(() => false))) {
try {
await openSettingsFromMenu()
} catch {
await openSettingsFromMenu()
}
}
await openSettingsTab(page, tab)
}
export const openSettingsTab = async (page: Page, tab: "general" | "feeds") => {
const settingsTab = page.getByTestId(`settings-tab-${tab}`)
await expect(settingsTab).toBeVisible({ timeout: 15_000 })
if (tab === "feeds") {
await expect
.poll(
async () => {
const className = (await settingsTab.getAttribute("class")) ?? ""
return !className.includes("opacity-50")
},
{ timeout: 15_000 },
)
.toBe(true)
}
await settingsTab.click()
await waitForSettingsTabContent(page, tab)
}
export const closeSettings = async (page: Page) => {
const settingsModal = page.locator("#setting-modal").first()
if (!(await settingsModal.isVisible().catch(() => false))) {
return
}
await page.keyboard.press("Escape").catch(() => {})
if (await settingsModal.isVisible().catch(() => false)) {
const modalClose = settingsModal.getByTestId("modal-close").first()
if (await isVisible(modalClose)) {
await modalClose.click().catch(() => {})
}
}
await expect
.poll(async () => settingsModal.isVisible().catch(() => false), { timeout: 10_000 })
.toBe(false)
}
export const setLanguage = async (page: Page, label: string) => {
await page.getByTestId("settings-language-select").click()
await page.getByRole("option", { name: label }).click()
}
export const getLanguageLabel = async (page: Page) => {
return page.getByTestId("settings-language-select").textContent()
}
export const openOnboardingFeedForm = async (
page: Page,
_env?: DesktopE2EEnv,
_options?: { electron?: boolean },
) => {
const discoverInput = page.getByTestId("discover-form-input")
if (!(await discoverInput.isVisible().catch(() => false))) {
await returnToMainShell(page)
const discoverTrigger = page.getByTestId("subscription-discover-trigger")
await expect(discoverTrigger).toBeVisible({ timeout: 15_000 })
await discoverTrigger.click()
}
await expect(discoverInput).toBeVisible({ timeout: 15_000 })
await discoverInput.fill(ONBOARDING_FEED_URL)
await discoverInput.press("Enter")
await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 })
}
export const followOnboardingFeed = async (
page: Page,
env: DesktopE2EEnv,
options?: { electron?: boolean },
) => {
await openOnboardingFeedForm(page, env, options)
const onboardingDiscoverCard = page
.locator("[data-feed-id]")
.filter({ hasText: "Welcome to Folo" })
.first()
const followButton = onboardingDiscoverCard.getByRole("button", { name: /^Follow$/i })
if (await followButton.isVisible().catch(() => false)) {
await expect(followButton).toBeEnabled({ timeout: 15_000 })
await followButton.click()
}
await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 })
}
export const dismissFeedForm = async (page: Page) => {
const cancelButton = visibleByTestId(page, "feed-form-cancel")
const dialog = page.locator('[role="dialog"]').last()
if (!(await cancelButton.isVisible().catch(() => false))) {
if (await dialog.isVisible().catch(() => false)) {
await page.keyboard.press("Escape").catch(() => {})
}
return
}
await cancelButton.click()
if (
(await cancelButton.isVisible().catch(() => false)) ||
(await dialog.isVisible().catch(() => false))
) {
await page.keyboard.press("Escape").catch(() => {})
}
}
const findSettingsFeedRow = async (page: Page, onboardingFeedId: string | null) => {
const targetedFeedRow = onboardingFeedId
? page.getByTestId(`settings-feed-row-${onboardingFeedId}`)
: null
const fallbackFeedRow = page
.locator('[data-testid^="settings-feed-row-"]')
.filter({
hasText: "Welcome to Folo",
})
.first()
const settingsViewport = page.locator("#setting-modal [data-radix-scroll-area-viewport]").first()
await settingsViewport
.evaluate((element) => {
if (element instanceof HTMLElement) {
element.scrollTop = 0
}
})
.catch(() => {})
for (let attempt = 0; attempt < 24; attempt++) {
if (targetedFeedRow && (await targetedFeedRow.isVisible().catch(() => false))) {
return targetedFeedRow
}
if (await fallbackFeedRow.isVisible().catch(() => false)) {
return fallbackFeedRow
}
await settingsViewport.hover().catch(() => {})
await page.mouse.wheel(0, 1200)
await page.waitForTimeout(150)
}
return targetedFeedRow && (await targetedFeedRow.count()) > 0 ? targetedFeedRow : fallbackFeedRow
}
export const unsubscribeFirstFeedFromSettings = async (page: Page, _env?: DesktopE2EEnv) => {
const onboardingFeedItem = page
.locator("[data-feed-id]")
.filter({
hasText: "Welcome to Folo",
})
.first()
const onboardingFeedId =
(await onboardingFeedItem.count()) > 0
? await onboardingFeedItem.getAttribute("data-feed-id")
: null
await openSettings(page)
await openSettingsTab(page, "feeds")
const feedRow = await findSettingsFeedRow(page, onboardingFeedId)
await expect(feedRow).toBeVisible({ timeout: 15_000 })
await feedRow.scrollIntoViewIfNeeded().catch(() => {})
const feedRowTestId = await feedRow.getAttribute("data-testid")
await feedRow.click()
const unsubscribeButton = page.getByTestId("feeds-batch-unsubscribe")
await expect(unsubscribeButton).toBeVisible({ timeout: 15_000 })
await unsubscribeButton.click()
await page.getByTestId("confirm-destroy").click()
if (feedRowTestId) {
await expect(page.getByTestId(feedRowTestId)).toHaveCount(0, { timeout: 15_000 })
} else {
await expect(feedRow).toHaveCount(0, { timeout: 15_000 })
}
}
export const expectOnboardingFeedUnsubscribed = async (
page: Page,
_env?: DesktopE2EEnv,
_options?: { electron?: boolean },
) => {
await openOnboardingFeedForm(page)
await expect(page.getByTestId("feed-form-cancel")).toHaveCount(0)
}
export const expectTimelineSwitchAndEntryReadFlow = async (page: Page) => {
await returnToMainShell(page)
await page.getByTestId("timeline-tab-videos").click()
await expect.poll(async () => page.locator("[data-entry-id]").count()).toBe(0)
await page.getByTestId("timeline-tab-articles").click()
await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0)
const unreadOnboardingEntry = page
.locator('[data-entry-id][data-read="false"]:visible')
.filter({ has: page.locator("a[href]") })
.first()
await expect(unreadOnboardingEntry).toBeVisible({ timeout: 15_000 })
const onboardingEntryId = await unreadOnboardingEntry.getAttribute("data-entry-id")
expect(onboardingEntryId).toBeTruthy()
const onboardingEntry = page.locator(`[data-entry-id="${onboardingEntryId}"]`)
const onboardingEntryLink = unreadOnboardingEntry.locator("a[href]").first()
await unreadOnboardingEntry.scrollIntoViewIfNeeded().catch(() => {})
await expect(onboardingEntryLink).toBeVisible({ timeout: 15_000 })
await onboardingEntryLink.click()
const entryRender = page.getByTestId("entry-render")
await expect(entryRender).toBeVisible({ timeout: 15_000 })
await expect(onboardingEntry).toHaveAttribute("data-active", "true", { timeout: 15_000 })
await expect(onboardingEntry).toHaveAttribute("data-read", "true", { timeout: 15_000 })
const toggleReadButton = page.getByTestId("command-action-entry-read").last()
await expect(toggleReadButton).toBeVisible({ timeout: 15_000 })
await expect(toggleReadButton).toBeEnabled({ timeout: 15_000 })
await toggleReadButton.click()
await expect(onboardingEntry).toHaveAttribute("data-read", "false", { timeout: 15_000 })
await toggleReadButton.click()
await expect(onboardingEntry).toHaveAttribute("data-read", "true", { timeout: 15_000 })
}

View File

@ -0,0 +1,70 @@
import { execSync } from "node:child_process"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import type { ElectronApplication, Page } from "@playwright/test"
import { _electron as electron } from "@playwright/test"
import { join } from "pathe"
import type { DesktopE2EEnv } from "./env"
let buildSignature: string | null = null
const ensureElectronBuilt = (env: DesktopE2EEnv) => {
const nextSignature = `${env.apiURL}|${env.webURL}`
if (buildSignature === nextSignature) {
return
}
execSync("pnpm run build:electron-vite", {
cwd: env.desktopAppDir,
env: {
...process.env,
VITE_API_URL: env.apiURL,
VITE_WEB_URL: env.webURL,
},
stdio: "inherit",
})
buildSignature = nextSignature
}
export const launchElectronApp = async (env: DesktopE2EEnv) => {
ensureElectronBuilt(env)
const userDataDir = await mkdtemp(join(tmpdir(), "folo-e2e-"))
const electronApp = await electron.launch({
args: [env.desktopAppDir],
cwd: env.desktopAppDir,
env: {
...process.env,
CI: process.env.CI ?? "1",
NODE_ENV: "test",
VITE_API_URL: env.apiURL,
VITE_WEB_URL: env.webURL,
FOLO_E2E_USER_DATA_DIR: userDataDir,
},
timeout: 120_000,
})
const page = await electronApp.firstWindow()
await page.waitForLoadState("domcontentloaded")
await page.evaluate(() => {
window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token"
})
return {
electronApp,
page,
userDataDir,
}
}
export const closeElectronApp = async (app: {
electronApp: ElectronApplication
page: Page
userDataDir: string
}) => {
await app.electronApp.close().catch(() => {})
await rm(app.userDataDir, { force: true, recursive: true })
}

View File

@ -0,0 +1,83 @@
import { fileURLToPath } from "node:url"
import { join } from "pathe"
export type DesktopE2EProfile = "local" | "prod"
const DESKTOP_E2E_PROFILES = {
local: {
apiURL: "http://localhost:3000",
webURL: "http://localhost:2233",
webBaseURL: "http://localhost:2233",
webUsesHashRouter: false,
},
prod: {
apiURL: "https://api.folo.is",
webURL: "https://app.folo.is",
webBaseURL: null,
webUsesHashRouter: true,
},
} as const
export interface DesktopE2EEnv {
profile: DesktopE2EProfile
apiURL: string
webURL: string
webBaseURL: string
webUsesHashRouter: boolean
webDevServerURL: string
debugProxyPath: string
desktopAppDir: string
}
const supportDir = fileURLToPath(new URL(".", import.meta.url))
const desktopAppDir = join(supportDir, "..", "..")
const normalizeRoute = (route: string) => {
if (!route || route === "/") {
return "/"
}
return route.startsWith("/") ? route : `/${route}`
}
export const resolveDesktopE2EEnv = (): DesktopE2EEnv => {
const profile = (process.env.FOLO_E2E_PROFILE ?? "local") as DesktopE2EProfile
const resolvedProfile = profile in DESKTOP_E2E_PROFILES ? profile : "local"
const profileConfig = DESKTOP_E2E_PROFILES[resolvedProfile]
const webDevServerURL = process.env.FOLO_E2E_WEB_DEV_SERVER_URL ?? "http://localhost:2233"
const debugProxyPath = process.env.FOLO_E2E_WEB_DEBUG_PROXY_PATH ?? "/__debug_proxy.html"
const webBaseURL =
resolvedProfile === "prod"
? new URL(
`${debugProxyPath}?debug-host=${encodeURIComponent(webDevServerURL)}`,
profileConfig.webURL,
).toString()
: profileConfig.webBaseURL
return {
profile: resolvedProfile,
apiURL: process.env.FOLO_E2E_API_URL ?? profileConfig.apiURL,
webURL: process.env.FOLO_E2E_WEB_URL ?? profileConfig.webURL,
webBaseURL,
webUsesHashRouter: profileConfig.webUsesHashRouter,
webDevServerURL,
debugProxyPath,
desktopAppDir,
}
}
export const buildWebAppURL = (env: DesktopE2EEnv, route = "/") => {
const normalizedRoute = normalizeRoute(route)
if (env.webUsesHashRouter) {
const url = new URL(env.webBaseURL)
url.hash = normalizedRoute
return url.toString()
}
return new URL(normalizedRoute, `${env.webBaseURL}/`).toString()
}
export const buildHashRoute = (route = "/") => normalizeRoute(route)

View File

@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test"
import { createTestAccount, tryDeleteCurrentUser } from "../../support/account"
import {
dismissFeedForm,
expectTimelineSwitchAndEntryReadFlow,
followOnboardingFeed,
loginWithCredential,
logoutFromProfileMenu,
registerWithCredential,
unsubscribeFirstFeedFromSettings,
} from "../../support/app"
import { closeElectronApp, launchElectronApp } from "../../support/electron"
import { resolveDesktopE2EEnv } from "../../support/env"
test.describe("electron core flows", () => {
test("covers registration, login, follow, unfollow, timeline and read state", async () => {
test.setTimeout(240_000)
const env = resolveDesktopE2EEnv()
const account = createTestAccount("electron-core")
let electronApp = await launchElectronApp(env)
try {
await test.step("registers a new account", async () => {
await registerWithCredential(electronApp.page, account)
})
await test.step("logs out and logs back in", async () => {
await logoutFromProfileMenu(electronApp.page)
await closeElectronApp(electronApp)
electronApp = await launchElectronApp(env)
await loginWithCredential(electronApp.page, account)
})
await test.step("follows onboarding feed", async () => {
await followOnboardingFeed(electronApp.page, env)
await dismissFeedForm(electronApp.page)
})
await test.step("switches timeline, opens an entry, and toggles read state", async () => {
await expectTimelineSwitchAndEntryReadFlow(electronApp.page)
})
await test.step("unsubscribes onboarding feed from settings", async () => {
await unsubscribeFirstFeedFromSettings(electronApp.page)
})
const cleanup = await tryDeleteCurrentUser(electronApp.page, env)
expect(cleanup.status).toBeGreaterThanOrEqual(-1)
test.info().annotations.push({
type: "cleanup",
description: `delete-user-custom status=${cleanup.status}`,
})
} finally {
await closeElectronApp(electronApp)
}
})
})

View File

@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test"
import { createTestAccount, tryDeleteCurrentUser } from "../../support/account"
import {
closeSettings,
dismissFeedForm,
expectOnboardingFeedUnsubscribed,
expectTimelineSwitchAndEntryReadFlow,
followOnboardingFeed,
loginWithCredential,
logoutFromProfileMenu,
openWebApp,
registerWithCredential,
unsubscribeFirstFeedFromSettings,
} from "../../support/app"
import { resolveDesktopE2EEnv } from "../../support/env"
test.describe("web core flows", () => {
test("covers registration, login, follow, unfollow, timeline and read state", async ({
page,
browser,
}) => {
test.setTimeout(180_000)
const env = resolveDesktopE2EEnv()
const account = createTestAccount("web-core")
let activePage = page
let loginContext: Awaited<ReturnType<typeof browser.newContext>> | null = null
try {
await openWebApp(activePage, env)
await test.step("registers a new account", async () => {
await registerWithCredential(activePage, account)
})
await test.step("follows onboarding feed", async () => {
await followOnboardingFeed(activePage, env)
await dismissFeedForm(activePage)
})
await test.step("logs out and logs back in", async () => {
await logoutFromProfileMenu(activePage)
loginContext = await browser.newContext()
activePage = await loginContext.newPage()
await openWebApp(activePage, env)
await loginWithCredential(activePage, account)
})
await test.step("switches timeline, opens an entry, and toggles read state", async () => {
await expectTimelineSwitchAndEntryReadFlow(activePage)
})
await test.step("unsubscribes onboarding feed from settings", async () => {
await unsubscribeFirstFeedFromSettings(activePage)
await closeSettings(activePage)
await expectOnboardingFeedUnsubscribed(activePage, env)
})
await test.step("re-subscribes onboarding feed", async () => {
await followOnboardingFeed(activePage, env)
await dismissFeedForm(activePage)
})
await test.step("tries to clean up the temporary account", async () => {
const cleanup = await tryDeleteCurrentUser(activePage, env)
expect(cleanup.status).toBeGreaterThanOrEqual(-1)
test.info().annotations.push({
type: "cleanup",
description: `delete-user-custom status=${cleanup.status}`,
})
})
} finally {
await loginContext?.close().catch(() => {})
}
})
})

View File

@ -0,0 +1,94 @@
import type { BrowserContext } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { createTestAccount, tryDeleteCurrentUser } from "../../support/account"
import {
getLanguageLabel,
loginWithCredential,
openSettings,
openWebApp,
registerWithCredential,
setLanguage,
} from "../../support/app"
import { resolveDesktopE2EEnv } from "../../support/env"
const closeContextSafely = async (context: BrowserContext) => {
try {
await context.close()
} catch (error) {
if (error instanceof Error && error.message.includes("ENOENT")) {
return
}
throw error
}
}
test.describe("web multi-session sync", () => {
test("syncs settings between two browser sessions", async ({ browser }) => {
test.setTimeout(180_000)
const env = resolveDesktopE2EEnv()
const account = createTestAccount("web-sync")
const contextA = await browser.newContext()
const contextB = await browser.newContext()
const pageA = await contextA.newPage()
const pageB = await contextB.newPage()
try {
await openWebApp(pageA, env)
await registerWithCredential(pageA, account)
await openWebApp(pageB, env)
await loginWithCredential(pageB, account)
await openSettings(pageA)
await openSettings(pageB)
await test.step("session A change syncs to session B", async () => {
await setLanguage(pageA, "日本語")
await expect
.poll(async () => getLanguageLabel(pageA), { timeout: 15_000 })
.toContain("日本語")
await expect
.poll(
async () => {
await pageB.reload({ waitUntil: "domcontentloaded" })
await openSettings(pageB)
return getLanguageLabel(pageB)
},
{ timeout: 30_000 },
)
.toContain("日本語")
})
await test.step("session B change syncs back to session A", async () => {
await setLanguage(pageB, "English")
await expect
.poll(async () => getLanguageLabel(pageB), { timeout: 15_000 })
.toContain("English")
await expect
.poll(
async () => {
await pageA.reload({ waitUntil: "domcontentloaded" })
await openSettings(pageA)
return getLanguageLabel(pageA)
},
{ timeout: 60_000 },
)
.toContain("English")
})
const cleanup = await tryDeleteCurrentUser(pageA, env)
expect(cleanup.status).toBeGreaterThanOrEqual(-1)
test.info().annotations.push({
type: "cleanup",
description: `delete-user-custom status=${cleanup.status}`,
})
} finally {
await closeContextSafely(contextA)
await closeContextSafely(contextB)
}
})
})

View File

@ -102,7 +102,10 @@ const config: ForgeConfig = {
buildVersion: process.env.BUILD_VERSION || undefined,
appBundleId: "is.follow",
icon: isStaging ? "resources/icon-staging" : "resources/icon",
extraResource: ["./resources/app-update.yml"],
extraResource: [
"./resources/app-update.yml",
...(fs.existsSync("./resources/cli") ? ["./resources/cli"] : []),
],
protocols: [
{
name: "Folo",

View File

@ -11,9 +11,11 @@
},
"exports": {
".": {
"import": "./export.js"
"types": "./dist/export.d.ts",
"import": "./dist/export.js"
}
},
"types": "./dist/export.d.ts",
"scripts": {
"build": "tsc",
"test": "vitest",

View File

@ -29,6 +29,7 @@ export const isWindows11 = detectingWindows11()
// Custom APIs for renderer
const api = {
canWindowBlur: process.platform === "darwin" || (process.platform === "win32" && isWindows11),
isWindowsStore: Boolean((process as typeof process & { windowsStore?: boolean }).windowsStore),
}
// Use `contextBridge` APIs to expose Electron APIs to

View File

@ -1,7 +1,14 @@
import { app, protocol } from "electron"
import path from "pathe"
if (import.meta.env.DEV) app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)"))
const e2eUserDataDir = process.env.FOLO_E2E_USER_DATA_DIR
if (e2eUserDataDir) {
app.setPath("userData", e2eUserDataDir)
} else if (import.meta.env.DEV) {
app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)"))
}
protocol.registerSchemesAsPrivileged([
{
scheme: "app",

View File

@ -3,6 +3,7 @@ import { createServices } from "electron-ipc-decorator"
import { AppService } from "./services/app"
import { AuthService } from "./services/auth"
import { CliService } from "./services/cli"
import { DebugService } from "./services/debug"
import { DockService } from "./services/dock"
import { IntegrationService } from "./services/integration"
@ -14,6 +15,7 @@ import { SettingService } from "./services/setting"
const services = createServices([
AppService,
AuthService,
CliService,
DebugService,
DockService,
MenuService,

View File

@ -1,18 +1,179 @@
import { env } from "@follow/shared/env.desktop"
import { createDesktopAPIHeaders } from "@follow/utils/headers"
import PKG from "@pkg"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
import { WindowManager } from "~/manager/window"
import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../../lib/cli-session-sync"
import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user"
import { logger } from "../../logger"
export class AuthService extends IpcService {
static override readonly groupName = "auth"
private async applySessionToken(token: string): Promise<void> {
const mainWindow = WindowManager.getMainWindow()
if (!mainWindow || !token) {
return
}
const apiURL = env.VITE_API_URL
const url = new URL(apiURL)
const isSecure = url.protocol === "https:"
const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1"
await mainWindow.webContents.session.cookies.set({
url: apiURL,
name: BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN,
value: token,
...(isLocalhost ? {} : { domain: url.hostname }),
path: "/",
httpOnly: true,
secure: isSecure,
sameSite: "no_restriction",
expirationDate: new Date().setDate(new Date().getDate() + 30),
})
}
private async clearSessionToken(): Promise<void> {
const mainWindow = WindowManager.getMainWindow()
if (!mainWindow) {
return
}
const { session } = mainWindow.webContents
const apiURL = env.VITE_API_URL
await Promise.allSettled([
session.cookies.remove(apiURL, BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN),
session.cookies.remove(apiURL, "__Secure-better-auth.session_token"),
session.cookies.remove(apiURL, "better-auth.last_used_login_method"),
])
}
private async requestCredentialAuth(
path: "/sign-in/email" | "/sign-up/email",
payload: Record<string, unknown>,
headers?: Record<string, string>,
) {
const response = await fetch(`${env.VITE_API_URL}/better-auth${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
...createDesktopAPIHeaders({ version: PKG.version }),
...headers,
},
body: JSON.stringify(payload),
})
const data = (await response
.json()
.catch(async () => ({ message: await response.text() }))) as Record<string, unknown>
const setCookie = response.headers.get("set-cookie") || ""
const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/)
const sessionToken = sessionCookieMatch?.[1] ?? null
const token = typeof data.token === "string" ? data.token : null
const persistedSessionToken = sessionToken ?? token
if (response.ok && persistedSessionToken) {
void this.applySessionToken(persistedSessionToken).catch(() => {})
}
if (sessionToken) {
data.sessionToken = sessionToken
}
return {
data,
error: response.ok
? null
: {
message: typeof data.message === "string" ? data.message : response.statusText,
status: response.status,
},
}
}
@IpcMethod()
async sessionChanged(_context: IpcContext): Promise<void> {
await updateNotificationsToken()
// Sync session token to CLI config
const token = await getSessionTokenFromCookies()
await syncSessionToCliConfig(token).catch((err) => {
logger.error("Failed to sync session to CLI config:", err)
})
}
@IpcMethod()
async signOut(_context: IpcContext): Promise<void> {
await deleteNotificationsToken()
// Clear CLI config token on sign out
await syncSessionToCliConfig().catch((err) => {
logger.error("Failed to clear CLI config token:", err)
})
}
@IpcMethod()
async signOutRemote(_context: IpcContext, token?: string): Promise<void> {
await fetch(`${env.VITE_API_URL}/better-auth/sign-out`, {
method: "POST",
headers: {
...createDesktopAPIHeaders({ version: PKG.version }),
...(token
? {
Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`,
}
: {}),
},
}).catch(() => {})
await this.clearSessionToken()
}
@IpcMethod()
async signInWithCredential(
_context: IpcContext,
payload: { email: string; password: string; headers?: Record<string, string> },
) {
return this.requestCredentialAuth(
"/sign-in/email",
{
email: payload.email,
password: payload.password,
},
payload.headers,
)
}
@IpcMethod()
async signUpWithCredential(
_context: IpcContext,
payload: {
email: string
password: string
name: string
callbackURL: string
headers?: Record<string, string>
},
) {
return this.requestCredentialAuth(
"/sign-up/email",
{
email: payload.email,
password: payload.password,
name: payload.name,
callbackURL: payload.callbackURL,
},
payload.headers,
)
}
@IpcMethod()
async setSessionToken(_context: IpcContext, token: string): Promise<void> {
await this.applySessionToken(token)
}
}

View File

@ -0,0 +1,222 @@
import { execSync } from "node:child_process"
import { existsSync, lstatSync, readlinkSync } from "node:fs"
import { unlink, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { app } from "electron"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
import path from "pathe"
import { logger } from "../../logger"
const CLI_BINARY_NAME = "folo"
const getDefaultInstallDir = (): string => {
switch (process.platform) {
case "win32": {
return path.join(process.env.LOCALAPPDATA || "", "Folo", "bin")
}
default: {
return "/usr/local/bin"
}
}
}
const getCliSourcePath = (): string => {
if (app.isPackaged) {
return path.join(process.resourcesPath, "cli", "index.js")
}
// In dev, read from the cli workspace directly
return path.resolve(app.getAppPath(), "../../cli/dist/index.js")
}
const getCliInstallPath = (): string => {
return path.join(getDefaultInstallDir(), CLI_BINARY_NAME)
}
/** Check whether the CLI is present at the given path (also checks .cmd on Windows). */
const cliExistsAt = (installPath: string): boolean => {
if (existsSync(installPath)) return true
if (process.platform === "win32" && existsSync(`${installPath}.cmd`)) return true
return false
}
export interface CliInstallStatus {
installed: boolean
installPath: string | null
cliSourceAvailable: boolean
}
export class CliService extends IpcService {
static override readonly groupName = "cli"
@IpcMethod()
async getInstallStatus(_context: IpcContext): Promise<CliInstallStatus> {
const installPath = getCliInstallPath()
const cliSourcePath = getCliSourcePath()
const cliSourceAvailable = existsSync(cliSourcePath)
try {
if (!cliExistsAt(installPath)) {
return { installed: false, installPath: null, cliSourceAvailable }
}
if (existsSync(installPath)) {
const stats = lstatSync(installPath)
if (stats.isSymbolicLink()) {
const target = readlinkSync(installPath)
const isOurs = target.includes("Folo") || target.includes("cli/index.js")
return { installed: isOurs, installPath, cliSourceAvailable }
}
}
// Exists as a regular file (wrapper script or .cmd on Windows)
return { installed: true, installPath, cliSourceAvailable }
} catch {
return { installed: false, installPath: null, cliSourceAvailable }
}
}
@IpcMethod()
async installCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> {
const cliSource = getCliSourcePath()
if (!existsSync(cliSource)) {
return { success: false, error: "CLI bundle not found in app resources" }
}
const installPath = getCliInstallPath()
const wrapperContent = `#!/bin/sh\nexec /usr/bin/env node "${cliSource}" "$@"\n`
if (process.platform === "win32") {
return this.installCliWindows(cliSource, installPath)
}
try {
// Try without elevated permissions first
await writeFile(installPath, wrapperContent, { mode: 0o755 })
logger.info(`CLI installed at ${installPath}`)
return { success: true }
} catch {
// Write to a temp file first, then use admin privileges to copy it.
// This avoids shell-expansion issues with $@ in the wrapper content.
const tmpFile = path.join(tmpdir(), `folo-cli-wrapper-${Date.now()}`)
try {
await writeFile(tmpFile, wrapperContent, { mode: 0o755 })
if (process.platform === "darwin") {
execSync(
`osascript -e 'do shell script "cp \\"${tmpFile}\\" \\"${installPath}\\" && chmod +x \\"${installPath}\\"" with administrator privileges'`,
)
} else {
// Linux: use pkexec
execSync(`pkexec sh -c 'cp "${tmpFile}" "${installPath}" && chmod +x "${installPath}"'`)
}
logger.info(`CLI installed at ${installPath} (with elevated privileges)`)
return { success: true }
} catch (err) {
logger.error("Failed to install CLI with elevated privileges:", err)
return {
success: false,
error: err instanceof Error ? err.message : "Failed to install CLI",
}
} finally {
await unlink(tmpFile).catch(() => {})
}
}
}
@IpcMethod()
async uninstallCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> {
const installPath = getCliInstallPath()
if (!cliExistsAt(installPath)) {
return { success: true }
}
if (process.platform === "win32") {
return this.uninstallCliWindows(installPath)
}
try {
await unlink(installPath)
logger.info(`CLI uninstalled from ${installPath}`)
return { success: true }
} catch {
// Needs elevated permissions
if (process.platform === "darwin") {
try {
execSync(
`osascript -e 'do shell script "rm -f \\"${installPath}\\"" with administrator privileges'`,
)
logger.info(`CLI uninstalled from ${installPath} (with admin privileges)`)
return { success: true }
} catch (err) {
logger.error("Failed to uninstall CLI with admin privileges:", err)
return {
success: false,
error: err instanceof Error ? err.message : "Failed to uninstall CLI",
}
}
}
try {
execSync(`pkexec rm -f "${installPath}"`)
logger.info(`CLI uninstalled from ${installPath} (with pkexec)`)
return { success: true }
} catch (err) {
logger.error("Failed to uninstall CLI:", err)
return {
success: false,
error: err instanceof Error ? err.message : "Failed to uninstall CLI",
}
}
}
}
private async installCliWindows(
cliSource: string,
installPath: string,
): Promise<{ success: boolean; error?: string }> {
const installDir = path.dirname(installPath)
const cmdContent = `@echo off\r\nnode "${cliSource}" %*\r\n`
try {
const { mkdirSync, writeFileSync } = await import("node:fs")
mkdirSync(installDir, { recursive: true })
writeFileSync(`${installPath}.cmd`, cmdContent)
logger.info(`CLI installed at ${installPath}.cmd`)
return { success: true }
} catch (err) {
logger.error("Failed to install CLI on Windows:", err)
return {
success: false,
error: err instanceof Error ? err.message : "Failed to install CLI",
}
}
}
private async uninstallCliWindows(
installPath: string,
): Promise<{ success: boolean; error?: string }> {
try {
const { unlinkSync } = await import("node:fs")
const cmdPath = `${installPath}.cmd`
if (existsSync(cmdPath)) {
unlinkSync(cmdPath)
}
if (existsSync(installPath)) {
unlinkSync(installPath)
}
logger.info(`CLI uninstalled from ${installPath}`)
return { success: true }
} catch (err) {
logger.error("Failed to uninstall CLI on Windows:", err)
return {
success: false,
error: err instanceof Error ? err.message : "Failed to uninstall CLI",
}
}
}
}

View File

@ -0,0 +1,61 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { homedir } from "node:os"
import { env } from "@follow/shared/env.desktop"
import { join } from "pathe"
import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app"
import { WindowManager } from "~/manager/window"
import { logger } from "../logger"
const CLI_CONFIG_DIR = join(homedir(), ".folo")
const CLI_CONFIG_PATH = join(CLI_CONFIG_DIR, "config.json")
interface CliConfig {
token?: string
apiUrl?: string
}
const readCliConfig = async (): Promise<CliConfig> => {
try {
const raw = await readFile(CLI_CONFIG_PATH, "utf8")
return JSON.parse(raw) as CliConfig
} catch {
return {}
}
}
const writeCliConfig = async (config: CliConfig): Promise<void> => {
await mkdir(CLI_CONFIG_DIR, { recursive: true })
await writeFile(CLI_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8")
}
export const getSessionTokenFromCookies = async (): Promise<string | undefined> => {
const window = WindowManager.getMainWindow()
if (!window) return undefined
const cookies = await window.webContents.session.cookies.get({
domain: new URL(env.VITE_API_URL).hostname,
})
const sessionCookie = cookies.find((cookie) =>
cookie.name.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN),
)
return sessionCookie?.value
}
export const syncSessionToCliConfig = async (token?: string): Promise<void> => {
const config = await readCliConfig()
if (token) {
config.token = token
config.apiUrl = env.VITE_API_URL
} else {
delete config.token
}
await writeCliConfig(config)
logger.info(`CLI config synced (token ${token ? "set" : "cleared"})`)
}

View File

@ -11,6 +11,7 @@ import { WindowManager } from "~/manager/window"
import { getIconPath } from "../helper"
import { initializeIpcServices } from "../ipc"
import { checkAndCleanCodeCache, clearCacheCronJob } from "../lib/cleaner"
import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../lib/cli-session-sync"
import { t } from "../lib/i18n"
import { updateProxy } from "../lib/proxy"
import { store } from "../lib/store"
@ -47,6 +48,18 @@ class AppManagerStatic {
updateProxy()
registerUpdater()
registerAppTray()
// Sync session to CLI config after window and cookies are ready
setTimeout(async () => {
try {
const token = await getSessionTokenFromCookies()
if (token) {
await syncSessionToCliConfig(token)
}
} catch (err) {
logger.error("Failed to sync session to CLI on startup:", err)
}
}, 5000)
}
private registerProtocols() {

View File

@ -3,7 +3,7 @@ import type { ElectronAPI } from "@electron-toolkit/preload"
declare global {
interface Window {
electron?: ElectronAPI
api?: { canWindowBlur: boolean }
api?: { canWindowBlur: boolean; isWindowsStore: boolean }
platform: NodeJS.Platform
}
export const APP_NAME = "Folo"

View File

@ -21,6 +21,8 @@ export const CommandActionButton = ({
<ActionButton
ref={ref}
{...rest}
data-command-id={commandId}
data-testid={`command-action-${commandId.replaceAll(":", "-")}`}
tooltip={label.title}
tooltipDescription={label.description}
icon={icon}

View File

@ -14,6 +14,7 @@ export interface GlassButtonProps {
description?: string
onClick?: () => void
className?: string
testId?: string
children: ReactNode
/**
* Custom animation variants for hover and tap states
@ -157,6 +158,7 @@ export const GlassButton: FC<GlassButtonProps> = ({
description,
onClick,
className,
testId,
children,
hoverScale = 1.1,
tapScale = 0.95,
@ -168,6 +170,7 @@ export const GlassButton: FC<GlassButtonProps> = ({
<Tooltip>
<TooltipTrigger asChild>
<m.button
data-testid={testId}
type="button"
onClick={(e) => {
e.stopPropagation()

View File

@ -9,6 +9,7 @@ export const ModalClose = () => {
return (
<MotionButtonBase
data-testid="modal-close"
aria-label={t("words.close")}
className="absolute right-6 top-6 flex size-8 items-center justify-center rounded-md duration-200 hover:bg-material-ultra-thick"
onClick={dismiss}

View File

@ -390,6 +390,7 @@ export const ModalInternal = memo(function Modal({
</Dialog.Title>
{canClose && (
<Dialog.DialogClose
data-testid="modal-close"
className="center z-[2] -mr-1 rounded-lg p-2 text-text-secondary hover:bg-fill-quaternary hover:text-text"
tabIndex={1}
onClick={close}

View File

@ -1,11 +1,21 @@
import { useCallback } from "react"
import { useGoogleReCaptcha } from "react-google-recaptcha-v3"
type FoloE2EWindow = Window &
typeof globalThis & {
__FOLO_E2E_RECAPTCHA_TOKEN__?: string
}
export const useRecaptchaToken = () => {
const { executeRecaptcha } = useGoogleReCaptcha()
return useCallback(
async (action: string) => {
const e2eToken = (window as FoloE2EWindow).__FOLO_E2E_RECAPTCHA_TOKEN__
if (e2eToken) {
return e2eToken
}
if (!executeRecaptcha) {
return null
}

View File

@ -2,6 +2,8 @@ import { initializeDayjs } from "@follow/components/dayjs"
import { registerGlobalContext } from "@follow/shared/bridge"
import { DEV, ELECTRON_BUILD, IN_ELECTRON } from "@follow/shared/constants"
import { hydrateDatabaseToStore } from "@follow/store/hydrate"
import { whoami } from "@follow/store/user/getters"
import { userSyncService } from "@follow/store/user/store"
import { tracker } from "@follow/tracker"
import { repository } from "@pkg"
import { enableMapSet } from "immer"
@ -82,14 +84,25 @@ export const initializeApp = async () => {
apm("initializeSettings", initializeSettings)
await apm("i18n", initI18n)
apm("setting sync", () => {
settingSyncQueue.init()
settingSyncQueue.syncLocal()
})
await apm("initAnalytics", initAnalytics)
void apm("setting sync", async () => {
await settingSyncQueue.init()
await userSyncService.whoami().catch(() => null)
if (!whoami()) {
return
}
await settingSyncQueue.syncLocal()
}).catch((error) => {
appLog("setting sync failed", error)
void tracker.manager.captureException(error, {
module: "setting_sync",
stage: "bootstrap",
})
})
const loadingTime = Date.now() - now
appLog(`Initialize ${APP_NAME} done,`, `${loadingTime}ms`)

View File

@ -1,4 +1,5 @@
import { env } from "@follow/shared/env.desktop"
import { whoami } from "@follow/store/user/getters"
import { userActions } from "@follow/store/user/store"
import { createDesktopAPIHeaders } from "@follow/utils/headers"
import { FollowClient } from "@follow-app/client-sdk"
@ -58,6 +59,12 @@ followClient.addErrorInterceptor(async ({ error, response }) => {
followClient.addResponseInterceptor(async ({ response }) => {
if (response.status === 401) {
const shouldPromptForLogin = response.url.includes("/better-auth/get-session") || !whoami()
if (!shouldPromptForLogin) {
return response
}
// Or we can present LoginModal here.
// router.navigate("/login")
// If any response status is 401, we can set auth fail. Maybe some bug, but if navigate to login page, had same issues

View File

@ -1,8 +1,11 @@
import { Auth } from "@follow/shared/auth"
import { IN_ELECTRON } from "@follow/shared/constants"
import { env } from "@follow/shared/env.desktop"
import { createDesktopAPIHeaders } from "@follow/utils/headers"
import PKG from "@pkg"
import { getAuthSessionToken } from "./client-session"
const headers = createDesktopAPIHeaders({ version: PKG.version })
const auth = new Auth({
@ -10,6 +13,15 @@ const auth = new Auth({
webURL: env.VITE_WEB_URL,
fetchOptions: {
headers,
onRequest: (context) => {
const authSessionToken = IN_ELECTRON ? getAuthSessionToken() : null
if (authSessionToken) {
context.headers.set(
"Cookie",
`__Secure-better-auth.session_token=${authSessionToken}; better-auth.session_token=${authSessionToken}`,
)
}
},
},
})

View File

@ -3,6 +3,7 @@ import { nanoid } from "nanoid"
const CLIENT_ID_KEY = getStorageNS("client_id")
const SESSION_ID_KEY = getStorageNS("session_id")
const AUTH_SESSION_TOKEN_KEY = getStorageNS("auth_session_token")
export const getClientId = (): string => {
const clientId = localStorage.getItem(CLIENT_ID_KEY)
@ -31,3 +32,15 @@ export const clearSessionId = (): void => {
export const clearClientId = (): void => {
localStorage.removeItem(CLIENT_ID_KEY)
}
export const getAuthSessionToken = (): string | null => {
return localStorage.getItem(AUTH_SESSION_TOKEN_KEY)
}
export const setAuthSessionToken = (token: string): void => {
localStorage.setItem(AUTH_SESSION_TOKEN_KEY, token)
}
export const clearAuthSessionToken = (): void => {
localStorage.removeItem(AUTH_SESSION_TOKEN_KEY)
}

View File

@ -2,7 +2,7 @@ import { clamp, cn } from "@follow/utils"
import Spline from "@splinetool/react-spline"
import { useCallback, useRef } from "react"
const resolvedAIIconUrl = "https://cdn.follow.is/ai2.splinecode"
const resolvedAIIconUrl = "https://assets.folo.is/ai2.splinecode"
export const AISplineLoader = ({ className }: { className?: string }) => {
const containerRef = useRef<HTMLDivElement>(null)

View File

@ -170,6 +170,7 @@ function SubviewLayoutInner() {
<LinearBlur className="absolute inset-0 z-[-1]" tint="var(--fo-background)" side="top" />
{/* Left: Back button (circular, glass) */}
<GlassButton
testId="subview-back"
description={t("words.back", { ns: "common" })}
onClick={backHandler}
className={cn(

View File

@ -21,6 +21,8 @@ import { z } from "zod"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useRecaptchaToken } from "~/hooks/common"
import { loginHandler, signUp, twoFactor } from "~/lib/auth"
import { ipcServices } from "~/lib/client"
import { setAuthSessionToken } from "~/lib/client-session"
import { handleSessionChanges } from "~/queries/auth"
import { TOTPForm } from "../profile/two-factor"
@ -30,6 +32,87 @@ const formSchema = z.object({
password: IN_ELECTRON ? z.string().min(8).max(128) : z.string().min(8).max(128).or(z.literal("")),
})
const getAuthTokenFromResult = (result: unknown) => {
if (!result || typeof result !== "object") {
return null
}
if ("sessionToken" in result && typeof result.sessionToken === "string") {
return result.sessionToken
}
if ("token" in result && typeof result.token === "string") {
return result.token
}
if (
"data" in result &&
result.data &&
typeof result.data === "object" &&
("sessionToken" in result.data || "token" in result.data)
) {
const { sessionToken, token } = result.data as { sessionToken?: unknown; token?: unknown }
if (typeof sessionToken === "string") {
return sessionToken
}
return typeof token === "string" ? token : null
}
return null
}
type ElectronAuthResult = {
data?: Record<string, unknown>
error?: {
message: string
status?: number
} | null
}
const normalizeElectronAuthResult = (result: unknown): ElectronAuthResult => {
if (!result || typeof result !== "object") {
return {}
}
return result as ElectronAuthResult
}
const setElectronSessionToken = async (token: string) => {
if (!ipcServices) {
return
}
const authService = ipcServices.auth as
| (typeof ipcServices.auth & {
setSessionToken?: (token: string) => Promise<void>
})
| undefined
await authService?.setSessionToken?.(token)
}
const getElectronAuthService = () => {
if (!ipcServices) {
return null
}
return ipcServices.auth as typeof ipcServices.auth & {
setSessionToken?: (token: string) => Promise<void>
signInWithCredential?: (payload: {
email: string
password: string
headers?: Record<string, string>
}) => Promise<unknown>
signUpWithCredential?: (payload: {
email: string
password: string
name: string
callbackURL: string
headers?: Record<string, string>
}) => Promise<unknown>
}
}
export function LoginWithPassword({
runtime,
onLoginStateChange,
@ -75,11 +158,19 @@ export function LoginWithPassword({
}
// Use password authentication
const res = await loginHandler("credential", runtime, {
email: values.email,
password: values.password,
headers,
})
const res = IN_ELECTRON
? normalizeElectronAuthResult(
await getElectronAuthService()?.signInWithCredential?.({
email: values.email,
password: values.password,
headers,
}),
)
: await loginHandler("credential", runtime, {
email: values.email,
password: values.password,
headers,
})
if (res?.error) {
toast.error(res.error.message)
return
@ -105,13 +196,20 @@ export function LoginWithPassword({
},
})
} else {
if (IN_ELECTRON) {
const token = getAuthTokenFromResult(res)
if (token) {
setAuthSessionToken(token)
void setElectronSessionToken(token)
}
}
handleSessionChanges()
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form data-testid="login-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
@ -119,7 +217,7 @@ export function LoginWithPassword({
<FormItem>
<FormLabel>{t("login.email")}</FormLabel>
<FormControl>
<Input type="email" {...field} />
<Input data-testid="login-email-input" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -147,7 +245,7 @@ export function LoginWithPassword({
</a>
</FormLabel>
<FormControl>
<Input type="password" {...field} />
<Input data-testid="login-password-input" type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -155,6 +253,7 @@ export function LoginWithPassword({
/>
<div className="flex flex-col space-y-3">
<Button
data-testid="login-submit"
type="submit"
isLoading={form.formState.isSubmitting}
disabled={!form.formState.isValid}
@ -172,6 +271,7 @@ export function LoginWithPassword({
<div className="flex items-center justify-center gap-1 pb-2 text-center text-sm">
If you don't have an account,{" "}
<button
data-testid="login-switch-register"
type="button"
className="flex cursor-pointer items-center gap-1 text-accent hover:underline"
onClick={() => onLoginStateChange("register")}
@ -241,27 +341,54 @@ export function RegisterForm({
return
}
return signUp.email({
email: values.email,
password: values.password,
name: values.email.split("@")[0]!,
callbackURL: "/",
fetchOptions: {
onSuccess() {
handleSessionChanges()
},
onError(context) {
toast.error(context.error.message)
},
headers,
},
})
const result = IN_ELECTRON
? normalizeElectronAuthResult(
await getElectronAuthService()?.signUpWithCredential?.({
email: values.email,
password: values.password,
name: values.email.split("@")[0]!,
callbackURL: "/",
headers,
}),
)
: await signUp.email({
email: values.email,
password: values.password,
name: values.email.split("@")[0]!,
callbackURL: "/",
fetchOptions: {
onError(context) {
toast.error(context.error.message)
},
headers,
},
})
if (result?.error) {
return result
}
if (IN_ELECTRON) {
const token = getAuthTokenFromResult(result)
if (token) {
setAuthSessionToken(token)
void setElectronSessionToken(token)
}
}
handleSessionChanges()
return result
}
return (
<div className="relative">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form
data-testid="register-form"
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
@ -269,7 +396,7 @@ export function RegisterForm({
<FormItem>
<FormLabel>{t("register.email")}</FormLabel>
<FormControl>
<Input type="email" {...field} />
<Input data-testid="register-email-input" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -286,7 +413,7 @@ export function RegisterForm({
: `${t("register.password")} (${t("register.password_optional")})`}
</FormLabel>
<FormControl>
<Input type="password" {...field} />
<Input data-testid="register-password-input" type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -303,13 +430,14 @@ export function RegisterForm({
: `${t("register.confirm_password")} (${t("register.password_optional")})`}
</FormLabel>
<FormControl>
<Input type="password" {...field} />
<Input data-testid="register-confirm-password-input" type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
data-testid="register-submit"
type="submit"
buttonClassName="w-full"
size="lg"
@ -327,6 +455,7 @@ export function RegisterForm({
<div className="flex items-center justify-center gap-1 pb-2 text-center text-sm">
If you already have an account,{" "}
<button
data-testid="register-switch-login"
type="button"
className="flex cursor-pointer items-center gap-1 text-accent hover:underline"
onClick={() => onLoginStateChange("login")}

View File

@ -9,11 +9,12 @@ import type { LoginRuntime } from "@follow/shared/auth"
import { stopPropagation } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import { m } from "motion/react"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { Trans, useTranslation } from "react-i18next"
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
import { authClient, loginHandler } from "~/lib/auth"
import { useSession } from "~/queries/auth"
import { useAuthProviders } from "~/queries/users"
import { LoginWithPassword, RegisterForm } from "./Form"
@ -32,10 +33,34 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
const { t } = useTranslation()
const { data: authProviders, isLoading } = useAuthProviders()
const { status } = useSession()
const isMobile = useMobile()
const providers = Object.entries(authProviders || [])
const providers = Object.entries(authProviders || {})
const effectiveProviders = useMemo(() => {
if (providers.some(([key]) => key === "credential")) {
return providers
}
return providers.concat([
[
"credential",
{
name: t("words.email"),
id: "credential",
color: "",
icon: "",
icon64: "",
},
],
])
}, [providers, t])
const visibleProviders = useMemo(
() =>
isLoading ? effectiveProviders.filter(([key]) => key === "credential") : effectiveProviders,
[effectiveProviders, isLoading],
)
const [isRegister, setIsRegister] = useState(true)
const [isEmail, setIsEmail] = useState(false)
@ -75,6 +100,12 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
}
}, [lastMethod])
useEffect(() => {
if (status === "authenticated") {
modal.dismiss()
}
}, [modal, status])
const Inner = (
<>
{isEmail && (
@ -85,6 +116,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
transition={Spring.presets.smooth}
>
<MotionButtonBase
data-testid="auth-back"
className="flex cursor-button items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium duration-200 hover:bg-fill-secondary"
onClick={() => setIsEmail(false)}
>
@ -149,58 +181,55 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<div className="flex flex-col gap-4">
{/* Login Providers */}
<div className="flex flex-col gap-2.5">
{isLoading
? // Skeleton loaders to prevent CLS
Array.from({ length: 4 }, (_, index) => (
<div
key={`login-skeleton-${index}`}
className="relative h-12 w-full animate-pulse rounded-xl border border-fill-secondary bg-material-medium"
/>
))
: providers.map(([key, provider], index) => (
<m.div
key={key}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
>
<button
type="button"
onClick={() => {
if (key === "credential") {
setIsEmail(true)
} else {
loginHandler(key, "app")
}
}}
className="group center relative w-full gap-2 rounded-xl border border-border bg-material-medium py-3.5 pl-5 font-medium backdrop-blur-sm transition-all duration-200 hover:border-folo/30 hover:bg-folo/10"
>
<img
className={cn(
"absolute left-7 size-5 object-contain",
!provider.iconDark64 &&
"dark:brightness-[0.85] dark:hue-rotate-180 dark:invert",
)}
src={isDark ? provider.iconDark64 || provider.icon64 : provider.icon64}
alt={provider.name}
/>
<span className="relative z-10">
{t("login.continueWith", { provider: provider.name })}
</span>
{lastMethod === key && (
<m.div
className="absolute -right-2 -top-2 z-20 rounded-lg bg-accent px-2.5 py-1 text-xs font-medium text-white"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={Spring.presets.bouncy}
>
{t("login.lastUsed")}
</m.div>
{visibleProviders.map(([key, provider], index) => (
<m.div
key={key}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
>
<button
data-testid={`login-provider-${key}`}
type="button"
onClick={() => {
if (key === "credential") {
setIsEmail(true)
} else {
loginHandler(key, "app")
}
}}
className="group center relative w-full gap-2 rounded-xl border border-border bg-material-medium py-3.5 pl-5 font-medium backdrop-blur-sm transition-all duration-200 hover:border-folo/30 hover:bg-folo/10"
>
{provider.icon64 ? (
<img
className={cn(
"absolute left-7 size-5 object-contain",
!provider.iconDark64 &&
"dark:brightness-[0.85] dark:hue-rotate-180 dark:invert",
)}
</button>
</m.div>
))}
src={isDark ? provider.iconDark64 || provider.icon64 : provider.icon64}
alt={provider.name}
/>
) : (
<i className="i-mgc-mail-cute-re absolute left-7 size-5 text-text-secondary" />
)}
<span className="relative z-10">
{t("login.continueWith", { provider: provider.name })}
</span>
{lastMethod === key && (
<m.div
className="absolute -right-2 -top-2 z-20 rounded-lg bg-accent px-2.5 py-1 text-xs font-medium text-white"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={Spring.presets.bouncy}
>
{t("login.lastUsed")}
</m.div>
)}
</button>
</m.div>
))}
</div>
{/* Footer Links */}
@ -250,6 +279,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
{/* Switch Account Type */}
<m.button
data-testid={isRegister ? "register-switch-login" : "login-switch-register"}
className="group w-full cursor-pointer pb-2 text-center text-sm font-medium transition-colors"
onClick={() => setIsRegister(!isRegister)}
whileHover={{ scale: 1.02 }}
@ -284,6 +314,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<div
onClick={stopPropagation}
tabIndex={-1}
data-testid="login-modal"
className="relative w-[28rem] overflow-hidden rounded-2xl border border-folo/20 bg-background p-6 shadow-2xl shadow-folo/10 backdrop-blur-xl"
>
{/* Inner glow layer */}

View File

@ -31,6 +31,8 @@ import { followClient } from "~/lib/api-client"
import { DiscoverFeedCard } from "./DiscoverFeedCard"
import { FeedForm } from "./FeedForm"
const isFeedLikeUrl = (value: string) => /^(?:https?:\/\/|folo:\/\/|follow:\/\/)/.test(value.trim())
const FEED_DISCOVERY_INFO = {
search: {
label: "discover.any_url_or_keyword",
@ -56,7 +58,9 @@ const FEED_DISCOVERY_INFO = {
</a>
),
schema: z.object({
keyword: z.string().url().startsWith("https://"),
keyword: z.string().refine(isFeedLikeUrl, {
message: "Invalid RSS URL",
}),
}),
},
rsshub: {
@ -301,6 +305,7 @@ export function DiscoverForm({ type = "search" }: { type?: string }) {
<FormControl>
<Input
autoFocus
data-testid="discover-form-input"
{...field}
onChange={handleKeywordChange}
onCompositionEnd={handleCompositionEnd}
@ -353,6 +358,7 @@ export function DiscoverForm({ type = "search" }: { type?: string }) {
)}
<div className="center flex" data-testid="discover-form-actions">
<Button
data-testid="discover-form-submit"
disabled={!form.formState.isValid}
type="submit"
isLoading={mutation.isPending}

View File

@ -347,6 +347,7 @@ const FeedInnerForm = ({
<Form {...form}>
<form
id="feed-form"
data-testid="feed-form"
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col gap-y-4 px-1"
>
@ -361,7 +362,11 @@ const FeedInnerForm = ({
</div>
<FormControl>
<div className="flex gap-2">
<Input placeholder={feed.title || undefined} {...field} />
<Input
data-testid="feed-form-title-input"
placeholder={feed.title || undefined}
{...field}
/>
<Button
buttonClassName="shrink-0"
type="button"
@ -480,6 +485,7 @@ const FeedInnerForm = ({
{isSubscribed && (
<Button
disabled={!isLoggedIn}
data-testid="feed-form-cancel"
type="button"
variant="ghost"
onClick={() => {
@ -491,6 +497,7 @@ const FeedInnerForm = ({
)}
<Button
disabled={!isLoggedIn}
data-testid="feed-form-submit"
form="feed-form"
type="submit"
isLoading={followMutation.isPending}

View File

@ -40,13 +40,18 @@ import { DiscoverTransform } from "./DiscoverTransform"
import { DiscoverUser } from "./DiscoverUser"
import { FeedForm } from "./FeedForm"
const isFeedLikeUrl = (value: string) => {
const trimmed = value.trim()
return /^(?:https?:\/\/|rsshub:\/\/|folo:\/\/|follow:\/\/)/.test(trimmed)
}
// Auto-detect input type
function detectInputType(value: string): "rss" | "rsshub" | "search" {
const trimmed = value.trim()
if (trimmed.startsWith("rsshub://")) {
return "rsshub"
}
if (trimmed.startsWith("https://") || trimmed.startsWith("http://")) {
if (isFeedLikeUrl(trimmed) && !trimmed.startsWith("rsshub://")) {
return "rss"
}
return "search"
@ -58,7 +63,9 @@ const searchSchema = z.object({
})
const rssSchema = z.object({
keyword: z.string().url().startsWith("https://"),
keyword: z.string().refine(isFeedLikeUrl, {
message: "Invalid RSS URL",
}),
})
const rsshubSchema = z.object({
@ -306,6 +313,7 @@ export function UnifiedDiscoverForm() {
<FormControl>
<Input
autoFocus
data-testid="discover-form-input"
{...field}
value={field.value || ""}
onChange={handleKeywordChange}
@ -397,6 +405,7 @@ export function UnifiedDiscoverForm() {
)}
<div className="center flex flex-col gap-3" data-testid="discover-form-actions">
<Button
data-testid="discover-form-submit"
disabled={!form.formState.isValid}
type="submit"
isLoading={mutation.isPending}

View File

@ -155,7 +155,12 @@ export const EntryItemWrapper: FC<
const Link = view === FeedViewType.SocialMedia ? "article" : NavLink
const isAll = view === FeedViewType.All
return (
<div data-entry-id={entry?.id} style={style}>
<div
data-entry-id={entry?.id}
data-read={asRead ? "true" : "false"}
data-active={isActive ? "true" : "false"}
style={style}
>
<Link
to={navigationPath}
className={cn(

View File

@ -5,14 +5,9 @@ import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useIsInbox } from "@follow/store/inbox/hooks"
import { cn } from "@follow/utils"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
AIChatPanelStyle,
setAIPanelVisibility,
useAIChatPanelStyle,
useAIPanelVisibility,
} from "~/atoms/settings/ai"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ErrorBoundary } from "~/components/common/ErrorBoundary"
import { ShadowDOM } from "~/components/common/ShadowDOM"
@ -20,8 +15,6 @@ import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { readableContentMaxWidthClassName } from "~/constants/ui"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import type { TextSelectionEvent } from "~/lib/simple-text-selection"
import { queueSelectedTextInsertion } from "~/modules/ai-chat/editor/plugins/selection/selectedTextBridge"
import { EntryContentHTMLRenderer } from "~/modules/renderer/html"
import { EntryContentMarkdownRenderer } from "~/modules/renderer/markdown"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
@ -33,7 +26,6 @@ import { EntryRenderError } from "../entry-content/EntryRenderError"
import { ReadabilityNotice } from "../entry-content/ReadabilityNotice"
import { EntryAttachments } from "../EntryAttachments"
import { EntryTitle } from "../EntryTitle"
import { TextSelectionToolbar } from "../selection/TextSelectionToolbar"
import { MediaTranscript, TranscriptToggle, useTranscription } from "./shared"
import { ArticleAudioPlayer } from "./shared/AudioPlayer"
import type { EntryLayoutProps } from "./types"
@ -53,48 +45,15 @@ export const ArticleLayout: React.FC<EntryLayoutProps> = ({
const feed = useFeedById(entry?.feedId)
const isInbox = useIsInbox(entry?.inboxId)
const [showTranscript, setShowTranscript] = useState(false)
const [textSelection, setTextSelection] = useState<TextSelectionEvent | null>(null)
const { content } = useEntryContent(entryId)
const customCSS = useUISettingKey("customCSS")
const handleTextSelect = useCallback((event: TextSelectionEvent) => {
setTextSelection(event)
}, [])
const handleSelectionClear = useCallback(() => {
setTextSelection(null)
}, [])
const aiChatPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
const shouldShowAISummary = aiChatPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible
const handleAskAI = useCallback(
(selectionEvent?: TextSelectionEvent) => {
const pendingSelection = selectionEvent ?? textSelection
if (!pendingSelection?.selectedText) return
queueSelectedTextInsertion({
text: pendingSelection.selectedText,
sourceEntryId: entryId,
timestamp: pendingSelection.timestamp,
})
setAIPanelVisibility(true)
handleSelectionClear()
},
[entryId, handleSelectionClear, textSelection],
)
useEffect(() => {
if (!showTranscript) return
handleSelectionClear()
}, [showTranscript, handleSelectionClear])
useEffect(() => {
handleSelectionClear()
}, [entryId, handleSelectionClear])
if (!entry) return null
return (
@ -123,12 +82,7 @@ export const ArticleLayout: React.FC<EntryLayoutProps> = ({
type="transcription"
/>
) : (
<ShadowDOM
injectHostStyles={!isInbox}
textSelectionEnabled
onTextSelect={handleTextSelect}
onSelectionClear={handleSelectionClear}
>
<ShadowDOM injectHostStyles={!isInbox}>
{!!customCSS && <MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>}
<Renderer
@ -143,13 +97,6 @@ export const ArticleLayout: React.FC<EntryLayoutProps> = ({
)}
</ErrorBoundary>
</div>
<TextSelectionToolbar
selection={textSelection}
onRequestClose={handleSelectionClear}
onAskAI={handleAskAI}
entryId={entryId}
/>
</WrappedElementProvider>
<EntryAttachments entryId={entryId} />
@ -167,9 +114,6 @@ const Renderer: React.FC<{
content?: string
title?: string
}
onTextSelect?: (event: TextSelectionEvent) => void
onSelectionClear?: (entryId: string) => void
textSelectionEnabled?: boolean
}> = ({ entryId, view, feedId, noMedia = false, content = "", translation }) => {
const mediaInfo = useEntryMediaInfo(entryId)
const isMarkdownEntry = useMemo(() => {

View File

@ -8,7 +8,7 @@ export const ConfirmDestroyModalContent = ({ onConfirm }: { onConfirm: () => voi
<div className="w-[540px]">
<div className="mb-4 text-sm">{t("sidebar.feed_actions.unfollow_feed_many_warning")}</div>
<div className="flex justify-end">
<Button buttonClassName="bg-red" onClick={onConfirm}>
<Button data-testid="confirm-destroy" buttonClassName="bg-red" onClick={onConfirm}>
{t("words.confirm")}
</Button>
</div>

View File

@ -0,0 +1,41 @@
import { Button } from "@follow/components/ui/button/index.js"
import { useTranslation } from "react-i18next"
export const ReviewPromptModalContent = ({
dismiss,
onNegative,
onPositive,
}: {
dismiss: () => void
onNegative: () => void
onPositive: () => void
}) => {
const { t } = useTranslation("settings")
return (
<div className="flex min-w-80 max-w-md flex-col gap-4">
<p className="text-sm leading-relaxed text-text-secondary">{t("reviewPrompt.description")}</p>
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
onClick={() => {
onNegative()
dismiss()
}}
>
{t("reviewPrompt.notReally")}
</Button>
<Button
onClick={() => {
onPositive()
dismiss()
}}
>
{t("reviewPrompt.loveIt")}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
let triggerReviewPromptDebug: (() => void) | null = null
let resetReviewPromptDebug: (() => void) | null = null
export const setDesktopReviewPromptDebugAction = (callback: (() => void) | null) => {
triggerReviewPromptDebug = callback
}
export const openDesktopReviewPromptDebug = () => {
triggerReviewPromptDebug?.()
}
export const setDesktopReviewPromptResetAction = (callback: (() => void) | null) => {
resetReviewPromptDebug = callback
}
export const resetDesktopReviewPromptDebug = () => {
resetReviewPromptDebug?.()
}

View File

@ -0,0 +1,344 @@
import { sheetStackAtom } from "@follow/components/ui/sheet/context.js"
import { UserRole } from "@follow/constants"
import {
getReviewPromptEligibility,
recordReviewPromptActiveDay,
recordReviewPromptEntryOpen,
recordReviewPromptPaidConversion,
recordReviewPromptSubscriptionAdded,
syncReviewPromptSubscriptionCount,
} from "@follow/shared/review-prompt"
import { useAllFeedSubscription, useAllListSubscription } from "@follow/store/subscription/hooks"
import { useUserRole } from "@follow/store/user/hooks"
import { tracker, TrackerMapper, trackManager } from "@follow/tracker"
import { useAtomValue } from "jotai"
import { useEffect, useMemo, useRef } from "react"
import { useTranslation } from "react-i18next"
import { useLocation } from "react-router"
import { useIsInMASReview } from "~/atoms/server-configs"
import { useHasModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
import { DebugRegistry } from "../debug/registry"
import { setDesktopReviewPromptDebugAction, setDesktopReviewPromptResetAction } from "./debug"
import { ReviewPromptModalContent } from "./ReviewPromptModalContent"
import { useDesktopReviewPromptState } from "./use-review-prompt-state"
import {
clearDesktopReviewPromptState,
getDesktopReviewDebugTarget,
openDesktopFeedbackEmail,
openDesktopStoreReview,
persistDesktopReviewOutcome,
readDesktopReviewPromptState,
REVIEW_PROMPT_QUIET_WINDOW_MS,
} from "./utils"
const isPaidRole = (role: ReturnType<typeof useUserRole>) =>
role === UserRole.Pro || role === UserRole.Plus
export const ReviewPromptProvider = () => {
const { t } = useTranslation("settings")
const location = useLocation()
const { present } = useModalStack()
const hasModal = useHasModal()
const sheetStack = useAtomValue(sheetStackAtom)
const feedSubscriptions = useAllFeedSubscription()
const listSubscriptions = useAllListSubscription()
const role = useUserRole()
const isInMASReview = useIsInMASReview()
const {
distribution,
getLatestReviewState,
platform,
rateTarget,
reviewState,
storageKey,
updateReviewState,
userId,
} = useDesktopReviewPromptState()
const hasAttemptedInSessionRef = useRef(false)
const lastActionRef = useRef<"positive" | "negative" | null>(null)
const roleRef = useRef(role)
const subscriptionCountRef = useRef(feedSubscriptions.length + listSubscriptions.length)
subscriptionCountRef.current = feedSubscriptions.length + listSubscriptions.length
useEffect(() => {
hasAttemptedInSessionRef.current = false
}, [storageKey])
useEffect(() => {
if (!userId) {
return
}
const recordActiveDay = () => {
updateReviewState((state) => recordReviewPromptActiveDay(state, new Date()))
}
recordActiveDay()
const onVisibilityChange = () => {
if (!document.hidden) {
recordActiveDay()
}
}
window.addEventListener("focus", recordActiveDay)
document.addEventListener("visibilitychange", onVisibilityChange)
return () => {
window.removeEventListener("focus", recordActiveDay)
document.removeEventListener("visibilitychange", onVisibilityChange)
}
}, [updateReviewState, userId])
useEffect(() => {
if (!userId) {
roleRef.current = role
return
}
if (isPaidRole(role) && !isPaidRole(roleRef.current)) {
updateReviewState((state) => recordReviewPromptPaidConversion(state, new Date()))
}
roleRef.current = role
}, [role, updateReviewState, userId])
useEffect(() => {
if (!userId) {
return
}
updateReviewState((state) =>
syncReviewPromptSubscriptionCount(state, subscriptionCountRef.current),
)
}, [feedSubscriptions.length, listSubscriptions.length, updateReviewState, userId])
useEffect(() => {
if (!userId) {
return
}
return trackManager.setTrackFn((code) => {
switch (code) {
case TrackerMapper.NavigateEntry: {
updateReviewState((state) => recordReviewPromptEntryOpen(state))
break
}
case TrackerMapper.Subscribe: {
updateReviewState((state) =>
recordReviewPromptSubscriptionAdded(state, subscriptionCountRef.current),
)
break
}
}
return Promise.resolve()
})
}, [updateReviewState, userId])
const isRouteBlocked = useMemo(
() => location.pathname.startsWith("/settings/plan"),
[location.pathname],
)
const isInQuietWindow = !hasModal && sheetStack.length === 0 && !isRouteBlocked
const isPlatformSupported = distribution !== "unsupported" && !isInMASReview
const presentReviewPrompt = useMemo(
() =>
({
score,
source,
target = rateTarget,
}: {
score?: number
source: "auto" | "manual"
target?: ReturnType<typeof getDesktopReviewDebugTarget>
}) => {
if (!target) {
return
}
lastActionRef.current = null
tracker.reviewPromptShown({ distribution, platform, score, source })
present({
canClose: true,
clickOutsideToDismiss: true,
id: "review-prompt-modal",
onClose: () => {
if (lastActionRef.current) {
lastActionRef.current = null
return
}
persistDesktopReviewOutcome({
appVersion: APP_VERSION,
distribution,
outcome: "dismissed",
platform,
source,
state: readDesktopReviewPromptState(storageKey),
storageKey,
})
},
title: t("reviewPrompt.title"),
content: ({ dismiss }) => (
<ReviewPromptModalContent
dismiss={dismiss}
onNegative={() => {
lastActionRef.current = "negative"
persistDesktopReviewOutcome({
appVersion: APP_VERSION,
distribution,
outcome: "negative_feedback",
platform,
source,
state: readDesktopReviewPromptState(storageKey),
storageKey,
})
void openDesktopFeedbackEmail({ distribution, userId })
}}
onPositive={() => {
lastActionRef.current = "positive"
persistDesktopReviewOutcome({
appVersion: APP_VERSION,
distribution,
outcome: "positive_store_redirect",
platform,
score,
source,
state: readDesktopReviewPromptState(storageKey),
storageKey,
})
void openDesktopStoreReview(target)
}}
/>
),
})
},
[distribution, platform, present, rateTarget, storageKey, t, userId],
)
const eligibility = useMemo(
() =>
getReviewPromptEligibility({
appVersion: APP_VERSION,
isLoggedIn: !!userId,
isInQuietWindow,
isPaidUser: isPaidRole(role),
isPlatformSupported,
now: new Date(),
state: reviewState,
}),
[isInQuietWindow, isPlatformSupported, reviewState, role, userId],
)
useEffect(() => {
if (!storageKey) {
setDesktopReviewPromptDebugAction(null)
setDesktopReviewPromptResetAction(null)
return
}
setDesktopReviewPromptDebugAction(() => {
presentReviewPrompt({
source: "manual",
score: undefined,
target: getDesktopReviewDebugTarget(),
})
})
setDesktopReviewPromptResetAction(() => {
clearDesktopReviewPromptState(storageKey)
hasAttemptedInSessionRef.current = false
updateReviewState(() => readDesktopReviewPromptState(storageKey))
})
return () => {
setDesktopReviewPromptDebugAction(null)
setDesktopReviewPromptResetAction(null)
}
}, [presentReviewPrompt, storageKey, updateReviewState])
useEffect(() => {
const removeTrigger = DebugRegistry.add("Review Prompt", () => {
presentReviewPrompt({
source: "manual",
score: undefined,
target: getDesktopReviewDebugTarget(),
})
})
const removeReset = DebugRegistry.add("Reset Review Prompt State", () => {
if (!storageKey) {
return
}
clearDesktopReviewPromptState(storageKey)
hasAttemptedInSessionRef.current = false
updateReviewState(() => readDesktopReviewPromptState(storageKey))
})
return () => {
removeTrigger()
removeReset()
}
}, [presentReviewPrompt, storageKey, updateReviewState])
useEffect(() => {
if (!userId || hasAttemptedInSessionRef.current || !eligibility.allowed || !rateTarget) {
return
}
const timeoutId = window.setTimeout(() => {
if (hasAttemptedInSessionRef.current) {
return
}
const latestState = readDesktopReviewPromptState(storageKey)
const latestEligibility = getReviewPromptEligibility({
appVersion: APP_VERSION,
isLoggedIn: !!userId,
isInQuietWindow: !hasModal && sheetStack.length === 0 && !isRouteBlocked,
isPaidUser: isPaidRole(roleRef.current),
isPlatformSupported: distribution !== "unsupported" && !isInMASReview,
now: new Date(),
state: latestState,
})
if (!latestEligibility.allowed) {
return
}
hasAttemptedInSessionRef.current = true
lastActionRef.current = null
tracker.reviewPromptEligible({
distribution,
platform,
score: latestEligibility.score,
source: "auto",
})
presentReviewPrompt({ source: "auto", score: latestEligibility.score })
}, REVIEW_PROMPT_QUIET_WINDOW_MS)
return () => {
window.clearTimeout(timeoutId)
}
}, [
distribution,
eligibility.allowed,
getLatestReviewState,
hasModal,
isInMASReview,
isRouteBlocked,
platform,
presentReviewPrompt,
rateTarget,
sheetStack.length,
storageKey,
userId,
])
return null
}

View File

@ -0,0 +1,61 @@
import type { ReviewPromptState } from "@follow/shared/review-prompt"
import { normalizeReviewPromptState } from "@follow/shared/review-prompt"
import { useWhoami } from "@follow/store/user/hooks"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { DesktopReviewDistribution } from "./utils"
import {
getDesktopReviewDistribution,
getDesktopReviewPlatform,
getDesktopReviewRateTarget,
getDesktopReviewStorageKey,
readDesktopReviewPromptState,
writeDesktopReviewPromptState,
} from "./utils"
export const useDesktopReviewPromptState = () => {
const user = useWhoami()
const distribution = getDesktopReviewDistribution()
const platform = getDesktopReviewPlatform()
const rateTarget = getDesktopReviewRateTarget()
const storageKey = useMemo(() => {
if (!user?.id) {
return null
}
return getDesktopReviewStorageKey(user.id, distribution)
}, [distribution, user?.id])
const [reviewState, setReviewState] = useState(() => readDesktopReviewPromptState(storageKey))
useEffect(() => {
setReviewState(readDesktopReviewPromptState(storageKey))
}, [storageKey])
const getLatestReviewState = useCallback(
() => readDesktopReviewPromptState(storageKey),
[storageKey],
)
const updateReviewState = useCallback(
(updater: (state: ReviewPromptState) => ReviewPromptState) => {
const nextState = normalizeReviewPromptState(updater(getLatestReviewState()))
writeDesktopReviewPromptState(storageKey, nextState)
setReviewState(nextState)
return nextState
},
[getLatestReviewState, storageKey],
)
return {
distribution: distribution as DesktopReviewDistribution,
getLatestReviewState,
platform,
rateTarget,
reviewState,
storageKey,
updateReviewState,
userId: user?.id ?? null,
}
}

View File

@ -0,0 +1,211 @@
import type { ReviewPromptOutcome, ReviewPromptState } from "@follow/shared/review-prompt"
import {
createReviewPromptState,
normalizeReviewPromptState,
recordReviewPromptOutcome,
} from "@follow/shared/review-prompt"
import { tracker } from "@follow/tracker"
import { getStorageNS } from "@follow/utils/ns"
import { ipcServices } from "~/lib/client"
export const REVIEW_PROMPT_QUIET_WINDOW_MS = 5000
export type DesktopReviewDistribution = "mas" | "microsoft_store" | "unsupported"
export type DesktopReviewRateTarget = "mas" | "microsoft_store" | null
const APPLE_REVIEW_URL =
"https://apps.apple.com/us/app/folo-follow-everything/id6739802604?action=write-review"
const MICROSOFT_PRODUCT_ID = "9NVFZPV0V0HT"
const MICROSOFT_REVIEW_URI = `ms-windows-store://review/?ProductId=${MICROSOFT_PRODUCT_ID}`
const MICROSOFT_REVIEW_URL = "https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct"
const SUPPORT_EMAIL = "support@folo.is"
const REVIEW_PROMPT_STORAGE_PREFIX = getStorageNS("review-prompt")
export const getDesktopReviewPlatform = () =>
window.platform === "win32" ? "windows" : window.platform === "darwin" ? "macos" : "desktop"
export const getDesktopReviewDistribution = (): DesktopReviewDistribution => {
if (typeof process !== "undefined" && process.mas) {
return "mas"
}
if (window.api?.isWindowsStore) {
return "microsoft_store"
}
return "unsupported"
}
export const getDesktopReviewRateTarget = (): DesktopReviewRateTarget => {
if (window.platform === "darwin") {
return "mas"
}
if (window.platform === "win32") {
return "microsoft_store"
}
return null
}
export const getDesktopReviewDebugTarget = (): DesktopReviewRateTarget => {
const defaultTarget = getDesktopReviewRateTarget()
if (defaultTarget) {
return defaultTarget
}
return /Windows/i.test(window.navigator.userAgent) ? "microsoft_store" : "mas"
}
export const getDesktopReviewStorageKey = (
userId: string,
distribution: DesktopReviewDistribution,
) => `${REVIEW_PROMPT_STORAGE_PREFIX}:${distribution}:${userId}`
const openExternal = async (url: string) => {
if (ipcServices?.app.openExternal) {
await ipcServices.app.openExternal(url)
return
}
window.open(url, "_blank", "noopener,noreferrer")
}
export const openDesktopStoreReview = async (target: DesktopReviewRateTarget) => {
switch (target) {
case "mas": {
await openExternal(APPLE_REVIEW_URL)
return
}
case "microsoft_store": {
try {
await openExternal(MICROSOFT_REVIEW_URI)
} catch {
await openExternal(MICROSOFT_REVIEW_URL)
}
return
}
default: {
return
}
}
}
export const openDesktopFeedbackEmail = async ({
distribution,
userId,
}: {
distribution: DesktopReviewDistribution
userId: string | null
}) => {
const subject = "Folo feedback"
const body = [
"Hi Folo team,",
"",
"Here is my feedback:",
"",
`Platform: ${getDesktopReviewPlatform()}`,
`Distribution: ${distribution}`,
`Version: ${APP_VERSION}`,
`User ID: ${userId ?? "anonymous"}`,
].join("\n")
await openExternal(
`mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`,
)
}
export const readDesktopReviewPromptState = (storageKey: string | null): ReviewPromptState => {
if (!storageKey) {
return createReviewPromptState()
}
try {
const raw = window.localStorage.getItem(storageKey)
if (!raw) {
return createReviewPromptState()
}
return normalizeReviewPromptState(JSON.parse(raw) as Partial<ReviewPromptState>)
} catch {
return createReviewPromptState()
}
}
export const writeDesktopReviewPromptState = (
storageKey: string | null,
state: ReviewPromptState,
) => {
if (!storageKey) {
return
}
window.localStorage.setItem(storageKey, JSON.stringify(state))
}
export const clearDesktopReviewPromptState = (storageKey: string | null) => {
if (!storageKey) {
return
}
window.localStorage.removeItem(storageKey)
}
export const trackDesktopReviewOutcome = ({
distribution,
outcome,
platform,
score,
source,
}: {
distribution: DesktopReviewDistribution
outcome: ReviewPromptOutcome
platform: string
score?: number
source: "auto" | "manual"
}) => {
switch (outcome) {
case "dismissed": {
tracker.reviewPromptDismissed({ distribution, platform, source })
return
}
case "negative_feedback": {
tracker.reviewPromptNegative({ distribution, platform, source })
tracker.reviewPromptFeedbackOpened({ distribution, platform, source })
return
}
case "positive_store_redirect": {
tracker.reviewPromptPositive({ distribution, platform, source })
tracker.reviewPromptStoreOpened({ distribution, platform, source })
return
}
case "native_request": {
tracker.reviewPromptNativeRequested({ distribution, platform, score, source })
}
}
}
export const persistDesktopReviewOutcome = ({
appVersion,
distribution,
outcome,
platform,
score,
source,
state,
storageKey,
}: {
appVersion: string
distribution: DesktopReviewDistribution
outcome: ReviewPromptOutcome
platform: string
score?: number
source: "auto" | "manual"
state: ReviewPromptState
storageKey: string | null
}) => {
const nextState = recordReviewPromptOutcome(state, outcome, new Date(), appVersion)
writeDesktopReviewPromptState(storageKey, nextState)
trackDesktopReviewOutcome({ distribution, outcome, platform, score, source })
return nextState
}

View File

@ -1,9 +1,11 @@
import type { AISettings, GeneralSettings, UISettings } from "@follow/shared/settings/interface"
import { whoami } from "@follow/store/user/getters"
import { tracker } from "@follow/tracker"
import { EventBus } from "@follow/utils/event-bus"
import { getStorageNS } from "@follow/utils/ns"
import { isEmptyObject, sleep } from "@follow/utils/utils"
import type { SettingsTab } from "@follow-app/client-sdk"
import { omit } from "es-toolkit/compat"
import { FollowAPIError } from "@follow-app/client-sdk"
import type { PrimitiveAtom } from "jotai"
import { __aiSettingAtom, aiServerSyncWhiteListKeys, getAISettings } from "~/atoms/settings/ai"
@ -23,17 +25,28 @@ type SettingMapping = {
ai: AISettings
}
const omitKeys = []
const pickSyncPayload = <T extends object>(payload: T, keys: readonly (keyof T | string)[]) => {
const nextPayload = {} as Partial<T>
const record = payload as Record<string, unknown>
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(record, key)) {
nextPayload[key as keyof T] = record[key as string] as T[keyof T]
}
}
return nextPayload
}
const localSettingGetterMap = {
appearance: () => omit(getUISettings(), uiServerSyncWhiteListKeys, omitKeys),
general: () => omit(getGeneralSettings(), generalServerSyncWhiteListKeys, omitKeys),
ai: () => omit(getAISettings(), aiServerSyncWhiteListKeys, omitKeys),
appearance: () => getUISettings(),
general: () => getGeneralSettings(),
ai: () => getAISettings(),
}
const createInternalSetter =
<T>(atom: PrimitiveAtom<T>) =>
(payload: T) => {
(payload: Partial<T>) => {
const current = jotaiStore.get(atom)
jotaiStore.set(atom, { ...current, ...payload })
}
@ -56,14 +69,56 @@ const bizSettingKeyToTabMapping = {
ai: "ai",
}
const isUnauthorizedError = (error: unknown) => {
if (error instanceof FollowAPIError) {
return error.status === 401
}
if (error && typeof error === "object" && "status" in error) {
return Number((error as { status?: unknown }).status) === 401
}
return false
}
export type SettingSyncTab = keyof SettingMapping
export interface SettingSyncQueueItem<T extends SettingSyncTab = SettingSyncTab> {
tab: T
payload: Partial<SettingMapping[T]>
date: number
}
interface PersistedSettingSyncQueue {
ownerUserId: string | null
queue: SettingSyncQueueItem[]
}
class SettingSyncQueue {
queue: SettingSyncQueueItem[] = []
private ownerUserId: string | null = null
private getCurrentUserId() {
return whoami()?.id ?? null
}
private bindQueueOwner(currentUserId: string) {
if (this.ownerUserId === null) {
this.ownerUserId = currentUserId
return
}
if (this.ownerUserId !== currentUserId) {
this.ownerUserId = currentUserId
this.queue = []
}
}
private reportSyncError(stage: "flush" | "syncLocal", error: unknown) {
void tracker.manager.captureException(error, {
module: "setting_sync",
stage,
})
}
private disposers: (() => void)[] = []
async init() {
@ -72,10 +127,15 @@ class SettingSyncQueue {
this.load()
const d1 = EventBus.subscribe("SETTING_CHANGE_EVENT", (data) => {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) return
this.bindQueueOwner(currentUserId)
const tab = bizSettingKeyToTabMapping[data.key]
if (!tab) return
const nextPayload = omit(data.payload, omitKeys, settingWhiteListMap[tab])
const nextPayload = pickSyncPayload(data.payload, settingWhiteListMap[tab])
if (isEmptyObject(nextPayload)) return
this.enqueue(tab, nextPayload)
})
@ -97,14 +157,21 @@ class SettingSyncQueue {
disposer()
}
this.queue = []
this.ownerUserId = null
}
private readonly storageKey = getStorageNS("setting_sync_queue")
private persist() {
if (this.queue.length === 0) {
localStorage.removeItem(this.storageKey)
return
}
localStorage.setItem(this.storageKey, JSON.stringify(this.queue))
const payload: PersistedSettingSyncQueue = {
ownerUserId: this.ownerUserId,
queue: this.queue,
}
localStorage.setItem(this.storageKey, JSON.stringify(payload))
}
private load() {
@ -114,19 +181,52 @@ class SettingSyncQueue {
return
}
const currentUserId = this.getCurrentUserId()
try {
this.queue = JSON.parse(queue)
const parsed = JSON.parse(queue) as unknown
if (Array.isArray(parsed)) {
// Backward compatibility: legacy versions persisted the queue array directly.
this.queue = parsed
this.ownerUserId = currentUserId
} else if (!parsed || typeof parsed !== "object") {
this.queue = []
this.ownerUserId = null
return
} else {
const payload = parsed as Partial<PersistedSettingSyncQueue>
this.queue = Array.isArray(payload.queue) ? payload.queue : []
if (typeof payload.ownerUserId === "string" || payload.ownerUserId === null) {
this.ownerUserId = payload.ownerUserId
} else {
// Backward compatibility for payloads without owner information.
this.ownerUserId = currentUserId
}
}
} catch {
/* empty */
}
if (!currentUserId) {
return
}
this.bindQueueOwner(currentUserId)
}
private chain = Promise.resolve()
private threshold = 1000
private enqueueTime = Date.now()
private flushScheduled = false
async enqueue<T extends SettingSyncTab>(tab: T, payload: Partial<SettingMapping[T]>) {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
return
}
this.bindQueueOwner(currentUserId)
const now = Date.now()
if (isEmptyObject(payload)) {
return
@ -137,13 +237,30 @@ class SettingSyncQueue {
date: now,
})
if (now - this.enqueueTime > this.threshold) {
this.chain = this.chain.then(() => sleep(this.threshold)).finally(() => this.flush())
this.enqueueTime = Date.now()
if (this.flushScheduled) {
return
}
this.flushScheduled = true
this.chain = this.chain
.finally(() => sleep(this.threshold))
.finally(async () => {
try {
await this.flush()
} finally {
this.flushScheduled = false
}
})
}
private async flush() {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
return
}
this.bindQueueOwner(currentUserId)
if (navigator.onLine === false) {
return
}
@ -167,7 +284,7 @@ class SettingSyncQueue {
const promises = [] as Promise<any>[]
for (const tab in groupedTab) {
const json = omit(groupedTab[tab], omitKeys, settingWhiteListMap[tab])
const json = pickSyncPayload(groupedTab[tab], settingWhiteListMap[tab])
if (isEmptyObject(json)) {
continue
@ -191,14 +308,31 @@ class SettingSyncQueue {
promises.push(promise)
}
await Promise.all(promises)
try {
await Promise.all(promises)
} catch (error) {
if (isUnauthorizedError(error)) {
this.queue = []
this.ownerUserId = currentUserId
return
}
this.reportSyncError("flush", error)
}
}
replaceRemote(tab?: SettingSyncTab) {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
return this.chain
}
this.bindQueueOwner(currentUserId)
if (!tab) {
const promises = [] as Promise<any>[]
for (const tab in localSettingGetterMap) {
const payload = localSettingGetterMap[tab]()
const payload = pickSyncPayload(localSettingGetterMap[tab](), settingWhiteListMap[tab])
const promise = followClient.api.settings.update({
tab: tab as SettingsTab,
@ -211,7 +345,7 @@ class SettingSyncQueue {
this.chain = this.chain.finally(() => Promise.all(promises))
return this.chain
} else {
const payload = localSettingGetterMap[tab]()
const payload = pickSyncPayload(localSettingGetterMap[tab](), settingWhiteListMap[tab])
this.chain = this.chain.finally(() =>
followClient.api.settings.update({
@ -225,7 +359,24 @@ class SettingSyncQueue {
}
async syncLocal() {
const remoteSettings = await settings.get().prefetch()
const currentUserId = this.getCurrentUserId()
if (!currentUserId) return
this.bindQueueOwner(currentUserId)
const remoteSettings = await settings
.get()
.prefetch()
.catch((error) => {
if (isUnauthorizedError(error)) {
this.queue = []
this.ownerUserId = currentUserId
return null
}
this.reportSyncError("syncLocal", error)
return null
})
if (!remoteSettings) return
@ -246,7 +397,7 @@ class SettingSyncQueue {
if (!localSettingsUpdated || remoteUpdatedDate > localSettingsUpdated) {
// Use remote and update local
const nextPayload = omit(remoteSettingPayload, omitKeys, settingWhiteListMap[tab])
const nextPayload = pickSyncPayload(remoteSettingPayload, settingWhiteListMap[tab])
if (isEmptyObject(nextPayload)) {
continue

View File

@ -178,6 +178,7 @@ const SettingItemButtonImpl = (props: {
return (
<button
data-testid={`settings-tab-${path}`}
className={cn(
"my-0.5 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-text",
isActive && "!bg-theme-item-active !text-text",

View File

@ -17,12 +17,20 @@ import { ipcServices } from "~/lib/client"
import { getNewIssueUrl } from "~/lib/issues"
import { EnvironmentDebugModalContent } from "~/modules/app/EnvironmentIndicator"
import { AppTipModalContent } from "~/modules/app-tip"
import { useDesktopReviewPromptState } from "~/modules/review-prompt/use-review-prompt-state"
import {
openDesktopFeedbackEmail,
openDesktopStoreReview,
persistDesktopReviewOutcome,
readDesktopReviewPromptState,
} from "~/modules/review-prompt/utils"
export const SettingAbout = () => {
const { t } = useTranslation("settings")
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
const { present } = useModalStack()
const currentEnvironment = getCurrentEnvironment().join("\n")
const { distribution, platform, rateTarget, storageKey, userId } = useDesktopReviewPromptState()
const { data: appVersion } = useQuery({
queryKey: ["appVersion"],
queryFn: () => ipcServices?.app.getAppVersion(),
@ -116,6 +124,36 @@ export const SettingAbout = () => {
})
}
const handleRateFolo = async () => {
if (!rateTarget) {
return
}
persistDesktopReviewOutcome({
appVersion: APP_VERSION,
distribution,
outcome: "positive_store_redirect",
platform,
source: "manual",
state: readDesktopReviewPromptState(storageKey),
storageKey,
})
await openDesktopStoreReview(rateTarget)
}
const handleSendFeedback = async () => {
persistDesktopReviewOutcome({
appVersion: APP_VERSION,
distribution,
outcome: "negative_feedback",
platform,
source: "manual",
state: readDesktopReviewPromptState(storageKey),
storageKey,
})
await openDesktopFeedbackEmail({ distribution, userId })
}
return (
<div className="mx-auto mt-6 max-w-3xl space-y-8">
{/* Header Section */}
@ -214,6 +252,34 @@ export const SettingAbout = () => {
</div>
<i className="i-mingcute-sparkles-2-line text-base text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent" />
</button>
{rateTarget && (
<button
type="button"
onClick={() => {
void handleRateFolo()
}}
className="group flex w-full items-center justify-between rounded-lg p-3 text-left transition-all hover:bg-fill-secondary hover:shadow-sm"
>
<div>
<div className="text-sm font-medium">{t("about.rateFolo")}</div>
<div className="text-xs text-text-tertiary">{t("about.rateFoloDescription")}</div>
</div>
<i className="i-mgc-star-cute-re text-base text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent" />
</button>
)}
<button
type="button"
onClick={() => {
void handleSendFeedback()
}}
className="group flex w-full items-center justify-between rounded-lg p-3 text-left transition-all hover:bg-fill-secondary hover:shadow-sm"
>
<div>
<div className="text-sm font-medium">{t("about.sendFeedback")}</div>
<div className="text-xs text-text-tertiary">{t("about.sendFeedbackDescription")}</div>
</div>
<i className="i-mgc-mail-cute-re text-base text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent" />
</button>
</div>
{/* Legal Section */}

View File

@ -0,0 +1,127 @@
import { Button } from "@follow/components/ui/button/index.js"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { ipcServices } from "~/lib/client"
import { SettingSectionTitle } from "../section"
export const SettingCli = () => {
interface CliInstallStatus {
installed: boolean
installPath: string | null
cliSourceAvailable: boolean
}
const { t } = useTranslation("settings")
const [status, setStatus] = useState<CliInstallStatus | null>(null)
const [loading, setLoading] = useState(false)
const refreshStatus = useCallback(async () => {
const result = await ipcServices?.cli.getInstallStatus()
if (result) {
setStatus(result)
}
}, [])
useEffect(() => {
refreshStatus()
}, [refreshStatus])
const handleInstall = useCallback(async () => {
setLoading(true)
try {
const result = await ipcServices?.cli.installCli()
if (result?.success) {
toast.success(t("cli.install_success"))
} else {
toast.error(result?.error || t("cli.install_failed"))
}
} catch {
toast.error(t("cli.install_failed"))
}
await refreshStatus()
setLoading(false)
}, [t, refreshStatus])
const handleUninstall = useCallback(async () => {
setLoading(true)
try {
const result = await ipcServices?.cli.uninstallCli()
if (result?.success) {
toast.success(t("cli.uninstall_success"))
} else {
toast.error(result?.error || t("cli.uninstall_failed"))
}
} catch {
toast.error(t("cli.uninstall_failed"))
}
await refreshStatus()
setLoading(false)
}, [t, refreshStatus])
if (!IN_ELECTRON) return null
return (
<div className="mt-4 space-y-6">
<SettingSectionTitle title={t("cli.title")} />
<div className="space-y-4">
<p className="text-sm text-text-secondary">{t("cli.description")}</p>
{status && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Status:</span>
{status.installed ? (
<span className="inline-flex items-center gap-1 rounded-full bg-green/10 px-2 py-0.5 text-xs text-green">
<i className="i-mingcute-check-line" />
{t("cli.installed")}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-500/10 px-2 py-0.5 text-xs text-zinc-500">
{t("cli.not_installed")}
</span>
)}
</div>
{status.installed && status.installPath && (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t("cli.path")}:</span>
<code className="rounded bg-fill-quaternary px-2 py-0.5 text-xs">
{status.installPath}
</code>
</div>
)}
{!status.cliSourceAvailable && (
<p className="text-sm text-orange-500">{t("cli.not_available")}</p>
)}
<div className="flex gap-2">
{!status.installed ? (
<Button
onClick={handleInstall}
disabled={loading || !status.cliSourceAvailable}
isLoading={loading}
>
{t("cli.install")}
</Button>
) : (
<Button
variant="outline"
onClick={handleUninstall}
disabled={loading}
isLoading={loading}
>
{t("cli.uninstall")}
</Button>
)}
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -328,6 +328,7 @@ const SubscriptionFeedsSection = () => {
</DropdownMenu>
<MotionButtonBase
data-testid="feeds-batch-unsubscribe"
className="text-xs text-red transition-colors hover:text-red/80"
type="button"
onClick={handleBatchUnsubscribe}
@ -529,6 +530,7 @@ const FeedListItem = memo(
return (
<div
data-id={id}
data-testid={`settings-feed-row-${id}`}
role="button"
tabIndex={-1}
className={clsx(

View File

@ -260,6 +260,7 @@ export const LanguageSelector = ({
<ResponsiveSelect
size="sm"
triggerClassName="w-48"
triggerTestId="settings-language-select"
contentClassName={contentClassName}
defaultValue={finalRenderLanguage}
value={finalRenderLanguage}

View File

@ -13,8 +13,21 @@ import { useTranslation } from "react-i18next"
import type { PaymentFeature, PaymentPlan } from "~/atoms/server-configs"
import { useIsPaymentEnabled, useServerConfigs } from "~/atoms/server-configs"
import { followClient } from "~/lib/api-client"
import { subscription } from "~/lib/auth"
const APPLE_SUBSCRIPTION_MANAGEMENT_URL = "https://apps.apple.com/account/subscriptions"
type ActiveSubscription = {
source: "stripe" | "apple" | null
plan: string | null
status: string | null
productId: string | null
periodEnd: string | null
trialEnd: string | null
canManage: boolean
}
const AI_MODEL_SELECTION_VALUE_LABELS = {
none: {
translationKey: "plan.featureValues.AI_MODEL_SELECTION.none",
@ -94,25 +107,12 @@ const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: bo
const useActiveSubscription = () => {
const userId = useWhoami()?.id
return useQuery({
queryKey: ["activeSubscription"],
queryKey: ["billingSubscription"],
queryFn: async () => {
const { data } = await subscription.list()
// Find subscription: active, trialing, or canceled with valid period end
return data?.find((sub) => {
if (!sub.stripeSubscriptionId) return false
// Active or trialing subscriptions
if (sub.status === "active" || sub.status === "trialing") {
return true
}
// Canceled subscriptions that haven't expired yet
if (sub.status === "canceled" && sub.periodEnd) {
return new Date(sub.periodEnd) > new Date()
}
return false
})
const response = await followClient.request<{ code: number; data: ActiveSubscription }>(
"/billing/subscription",
)
return response.data
},
enabled: !!userId,
})
@ -396,11 +396,13 @@ const PlanAction = ({
const { t } = useTranslation("settings")
const { data: activeSubscription } = useActiveSubscription()
const billingPortalMutation = useBillingPortal()
const canManageSubscription = !!activeSubscription?.stripeSubscriptionId
const canManageSubscription = !!activeSubscription?.canManage
const isAppleSubscription = activeSubscription?.source === "apple"
// Determine subscription status info
const isCanceled = activeSubscription?.status === "canceled"
const periodEnd = activeSubscription?.periodEnd ? new Date(activeSubscription.periodEnd) : null
const periodEnd = activeSubscription?.trialEnd ?? activeSubscription?.periodEnd
const effectivePeriodEnd = periodEnd ? new Date(periodEnd) : null
const getButtonConfig = () => {
switch (actionType) {
@ -486,7 +488,7 @@ const PlanAction = ({
<span>
{isCanceled
? t("plan.canceled_expires", {
date: periodEnd?.toLocaleDateString(undefined, {
date: effectivePeriodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
@ -494,14 +496,14 @@ const PlanAction = ({
})
: activeSubscription?.status === "trialing"
? t("plan.trial_ends", {
date: periodEnd?.toLocaleDateString(undefined, {
date: effectivePeriodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
}),
})
: t("plan.renews", {
date: periodEnd?.toLocaleDateString(undefined, {
date: effectivePeriodEnd?.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
@ -519,7 +521,14 @@ const PlanAction = ({
disabled={buttonConfig.disabled}
onClick={
actionType === "current" && canManageSubscription
? () => billingPortalMutation.mutate()
? () => {
if (isAppleSubscription) {
window.open(APPLE_SUBSCRIPTION_MANAGEMENT_URL, "_blank")
return
}
billingPortalMutation.mutate()
}
: onSelect
}
isLoading={isLoading || billingPortalMutation.isPending}

View File

@ -53,7 +53,11 @@ export const SubscriptionColumnHeader = memo(() => {
)}
<div className="relative flex items-center gap-2" onClick={stopPropagation}>
<Link to="/discover" tabIndex={-1}>
<ActionButton shortcut="$mod+T" tooltip={t("words.discover")}>
<ActionButton
data-testid="subscription-discover-trigger"
shortcut="$mod+T"
tooltip={t("words.discover")}
>
<i className="i-mgc-add-cute-re size-5 text-text-secondary" />
</ActionButton>
</Link>

View File

@ -18,6 +18,9 @@ import { useContextMenu } from "~/hooks/common/useContextMenu"
import { resetSelectedFeedIds } from "./atom"
import { useShowTimelineTabsSettingsModal } from "./TimelineTabsSettingsModal"
const getTimelineTabTestId = (name: string) =>
`timeline-tab-${name.split(".").pop()?.replaceAll("_", "-")}`
export function SubscriptionTabButton({
timelineId,
shortcut,
@ -153,6 +156,7 @@ const ViewAllSwitchButton: FC<{
return (
<ActionButton
data-testid={getTimelineTabTestId(item.name)}
shortcutScope={FocusablePresets.isNotFloatingLayerScope}
key={item.name}
tooltip={t(item.name, { ns: "common" })}
@ -214,6 +218,7 @@ const ViewSwitchButton: FC<{
return (
<ActionButton
data-testid={getTimelineTabTestId(item.name)}
shortcutScope={FocusablePresets.isNotFloatingLayerScope}
ref={setNodeRef}
key={item.name}

View File

@ -14,6 +14,7 @@ export const LoginButton: FC<LoginProps> = (props) => {
const { t } = useTranslation()
const Content = (
<ActionButton
data-testid="login-button"
className="relative z-[1]"
onClick={
method === "modal"

View File

@ -63,6 +63,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
<DropdownMenuTrigger
asChild
className="!outline-none focus-visible:bg-theme-item-hover data-[state=open]:bg-transparent"
data-testid="profile-menu-trigger"
>
{props.animatedAvatar ? (
<TransitionAvatar stage={dropdown ? "zoom-in" : ""} />
@ -154,6 +155,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
<DropdownMenuSeparator />
<DropdownMenuItem
className="pl-3"
data-testid="profile-menu-preferences"
onClick={() => {
settingModalPresent()
}}
@ -202,6 +204,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
)}
<DropdownMenuItem
className="pl-3"
data-testid="profile-menu-logout"
onClick={signOut}
icon={<i className="i-mgc-exit-cute-re" />}
>

View File

@ -0,0 +1,25 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { SettingCli } from "~/modules/settings/tabs/cli"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-terminal-cute-re"
const priority = (1000 << 1) + 25
const CLI_SETTINGS_DISABLED_FOR_THIS_RELEASE = true
export const loader = defineSettingPageData({
icon: iconName,
name: "titles.cli",
priority,
hideIf: () => CLI_SETTINGS_DISABLED_FOR_THIS_RELEASE || !IN_ELECTRON,
})
export function Component() {
return (
<>
<SettingsTitle />
<SettingCli />
</>
)
}

View File

@ -17,6 +17,7 @@ import { ModalStackProvider } from "~/components/ui/modal"
import { jotaiStore } from "~/lib/jotai"
import { persistConfig, queryClient } from "~/lib/query-client"
import { FollowCommandManager } from "~/modules/command/command-manager"
import { ReviewPromptProvider } from "~/modules/review-prompt/provider"
import { HotkeyProvider } from "./hotkey-provider"
import { I18nProvider } from "./i18n-provider"
@ -51,6 +52,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<StableRouterProvider />
<SettingSync />
<FollowCommandManager />
<ReviewPromptProvider />
{import.meta.env.DEV && <Devtools />}

View File

@ -1,13 +1,16 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { whoamiQueryKey } from "@follow/store/user/hooks"
import { userSyncService } from "@follow/store/user/store"
import { tracker } from "@follow/tracker"
import { clearStorage } from "@follow/utils/ns"
import type { FetchError } from "ofetch"
import { setLoginModalShow } from "~/atoms/user"
import { QUERY_PERSIST_KEY } from "~/constants"
import { useAuthQuery } from "~/hooks/common"
import { deleteUserCustom as deleteUserFn, getAccountInfo, signOut as signOutFn } from "~/lib/auth"
import { ipcServices } from "~/lib/client"
import { clearAuthSessionToken, getAuthSessionToken } from "~/lib/client-session"
import { defineQuery } from "~/lib/defineQuery"
import { clearLocalPersistStoreData } from "~/store/utils/clear"
@ -90,11 +93,14 @@ export const useSession = (options?: { enabled?: boolean }) => {
}
export const handleSessionChanges = () => {
setLoginModalShow(false)
ipcServices?.auth.sessionChanged()
window.location.reload()
}
export const signOut = async () => {
const authSessionToken = getAuthSessionToken()
clearAuthSessionToken()
// Clear query cache
localStorage.removeItem(QUERY_PERSIST_KEY)
@ -105,8 +111,18 @@ export const signOut = async () => {
clearStorage()
// Sign out
await tracker.manager.clear()
await ipcServices?.auth.signOut()
await signOutFn()
if (IN_ELECTRON) {
void ipcServices?.auth.signOut()
const authService = ipcServices?.auth as
| ({ signOutRemote?: (token?: string) => Promise<void> } & NonNullable<
typeof ipcServices
>["auth"])
| undefined
void authService?.signOutRemote?.(authSessionToken ?? undefined)
} else {
await ipcServices?.auth.signOut()
await signOutFn()
}
window.location.reload()
}

View File

@ -7,8 +7,10 @@ import { NotFound } from "./components/common/NotFound"
// @ts-ignore
import { routes as tree } from "./generated-routes"
const routerCreator =
IN_ELECTRON || globalThis["__DEBUG_PROXY__"] ? createHashRouter : createBrowserRouter
const isDebugProxyRuntime =
!!globalThis["__DEBUG_PROXY__"] || globalThis.location?.pathname?.startsWith("/__debug_proxy")
const routerCreator = IN_ELECTRON || isDebugProxyRuntime ? createHashRouter : createBrowserRouter
export const router = routerCreator([
{

Some files were not shown because too many files have changed in this diff Show More