This guide is for the people who build, operate, deploy, and extend Folo: maintainers, self-hosting developers, contributors, and architects evaluating the codebase. It documents the monorepo's structure, the multi-surface architecture, the shared internal packages, the data and state model, build and deployment pipelines, security posture, and the technical debt and improvement opportunities visible in the source.
End-user features (reading, subscribing, the CLI's day-to-day use) are covered in the companion User Guide. This document goes underneath that surface.
Note
Everything here is derived from the repository source at the documented commit. Folo's production backend API (
api.folo.is) is a separate service and is not part of this repository — the repo contains the clients (desktop, mobile, SSR, landing, CLI), the OTA update worker, and the shared packages they consume.
1. Executive Summary
Folo ("Follow everything in one place", formerly Follow) is an open-source,
AI-native information reader. The repository RSSNext/Folo is a large pnpm + Turbo
monorepo that produces multiple client applications from a shared core:
apps/desktop— an Electron application. Its renderer (@follow/web) is the primary web app and is also whatapp.folo.isserves.apps/mobile— a React Native application built with Expo (iOS + Android, with native Swift/Kotlin modules).apps/ssr— a minimal server-side-rendering site for external sharing (feeds, lists, profiles, OG images), deployable to Cloudflare Workers / Vercel.apps/landing— the marketing site.apps/cli— thefolocommand-line client (npm packagefolocli).apps/ota— a Cloudflare Worker (Hono) that serves mobile over-the-air updates and version metadata.
These are unified by a fleet of shared packages under packages/internal/*
(components, atoms, hooks, store, database, shared, constants, models, tracker, utils,
logger, types) and a content-extraction package (packages/readability).
| Property | Value |
|---|---|
| Repository | RSSNext/Folo (analyzed at commit c98f572, 2026-06-05) |
| License | AGPL-3.0-only (Electron main process: GPL-3.0-only; @follow/utils: MIT) |
| Package manager | pnpm@10.17.0 (Corepack-pinned) |
| Build orchestration | Turborepo (turbo.json) |
| Primary language | TypeScript (strict) |
| UI runtime | React 19 |
| Desktop shell | Electron 38 + electron-vite + electron-forge |
| Mobile runtime | Expo SDK 55 + React Native 0.83 |
| State | Jotai (atoms) · Zustand (stores) · TanStack Query (server state) |
| Local data | Drizzle ORM over SQLite (wa-sqlite/sqlocal on web, expo-sqlite on mobile) |
| Auth | better-auth (OAuth + email/password + 2FA), Stripe via @better-auth/stripe |
Important
The "API" the clients talk to (
https://api.folo.is) is external to this repo. When running from source you point the clients at the production API. There is no bundled server to stand up for normal development.
2. Architecture
2.1 Big picture
┌─────────────────────────────────────────────┐
│ Folo Backend API │
│ https://api.folo.is (external, │
│ better-auth + feeds + AI + Power) │
└───────────────▲──────────────▲──────────────┘
│ │
┌────────────────────────────┼──────────────┼───────────────────────┐
│ │ │ │
┌────────┴─────────┐ ┌─────────┴───────┐ ┌──┴──────────┐ ┌────────┴───────┐
│ apps/desktop │ │ apps/mobile │ │ apps/cli │ │ apps/ssr │
│ Electron │ │ Expo / RN │ │ folocli │ │ share pages │
│ ┌────────────┐ │ │ + native iOS/ │ │ (commander │ │ + OG images │
│ │ main proc │ │ │ Android mods │ │ + SDK) │ │ (Cloudflare/ │
│ │ (IPC svcs) │ │ └─────────────────┘ └─────────────┘ │ Vercel) │
│ ├────────────┤ │ └─────────────────┘
│ │ renderer = │ │ ┌─────────────────┐ ┌─────────────────┐
│ │ @follow/web│◄─┼────────┤ apps/landing │ │ apps/ota │
│ └────────────┘ │ shares │ marketing site │ │ mobile OTA │
└──────────────────┘ core └─────────────────┘ │ (Hono worker) │
▲ └─────────────────┘
│ all consume
┌────────┴───────────────────────────────────────────────────────────────────┐
│ packages/internal/* — components · atoms(jotai) · hooks · store(zustand) │
│ · database(drizzle+sqlite) · shared(auth/env/stripe) · constants · models │
│ · tracker(analytics) · utils · logger · types + packages/readability │
└──────────────────────────────────────────────────────────────────────────────┘
The defining architectural idea is one shared core, many surfaces. Business logic,
data models, the store, and most UI primitives live in packages/internal/*. Each app is
a thin shell that wires the shared core into its platform (Electron, Expo, a worker, or a
terminal).
2.2 The desktop app: main / renderer split
apps/desktop follows the standard Electron two-process model, organized under
layer/:
layer/main(@follow/electron-main, GPL-3.0) — the Node-privileged main process. It owns windows, the tray/dock, auto-update, the local key/value store (electron-store), proxy configuration, push notifications, and all native capabilities. It exposes them to the renderer through a typed IPC layer.layer/renderer(@follow/web) — the React 19 web app. This same renderer is what runs in the browser atapp.folo.is(built withWEB_BUILD=1). When packaged in Electron it talks to the main process over IPC; in the browser those capabilities degrade gracefully or are unavailable.layer/main/preload— the preload bridge exposing the IPC surface to the renderer.
This is why the desktop renderer and the web app are the same code: the renderer is written to run in a plain browser, and Electron simply adds native superpowers via IPC.
2.3 IPC architecture (desktop)
The main process exposes capabilities as IPC services using the
electron-ipc-decorator library. Each service is a class extending IpcService with a
static groupName, and each callable is a method decorated with @IpcMethod(). This
gives the renderer a typed, namespaced RPC surface (<group>.<method>).
| Service (group) | Responsibility (selected methods) |
|---|---|
app |
Version, check/apply updates, open external URLs, window actions (close/minimize/maximize), clipboard, in-page find, file download, export current page as PDF, cache folder/limit/clear, reveal log file, select directory |
auth |
Session token management (set/clear/apply cookies), email sign-in/sign-up, TOTP 2FA verify, remote sign-out, sync session → CLI config |
cli |
CLI install status, connect/disconnect the folo CLI to the desktop session |
setting |
Login-item (launch at login), open settings window, system fonts, appearance (light/dark/system), minimize-to-tray, proxy config, messaging token |
reader |
Readability extraction, TTS (via tts.folo.is) + voice list, code-language detection |
integration |
Save to Obsidian (frontmatter Markdown), Eagle media save, qBittorrent login + add-magnet, custom fetch, open custom URL scheme |
dock |
Background unread-count polling → dock/taskbar badge |
menu |
Native context menus, confirm dialogs, native share menu |
debug |
Inspect element |
Tip
To enumerate the entire renderer↔main privilege boundary, read
apps/desktop/layer/main/src/ipc/services/*.ts. Every privileged operation the renderer can trigger is exactly one@IpcMethod()in one of these services — this is the desktop security review surface.
2.4 The mobile app
apps/mobile is an Expo (SDK 55) React Native 0.83 app. It shares the same store, models,
database services, and many components with desktop via packages/internal/*, but uses
native UI primitives:
- Native modules under
apps/mobile/native(thefollow-nativeExpo module) with Swift (iOS) and Kotlin (Android) code — tab bar, pager view, context menus, shared webview, indicators, store-kit test helpers. - NativeWind (Tailwind for RN) + the React Native UIKit color system.
- expo-sqlite as the Drizzle driver (vs wa-sqlite on web).
- A micro web-app (
apps/mobile/web-app/html-renderer) — a small Vite-built HTML renderer injected into a WebView to render rich entry content (shiki, katex, blurhash) consistently with the desktop renderer. - Background refresh via
expo-background-task, push via Firebase Messaging, audio viareact-native-track-player.
2.5 SSR & sharing service
apps/ssr is a minimal SSR site for external sharing — public pages for feeds,
lists, user profiles, and OG (Open Graph) image generation. It is built with Vite +
React, served by Fastify locally and deployable as a Cloudflare Worker (and/or
Vercel — see vercel.json). OG images are rendered with satori + resvg.
Routes (from apps/ssr/worker-entry.ts):
| Method | Path | Purpose |
|---|---|---|
GET |
/healthz |
Health check |
GET |
/feed/:id |
Public feed share page |
GET |
/list/:id |
Public list share page |
GET |
/profile/:path |
Public user profile |
GET |
/og/:type/:id |
Generated OG image |
GET |
/share/* |
Share routes |
GET |
/login, /register, /forget-password, /reset-password |
Auth pages |
GET |
* |
SPA fallback |
xss is used to sanitize rendered content — appropriate, since these pages render
third-party feed content to anonymous visitors.
2.6 OTA update service
apps/ota is a Hono Cloudflare Worker that powers mobile over-the-air updates and
the version badges shown in the README. It:
- Serves OTA release metadata and archives (with
fzstd/tar-streamfor the bundles). - Aggregates store versions by querying the Apple App Store, Google Play, and the
Microsoft Store (see
apps/ota/src/lib/store-version.ts). - Fetches GitHub releases for desktop version info.
- Validates OTA code signing (
apps/ota/src/lib/code-signing.ts).
This is the service behind ota.folo.is.
2.7 The CLI
apps/cli (folocli) is a commander-based CLI that talks to the same API via the
@follow-app/client-sdk. It is intentionally small and dependency-light:
src/index.tsregisters ten command groups (auth, timeline, subscription, entry, feed, list, search, collection, opml, unread) and global options.src/client.tsbuilds aFollowClientwith the resolved token and base URL.src/config.tspersists{ token, apiUrl }to~/.folo/config.json.src/browser-login.tsimplementsfolo login: it spins up a local127.0.0.1callback HTTP server, opens the browser to the web login page with acli_callbackparam, receives a one-time token, and exchanges it (/better-auth/one-time-token/apply, falling back to/verify, then a session probe) for a session token.src/output.tsrenders the{ok,data,error}envelope as JSON, an ASCII table, or plain text.
The desktop app's auth IPC service syncs the desktop session into the CLI config so the
two share a sign-in.
2.8 State management
Three complementary tools, by design (see root AGENTS.md):
| Tool | Used for |
|---|---|
| Jotai | Fine-grained atoms — settings, UI state. Centralized in packages/internal/atoms. |
| Zustand | Larger domain stores — the data model in packages/internal/store (entry, feed, subscription, list, collection, unread, summary, translation, user, inbox, image, action). |
| TanStack Query | Server state — fetching/caching API data, with persisted query cache. |
2.9 Data layer
packages/internal/database is a Drizzle ORM schema over SQLite, with a
platform-specific driver:
- Web/desktop renderer:
wa-sqlite/sqlocal(SQLite compiled to WASM, persisted via OPFS/IndexedDB). - Mobile:
expo-sqlite.
Schemas live in src/schemas/; migrations are generated with drizzle-kit generate and
applied by a custom migrator (src/migrator.ts). A ResourceLock guards concurrent
access. Above the raw tables sits a service layer (src/services/*) of static service
objects — EntryService, FeedService, SubscriptionService, CollectionService,
ListService, InboxService, ImageService, SummaryService, TranslationService,
UnreadService, UserService — that the Zustand stores call. The @follow/store
package's morph/ and hydrate.ts translate API payloads into the local schema.
3. Repository Structure
RSSNext/Folo
├── apps/
│ ├── desktop/ # Electron app
│ │ ├── layer/
│ │ │ ├── main/ # @follow/electron-main (Node main process)
│ │ │ │ ├── src/ipc/services/ # IPC services (app/auth/cli/...)
│ │ │ │ ├── src/lib/ # store, tray, dock, proxy, updater, auth-cookies
│ │ │ │ ├── src/manager/ # app/window managers
│ │ │ │ ├── src/updater/ # auto-update + hot-update (dynamic render entry)
│ │ │ │ └── preload/ # IPC bridge
│ │ │ └── renderer/ # @follow/web (React 19 renderer = the web app)
│ │ │ └── src/modules/ # feature modules (ai-chat, entry-*, settings, ...)
│ │ ├── e2e/ # Playwright (electron + web)
│ │ └── plugins/vite/ # build plugins (main-hash, route builder)
│ ├── mobile/ # @follow/mobile (Expo / React Native)
│ │ ├── native/ # follow-native Expo module (Swift / Kotlin)
│ │ ├── web-app/html-renderer/ # WebView content renderer
│ │ └── src/modules/ # feature modules (entry-list, settings, ai, ...)
│ ├── ssr/ # @follow/ssr (share pages + OG images)
│ ├── landing/ # @follow/landing (marketing, vinext + Cloudflare)
│ ├── cli/ # folocli (commander)
│ └── ota/ # @follow/ota (Hono Cloudflare Worker)
├── packages/
│ ├── internal/
│ │ ├── components/ # @follow/components (shared UI: radix, lexical, ...)
│ │ ├── atoms/ # @follow/atoms (jotai)
│ │ ├── hooks/ # @follow/hooks
│ │ ├── store/ # @follow/store (zustand domain stores)
│ │ ├── database/ # @follow/database (drizzle + sqlite)
│ │ ├── shared/ # @follow/shared (auth, env, stripe, bridge)
│ │ ├── constants/ # @follow/constants (views, roles, rsshub, ...)
│ │ ├── models/ # @follow/models
│ │ ├── tracker/ # @follow/tracker (posthog/firebase/proxy adapters)
│ │ ├── utils/ # @follow/utils (MIT; readability, color, codecs, ...)
│ │ ├── logger/ # @follow/logger (electron-log / web)
│ │ └── types/ # @follow/types (global d.ts)
│ ├── configs/ # @follow/configs (tailwind, tsconfig presets)
│ └── readability/ # @follow-app/readability (Mozilla Readability)
├── locales/ # i18n JSON (app/ai/settings/errors/shortcuts/native/...)
├── icons/ # icon assets (icons/mgc is proprietary MingCute)
├── api/ # vercel webhook (e.g. Cloudflare cache purge)
├── docs/ # docs/superpowers (plans/specs)
├── scripts/ # repo scripts (icons, proxy, ota build)
├── turbo.json pnpm-workspace.yaml vercel.json eslint.config.mjs
└── README.md AGENTS.md CONTRIBUTING.md SECURITY.md
Note
Each app/package may carry its own
AGENTS.mdwith rules that override the root one for files in that subtree (e.g.apps/desktop/AGENTS.md,apps/mobile/AGENTS.md,packages/internal/AGENTS.md). The closest guide to the edited file wins.
4. Shared Internal Packages
| Package | Purpose | Key dependencies |
|---|---|---|
@follow/components |
Shared React UI primitives & modules | Radix UI, Lexical (rich editor), unified/rehype/remark (Markdown), KaTeX, motion, vaul |
@follow/atoms |
Jotai atoms (settings, UI state) | jotai |
@follow/hooks |
Reusable React hooks | TanStack Query, foxact, usehooks-ts |
@follow/store |
Zustand domain stores + morph/hydrate | zustand, immer, TanStack Query, batshit |
@follow/database |
Drizzle schema, services, migrator | drizzle-orm, wa-sqlite/sqlocal, expo-sqlite, ai |
@follow/shared |
Auth, env (@t3-oss/env-core), Stripe, IPC bridge |
better-auth, @better-auth/stripe, stripe, drizzle, zod, ai |
@follow/constants |
Enums (views, roles, routes), RSSHub categories, social links, store IDs | client-sdk |
@follow/models |
Domain models / types (incl. RSSHub) | client-sdk |
@follow/tracker |
Analytics with pluggable adapters | posthog, firebase analytics |
@follow/utils (MIT) |
Pure utilities: readability, color, JSON codecs, LRU cache, URL builder, path parser, sanitization | Mozilla Readability, dompurify, linkedom, hast/rehype |
@follow/logger |
Logging (electron-log on desktop, web logger elsewhere) | electron-log |
@follow/configs |
Tailwind preset (Apple UIKit colors), tsconfig presets, icon tooling | tailwindcss plugins, iconify |
@follow-app/readability |
Standalone content extraction | @mozilla/readability, linkedom, dompurify, chardet |
Tip
@follow/utilsis MIT-licensed (the rest of the app is AGPL/GPL). If you want to reuse Folo's utility code in a non-AGPL project, this is the package that permits it.
Design tokens & UI conventions
The UI is built on Tailwind bound to Apple UIKit color tokens (light/dark adaptive),
prefixed by CSS property (text-*, bg-*, border-*, plus material/control/interface
groups). Icons use MingCute via the i-mgc- class prefix. Motion uses Framer
Motion via m.* (LazyMotion) with spring presets, but the team standard is to prefer
CSS transitions for simple interactions to avoid frame drops. On mobile, NativeWind +
the RN UIKit color system play the same role. See root AGENTS.md for the full token map.
5. Authentication & Identity
Folo uses better-auth end to end. Salient facts for operators and integrators:
- Methods: Google / GitHub / Apple OAuth, email + password, and TOTP 2FA.
- Session transport: a session-token cookie. The cookie name is derived per API URL
(e.g.
__Secure-better-auth.session_tokenon HTTPS). The desktop main process manages these cookies in the Electron session (auth.tsIPC service:applySessionToken/clearSessionToken, dedupe, 30-day expiry). - Desktop auth flow: email sign-in/up POSTs to
/better-auth/sign-in/emailand/sign-up/email; 2FA via/better-auth/two-factor/verify-totp; sign-out hits/better-auth/sign-out. Origin/desktop headers are attached on every request. - CLI auth flow: browser login exchanges a one-time token at
/better-auth/one-time-token/apply(fallback/verify), validated against/better-auth/get-session. Token stored in~/.folo/config.json. - Roles & billing:
UserRole(Free/Basic/Plus/Pro/Admin) gates limits; Stripe is integrated via@better-auth/stripe(packages/internal/shared).
The session-token-as-cookie design is what enables the "paste your browser cookie into the desktop app" fallback documented in the User Guide.
6. Configuration
There are two distinct configuration worlds. Conflating them is the most common mistake when reasoning about Folo's settings.
6.1 User-facing application settings
End-user settings (appearance, reading behavior, AI, actions, integrations) are not files or environment variables. They are stored:
- Renderer/web: Jotai atoms + browser persistence (IndexedDB/localStorage).
- Desktop main:
electron-store(store.ts) for native-side settings — appearance, minimize-to-tray, proxy config, cache size limit, login-item, messaging token. - Mobile: Jotai atoms + a settings sync queue (
SettingSyncQueue) that reconciles settings to the account.
These sync with the account so settings follow the user across devices. There is no config file to hand-edit for normal operation.
6.2 Build / runtime environment variables
Environment variables in this repo are for building and CI, not app settings. The full set found in source:
| Variable | Where | Purpose |
|---|---|---|
WEB_BUILD |
apps/desktop/vite.config.ts |
Build the renderer as the standalone web app |
SSL |
desktop vite config | Enable HTTPS dev server (mkcert) |
analyzer |
desktop vite config | Bundle analysis build |
FOLO_E2E_USER_DATA_DIR |
desktop before-bootstrap.ts |
E2E isolated user-data dir |
PROFILE, NODE_ENV |
mobile app.config.base.ts |
Build profile / mode |
OTA_RUNTIME_VERSION |
mobile config | OTA runtime version pin |
EXPO_PUBLIC_APP_CHECK_DEBUG_TOKEN |
mobile env | Firebase App Check debug token (dev) |
EXPO_PUBLIC_E2E_ENV_PROFILE, EXPO_PUBLIC_E2E_LANGUAGE |
mobile | E2E config |
VERCEL |
apps/ssr/index.ts |
Detect Vercel runtime |
VITE_API_URL, VITE_WEB_URL |
desktop .env |
Point clients at the API/web origin |
CI, DEBUG, VITEST |
various | Tooling flags |
FOLO_TOKEN, FOLO_TEST_TOKEN |
CLI | CLI session token / test token |
The canonical API/web origins per environment (prod/dev/local) live in
packages/internal/shared/src/env.common.ts (DEFAULT_VALUES), which the CLI's browser
login and the apps both read.
Important
For local development you copy
apps/desktop/.env.exampleto.envand setVITE_API_URL=https://api.follow.is(perCONTRIBUTING.md). The mobile app needs anEXPO_PUBLIC_APP_CHECK_DEBUG_TOKENin its.env.
7. Development Setup
Prerequisites
- Node.js (see
.nvmrc) and Corepack enabled (corepack enable && corepack prepare). - pnpm 10.17.0 (pinned via
packageManager). - For mobile: a Mac with Xcode (iOS) and/or Android toolchain; Expo dev client.
Install
corepack enable && corepack prepare
pnpm install # postinstall builds the internal packages (build:packages)
Run each surface
# Desktop — browser renderer (recommended for most dev)
cd apps/desktop && pnpm run dev:web
# opens https://app.folo.is/__debug_proxy against the online API
# Desktop — full Electron
cd apps/desktop
cp .env.example .env # set VITE_API_URL=https://api.follow.is
pnpm run dev:electron
# Mobile (Expo) — requires Mac/Xcode for iOS
cd apps/mobile
cp .env.example .env # set EXPO_PUBLIC_APP_CHECK_DEBUG_TOKEN
pnpm run ios # build & install dev client (first run only)
pnpm run dev
# SSR share site
cd apps/ssr && pnpm run dev
# Landing
cd apps/landing && pnpm run dev
# CLI (from source)
cd apps/cli && pnpm run dev -- --help
Quality gates (must pass before commit/PR)
The order is enforced: typecheck → lint → test.
pnpm run typecheck # turbo typecheck across packages
pnpm run lint:fix # tsslint + eslint --fix
pnpm run test # cross-env CI=1, recursive vitest
# Or via turbo:
pnpm exec turbo run format:check typecheck lint
pnpm exec turbo run test
A simple-git-hooks pre-commit runs lint-staged (eslint + prettier; locale dedupe;
mobile build-id increment).
Tip
Co-locate Vitest tests next to source. After moving files or changing imports, run
pnpm lintandpnpm typecheckfor the affected package before pushing — CI requires all three gates green.
8. Build & Deployment
8.1 Desktop (Electron)
Built with electron-vite (bundling) + electron-forge (packaging). Makers are configured for every platform:
| Target | Maker |
|---|---|
macOS .dmg / .pkg |
maker-dmg, maker-pkg |
| macOS App Store (MAS) | --platform=mas universal |
| Windows installer | maker-squirrel |
| Windows Store (APPX) | maker-appx (manifest generated by a script) |
| Linux AppImage | electron-forge-maker-appimage |
| Cross-platform zip | maker-zip |
cd apps/desktop
pnpm run build:electron # vite build + forge make
pnpm run build:web # WEB_BUILD=1 vite build → out/web (the web app)
pnpm run publish # build + forge publish (GitHub publisher)
Auto-update uses electron-updater; there's also a hot-update path
(updater/hot-updater.ts, app.rendererUpdateReload IPC + a mainHash in
package.json) that can swap the renderer bundle without a full reinstall.
Caution
The desktop
package.jsoncarries amainHashintegrity value tied to the main-process bundle. Regenerate it (pnpm run update:main-hash) when the main bundle changes; otherwise hot-update integrity checks will reject the build.
8.2 Web app
The web app is the desktop renderer built with WEB_BUILD=1 (pnpm run build:web →
out/web). This is what app.folo.is serves. It's a Vite build with vite-plugin-pwa
(installable PWA, service worker) and legacy support.
8.3 SSR & landing (Cloudflare / Vercel)
apps/ssrbuilds with Vite +tsdownand deploys via Wrangler (deploy:dev/deploy:prod) and/or Vercel (vercel.json+ a prepare/cleanup script).apps/landinguses vinext + the Cloudflare Vite plugin;cf:deployships it to Cloudflare.
8.4 Mobile (Expo / EAS)
cd apps/mobile
pnpm run ios | pnpm run android # local dev builds
pnpm run update:export # expo export (iOS+Android bundles)
pnpm run update:bundle # build OTA release bundle
Native builds go through EAS (eas-cli). OTA updates are produced here and served by
apps/ota.
8.5 OTA worker
cd apps/ota
pnpm run deploy:dev | pnpm run deploy:prod # wrangler deploy
8.6 CLI
cd apps/cli
pnpm run build # tsup → dist/index.js (bin: folo)
# published to npm as folocli
8.7 Cache-purge webhook
api/vercel_webhook.ts is a small serverless webhook that (given a Cloudflare token and
zone ID) purges CDN cache — used in the deploy pipeline. Missing credentials throw
No Cloudflare token or zone ID found.
9. Security
Folo is a client application that talks to an external API, renders untrusted third-party content, and (on desktop) bridges to native capabilities. The security surface follows from that.
9.1 Untrusted content rendering
Feeds and entries are attacker-influenced HTML. Folo sanitizes aggressively:
@follow/utilsand@follow/componentsuse rehype-sanitize / hast-util-sanitize and DOMPurify before rendering Markdown/HTML.- The SSR share pages use
xssto sanitize content rendered to anonymous visitors. - Reader-mode extraction (
@follow-app/readability) runs in a controlled DOM (linkedom) rather than the live page.
Warning
Any new code path that injects feed-derived HTML (e.g. a custom renderer or a new integration that emits markup) must route through the existing sanitizers. The renderer renders feed content verbatim by design — sanitization is the only thing standing between a malicious feed and the user.
9.2 Desktop privilege boundary (IPC)
The renderer is browser-grade untrusted relative to the main process. Every privileged
operation is a single @IpcMethod() (see §2.3). Notable hardening already present:
- The
integrationservice masks sensitive query params and headers (token/key/password/authorization) in logs. openURLSchemevalidates the scheme format before callingshell.openExternal.- Obsidian filenames are sanitized (
INVALID_CHAR_REGEX, drive-letter handling) to prevent path traversal / NTFS alternate-data-stream tricks. customFetchenforces a timeout and aborts.
Caution
customFetchandopenURLSchemeare powerful primitives reachable from the renderer. Treat them as a trust boundary: validate inputs server-of-the-bridge-side, and never widen them to accept renderer-controlled headers without masking/allow-listing. A compromised or malicious feed that can drive renderer code could otherwise use these as an SSRF / arbitrary-request primitive.
9.3 Local integration credentials
The desktop app stores third-party credentials in electron-store:
- qBittorrent SID cookie (
qbittorrentSID) after login with host/username/password. - Eagle talks to
localhost:41595(assumes a local trusted Eagle instance).
These are local-trust integrations; document to users that enabling them grants Folo the ability to act against those local services.
9.4 Network & proxy
The desktop app supports a user-configured proxy (setting.setProxyConfig). Requests use
ofetch/ky/fetch with desktop/origin headers. TTS goes to tts.folo.is; readability
fetches the original article URL (an outbound request to arbitrary user-chosen origins —
expected for a reader).
9.5 Mobile
- Secrets via
expo-secure-store; Firebase App Check gates API abuse. - Native modules run with app privileges; the injected WebView renderer should only receive sanitized content.
9.6 Supply chain & provenance
- Releases are signed (SignPath on Windows; Apple notarization on macOS/iOS) and carry GitHub artifact attestations. The OTA worker validates code signing on update bundles.
- A few dependencies are pinned to forks / git URLs (
wa-sqlitefrom a git tag, a forkedsqlocal, a forkedreact-resizable-layout). Track these for upstream security fixes since they bypass the normal registry update path.
9.7 Reporting
Per SECURITY.md, report vulnerabilities to [email removed]. Always run the latest
version for security updates.
10. Analytics, AI & Notifications
Analytics (@follow/tracker)
A small tracker abstraction with pluggable adapters: PostHog (web + React Native),
Firebase Analytics (mobile), and a Proxy adapter. TrackManager extends TrackerManager;
events route through registered adapters. This makes it straightforward to add/remove
analytics backends or disable them.
AI (ai SDK)
The Vercel ai SDK (v6) powers summary, translation, and the AI Chat module
(apps/desktop/layer/renderer/src/modules/ai-chat), which includes a Lexical-based rich
editor with plugins (mentions, file upload, shortcuts), a chat-core store, and an
event system. BYOK lets users supply their own provider key (Settings → AI). AI
prompt/locale strings live under locales/ai.
Notifications
- Desktop: push receiver (
@eneris/push-receiver) + a messaging token; thedockservice polls unread counts in the background to update the badge. - Mobile: Firebase Cloud Messaging (
@react-native-firebase/messaging) +expo-notifications.
11. Internationalization
i18n uses i18next with flat keys only (no defaultValue, no conflicting dotted
keys). Locale JSON is centralized under top-level locales/ and split by domain:
app, ai, settings, errors, shortcuts, native, common, lang, mobile. Each
feature should provide at least en, zh-CN, and ja. A repo script (dedupe:locales)
and a lint-staged hook keep locale files deduplicated.
Tip
The
locales/tree is large (settings alone is hundreds of KB). When adding a feature, add its keys to the relevant domain file rather than creating a new top-level namespace, and runpnpm run dedupe:locales.
12. Operations & Troubleshooting (Developer)
Reproducing user-reported issues
The team triages issues on GitHub. Recurring categories and where to look:
| Symptom | Likely area |
|---|---|
| TTS not working | reader IPC service + tts.folo.is availability |
| Auto-translation blanks content | renderer translation + reader-mode interaction |
| High CPU / overheating (Apple Silicon, mobile) | media-heavy view rendering, animations (prefer CSS transitions) |
| Mobile login failures | better-auth OAuth/redirect handling, App Check |
| Captcha failures | recaptcha (react-google-recaptcha-v3) on sensitive flows |
| Embedded video anti-bot prompt | upstream site blocking the webview; offer external-open |
| Inbox email not received | account inbox routing (backend) |
Logs & diagnostics
- Desktop:
electron-log; Settings → Data & cache → Reveal log file, or theapp.revealLogFileIPC method. - CLI: run with
--verboseto log every request/response to stderr. - Renderer:
react-scanandwhy-did-you-renderare wired for perf debugging in dev.
E2E
- Desktop: Playwright (
e2e/playwright.config.ts, projectselectronandweb). - Mobile: Maestro flows (
apps/mobile/e2e, shared/iOS/Android), run viapnpm run e2e:ios/e2e:android.
13. Code Quality & Conventions
From AGENTS.md and the lint config:
- TypeScript strict, no
any; precise types. English comments. - Imports: use
patheinstead ofnode:pathfor cross-platform paths. - Styling: avoid inline styles in JSX; extract complex styles to a
styles.tsalongside the component (typed withCSSProperties). On mobile, use NativeWind, notStyleSheet.create, for new UI. - Components: shared/reusable UI goes in
packages/internal/componentsand is exported from the package index; app-specific UI stays in the app (.../modules/renderer/componentson desktop). - Motion: CSS transitions first; Framer Motion
m.*only when necessary. - Tooling: ESLint 9 (
eslint-config-hyoban) + tsslint (type-aware lint), Prettier with the Tailwind plugin, Turbo for orchestration.
The command-palette system (apps/desktop/.../modules/command) is modeled on VS Code's
command contributions, with a registry adapted from AFFiNE — a clean pattern to follow
when adding new commands (register once, surface in palette + context menus).
14. Potential Improvements & Technical Debt
Observations from the source that maintainers may want to prioritize. None are defects per se; they are areas where investment would pay off.
- Reader-mode + auto-translation interaction (reported): content can disappear when auto-translation is on. Worth an integration test that renders a translated entry and asserts the body is present.
- TTS resilience: TTS depends on a single external service (
tts.folo.is). Add graceful degradation/caching and clearer UI when the service is unavailable (cache already exists per-utterance; surface failures better). - Forked/git dependencies:
wa-sqlite(git tag),sqlocalfork,react-resizable-layoutfork. These bypass registry updates — track upstream for security/bug fixes and plan to converge on released versions. customFetch/openURLSchemeblast radius: powerful renderer-reachable primitives. Consider an allow-list of hosts/schemes and stronger input validation to reduce SSRF potential (see §9.2).- Performance on media-heavy views: overheating/high-CPU reports cluster around
Pictures/Videos grids and animations. Continue the "CSS transitions over JS motion"
push; consider virtualization audits and content-visibility tuning (the
tailwindcss-content-visibilityplugin is already present). - Mobile auth robustness: OAuth/redirect login failures on mobile recur; harden the callback handling and surface actionable errors.
- Two render engines for entry content: the desktop renderer and the mobile WebView
html-rendererboth render rich content (shiki/katex/blurhash). Keep them in lockstep to avoid divergence in how an entry looks across platforms. - Documentation gap (reported #4903/#5000): users ask for an official user guide and docs on writing automation/notification regular expressions. A short docs site for Actions patterns would cut support load.
15. Quick Reference
Apps & their package names
| Path | Package | Runtime / deploy |
|---|---|---|
apps/desktop |
Folo (renderer @follow/web, main @follow/electron-main) |
Electron / electron-forge; web → out/web |
apps/mobile |
@follow/mobile |
Expo / EAS (iOS, Android) |
apps/ssr |
@follow/ssr |
Fastify / Cloudflare Worker / Vercel |
apps/landing |
@follow/landing |
vinext / Cloudflare |
apps/cli |
folocli (bin folo) |
npm package, Node ≥ 18 |
apps/ota |
@follow/ota |
Hono / Cloudflare Worker |
Common commands
pnpm install # install + build internal packages
pnpm run typecheck # turbo typecheck
pnpm run lint:fix # tsslint + eslint --fix
pnpm run test # recursive vitest (CI=1)
pnpm run build:web # build the web app (desktop renderer)
pnpm exec turbo run format:check typecheck lint
Per-app dev
cd apps/desktop && pnpm run dev:web # web renderer against online API
cd apps/desktop && pnpm run dev:electron # full Electron (needs .env)
cd apps/mobile && pnpm run dev # Expo
cd apps/ssr && pnpm run dev # SSR
cd apps/cli && pnpm run dev -- --help # CLI
Key external endpoints
| Endpoint | Role |
|---|---|
https://api.folo.is (a.k.a. api.follow.is) |
Backend API + better-auth (external) |
https://app.folo.is |
Hosted web app (the desktop renderer) |
https://ota.folo.is |
OTA updates + version metadata (apps/ota) |
https://tts.folo.is |
Text-to-speech service (desktop reader IPC) |
localhost:41595 |
Local Eagle app (integration) |
Auth endpoints (better-auth)
| Path | Use |
|---|---|
/better-auth/sign-in/email, /sign-up/email |
Email auth (desktop) |
/better-auth/two-factor/verify-totp |
2FA |
/better-auth/sign-out |
Sign out |
/better-auth/one-time-token/apply (/verify) |
CLI token exchange |
/better-auth/get-session |
Session validation |
CLI config
~/.folo/config.json → { "token": "...", "apiUrl": "https://api.folo.is" }
FOLO_TOKEN → env override for the token