Merge pull request #4916 from RSSNext/release/desktop/1.4.0
release(desktop): Release v1.4.0
This commit is contained in:
commit
36f733be41
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pnpm-lock.yaml
|
||||
|
||||
CHANGELOG.md
|
||||
.context
|
||||
|
||||
apps/external/postcss.config.cjs
|
||||
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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.`)
|
||||
}
|
||||
|
|
@ -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/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineProject } from "vitest/config"
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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/,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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(() => {})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"})`)
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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?.()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export const LanguageSelector = ({
|
|||
<ResponsiveSelect
|
||||
size="sm"
|
||||
triggerClassName="w-48"
|
||||
triggerTestId="settings-language-select"
|
||||
contentClassName={contentClassName}
|
||||
defaultValue={finalRenderLanguage}
|
||||
value={finalRenderLanguage}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue