diff --git a/README.md b/README.md index 4d309e7..ad05974 100644 --- a/README.md +++ b/README.md @@ -6,29 +6,15 @@ Self-hosted App Store keyword tracker for indie iOS developers. [![License: Apache 2.0](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](LICENSE) [![macOS 13+](https://img.shields.io/badge/macOS-13%2B-lightgrey.svg)](#install) -Tracks where your apps rank for a set of keywords across multiple App Store regions, snapshots the top results, and remembers everything — so you can see real ASO trends instead of guessing from this-week-only screenshots. Runs entirely on your Mac: a Vapor service stores history in SQLite, a Svelte dashboard renders it, and a menubar app supervises the whole thing. +Tracks where your apps rank for a set of keywords across any of Apple's 175 App Store storefronts, snapshots the top results, and remembers everything — so you can see real ASO trends instead of guessing from this-week-only screenshots. A daily chart-position watchdog fires a browser notification the moment one of your apps enters, moves in, or exits a top-free category chart anywhere it's published. Runs entirely on your Mac: a Vapor service stores history in SQLite, a Svelte dashboard renders it, and a menu-bar app supervises the whole thing. You own the data and the schedule — no $50/mo subscription, no abandoned web app, no spreadsheet that goes stale within a week. ---- - -## Why - -The keyword-tracking tools indie devs reach for are either expensive subscriptions, abandoned web apps, or a spreadsheet that goes stale within a week. Keywordista gives you the same dashboard you'd pay $50/mo for, but you own the data and the schedule. - -Built for tracking dozens of keywords across half a dozen regions for a handful of apps — single-user scale. Not multi-tenant, not cloud-deployed, doesn't try to be. +![Keywordista dashboard — keyword ranks across multiple App Store storefronts with top-results snapshots, difficulty, and entry-barrier scoring](docs/dashboard.png) --- ## Install -> **A signed/notarized DMG is on the way.** For now, build from source — it's two commands. - -### Prerequisites - -- **macOS 13** Ventura or later -- **Swift 5.10+** (bundled with Xcode 15.3 / standalone toolchain) -- **Node 18+** for the Svelte SPA build - -### From source +> macOS 13+, Swift 5.10+, Node 18+. ```bash git clone https://github.com/bootuz/keywordista.git @@ -36,17 +22,9 @@ cd keywordista make open-mac-app ``` -The `make open-mac-app` target builds the Vapor server, builds the SPA, assembles `Keywordista.app`, and opens it. A magnifying-glass icon appears in your menu bar. Click **Open Dashboard** → the browser opens `http://127.0.0.1:8080/` (or `:8081…:8090` if `:8080` is already taken). +This builds the Vapor server, builds the SPA, assembles `Keywordista.app`, and opens it. A magnifying-glass icon appears in your menu bar. Click **Open Dashboard** → the browser opens `http://127.0.0.1:8080/` (auto-picks `:8081…:8090` if `:8080` is taken). -#### Headless / dev mode - -Prefer no menubar app? Use the launcher script: - -```bash -./keywordista -``` - -This builds the SPA and `exec`s the Vapor server in the foreground. Ctrl+C stops it. Same dashboard at `http://127.0.0.1:8080/`. Data lives in `./db.sqlite` instead of `~/Library/Application Support/Keywordista/`. +Prefer no menu-bar app? Run `./keywordista` to `exec` the Vapor server in the foreground at `:8080`, with data in `./db.sqlite` instead of `~/Library/Application Support/Keywordista/`. --- @@ -55,134 +33,102 @@ This builds the SPA and `exec`s the Vapor server in the foreground. Ctrl+C stops ``` ┌──────────────────────────────────┐ │ Keywordista.app (menubar shell) │ - │ ─ Spawns + supervises the server │ │ ─ Picks a free port (8080–8090) │ - │ ─ "Open Dashboard" in browser │ - │ ─ Quit kills the child cleanly │ + │ ─ Spawns + supervises the server │ └────────────┬─────────────────────┘ - │ spawns + │ ▼ ┌──────────────────────────────────────────────────┐ │ Vapor server (Swift, 127.0.0.1 only) │ │ ├ REST API under /api/v1 │ │ ├ Static Svelte SPA on / │ - │ ├ Daily refresh job (03:00 UTC) + on-demand │ - │ └ Polite serial worker (~1 req/sec to iTunes) │ - └────────────┬───────────────────────┬─────────────┘ - ▼ ▼ - ┌──────────────────┐ ┌──────────────────────┐ - │ SQLite (Fluent) │ │ iTunes Search API │ - │ + append-only │ │ (no key required) │ - │ rank history │ └──────────────────────┘ - └──────────────────┘ + │ ├ Keyword refresh @ 03:00 UTC (Queues) │ + │ └ Chart watchdog @ 04:00 UTC (Queues) │ + └───────┬──────────────────────────┬───────────────┘ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────────────────┐ + │ SQLite (Fluent) │ │ Apple iTunes endpoints │ + │ + append-only │ │ ─ /search (keyword rank) │ + │ rank history │ │ ─ /lookup (app metadata) │ + │ + chart_event │ │ ─ /rss/topfreeapplications │ + │ audit log │ │ (chart-watchdog source) │ + └──────────────────┘ └──────────────────────────────┘ + │ + ▼ + SPA polls /chart-events ──► new Notification(...) ``` -- **Append-only history.** Each refresh writes a `RankCheck` row keyed by `(keyword, watched_app, observed_at)`. We dedupe consecutive identical observations into a single row with `firstSeenAt`/`checkedAt`, so a stable rank doesn't bloat the DB but the timeline still tells you exactly when something changed. -- **No auth.** The server binds to `127.0.0.1` only. Anything that could send an HTTP request to it can already read the SQLite file directly — so the bearer-token gate would only add UX friction, not security. -- **Polite worker.** One job at a time, ~1 req/sec to iTunes. Stays well below Apple's edge-throttling threshold. +- **Append-only history.** Each refresh writes a `RankCheck` row. Consecutive identical observations dedupe into a single row with `firstSeenAt`/`checkedAt` so stable ranks don't bloat the DB but the timeline still tells you exactly when something changed. +- **Storefront-aware everywhere.** [Apple's 175-territory list](web/src/lib/countries.ts) is the source of truth. The *Add keyword* modal picks countries from a searchable multi-select with chips; the chart watchdog uses [per-app availability probing](Sources/App/Services/AvailabilityProber.swift) so it only polls storefronts where each app actually ships. +- **Chart-position watchdog.** Daily poll of Apple's free RSS top-free feed for each watched app's primary genre. Emits `entered` / `moved` / `exited` events ([Sources/App/Services/ChartTrackerService.swift](Sources/App/Services/ChartTrackerService.swift)); a browser-side polling loop fires `new Notification(...)` when something happens. Silent until something actually charts — most days are empty, which is correct. +- **No auth, polite worker.** Server binds to `127.0.0.1` only — anything that could reach it can already read the SQLite file directly. One job at a time, ~1 req/sec to iTunes; well below Apple's edge-throttling threshold. --- ## Dev workflow ```bash -# All targets are in the Makefile — run `make help` for the catalog. -make build-web # build the SPA into Public/ -make build # swift build the server -make dev-backend # run the server in the foreground -make dev-web # Vite dev server on :5173 (proxies /api → :8080) -make mac-app # build Keywordista.app from sources -make open-mac-app # build + open the .app -swift test # run server tests +make help # full target catalog +make dev-backend # Vapor on :8080 +make dev-web # Vite on :5173 (proxies /api → :8080) +swift test # Swift Testing suite +cd web && npm run check # svelte-check + tsc ``` -### Building a release DMG - -The `mac/build-dmg.sh` script produces a signed + notarized DMG suitable for sharing on GitHub Releases. It does the full release flow: universal binaries (arm64 + x86_64) for both the menubar app and the server, Developer ID Application signing with hardened runtime + timestamp, DMG packaging, Apple notarization, and ticket stapling. Output lands in `releases/Keywordista-$VERSION.dmg`. +Three layers: the Vapor server in `Sources/App/`, the Svelte 5 + Tailwind SPA in `web/`, and the SwiftUI `MenuBarExtra` app in `mac/`. Build outputs land in `Public/` (SPA) and `mac/Keywordista.app/` (menu-bar bundle). -**One-time setup** (only needed for full signing + notarization): - -```bash -# Store notarytool credentials in your keychain. You'll need an -# app-specific password from https://appleid.apple.com/account/manage -xcrun notarytool store-credentials keywordista \ - --apple-id \ - --team-id KHNA6PF8QV \ - --password -``` - -**Build commands:** - -```bash -make dmg # full release: sign + notarize + staple -make dmg-unsigned # skip signing entirely (faster, for testing) - -# Or per-stage opt-out via env vars: -KEYWORDISTA_SKIP_NOTARIZE=1 make dmg # sign but don't notarize -``` - -Contributors without a Developer ID cert can use `make dmg-unsigned` to verify the build flow. The resulting DMG installs but Gatekeeper will show "unidentified developer" on first launch. - -#### Automated releases via GitHub Actions - -Tagging `app-v0.1.0` and pushing the tag triggers `.github/workflows/release-app.yml`, which runs the same `build-dmg.sh` on a `macos-15` runner with all signing + notarization secrets injected. See [`.github/RELEASING.md`](.github/RELEASING.md) for the one-time secret-configuration ritual. - -### Project layout - -| Path | What lives there | -|---|---| -| `Sources/App/` | The Vapor server — models, controllers, services, jobs | -| `Tests/AppTests/` | Swift Testing suite (20 tests) for scoring + repositories + services | -| `web/` | The Svelte 5 + TypeScript + Tailwind SPA | -| `mac/` | The SwiftUI `MenuBarExtra` app + the `Keywordista.app` build script | -| `Public/` | Built SPA assets (regenerated by `npm run build`) | -| `keywordista` | Single-command launcher script for headless / dev mode | +Cutting a signed + notarized DMG: see [`.github/RELEASING.md`](.github/RELEASING.md). Tagged pushes (`app-v*`, `service-v*`) run the same release flow in CI. --- ## API surface -Everything under `/api/v1`. No auth — `127.0.0.1`-only. +Everything under `/api/v1`. No auth, `127.0.0.1`-only. Five categories: **apps**, **keywords**, **dashboard**, **charts**, **settings**. + +
+Full endpoint table | Method | Path | What | |---|---|---| -| `GET` | `/health` | Liveness probe (no auth needed; the menubar app pings this) | -| `POST` | `/apps` | Add a watched app — body `{ appStoreId, lookupCountry }` | +| `GET` | `/health` | Liveness probe (the menu-bar app pings this) | +| `POST` | `/apps` | Add a watched app — `{ appStoreId, lookupCountry }` | | `GET` | `/apps` | List watched apps | -| `DELETE` | `/apps/:id` | Remove a watched app (cascades to its rank history) | -| `POST` | `/keywords` | Add a tracked keyword — body `{ term, countryCode }` | +| `DELETE` | `/apps/:id` | Remove an app (cascades history) | +| `POST` | `/apps/:id/availability/refresh` | Re-probe a single app's 175 storefronts | +| `POST` | `/keywords` | Add a tracked keyword — `{ term, countryCode }` | | `GET` | `/keywords` | List keywords | | `DELETE` | `/keywords/:id` | Remove a keyword (cascade) | | `POST` | `/keywords/:id/refresh` | Enqueue one immediate refresh | | `POST` | `/refresh-all` | Enqueue refresh for every keyword | -| `GET` | `/refresh-status` | `{ pending }` — how many jobs are queued | -| `GET` | `/dashboard` | The dashboard table — one row per `(keyword, watched_app)` | -| `GET` | `/keywords/:id/history?watchedAppId=…` | Full rank history for one (keyword, app) pair | +| `GET` | `/refresh-status` | `{ pending }` — queued job count | +| `GET` | `/dashboard` | Dashboard table — one row per `(keyword, app)` | +| `GET` | `/keywords/:id/history?watchedAppId=…` | Full rank history for one `(keyword, app)` pair | +| `GET` | `/chart-positions` | Currently-charted snapshot rows | +| `GET` | `/chart-events?since=&limit=` | Append-only watchdog activity feed (SPA polls this) | +| `POST` | `/charts/refresh` | Kick the chart watchdog immediately | | `GET` | `/settings/{asc,asa}` | Read App Store Connect / Apple Search Ads credential status | | `PUT`/`DELETE` | `/settings/{asc,asa}` | Update / clear those credentials | -| `GET` | `/api/v1/version` | `{ current, latest, updateAvailable, downloadUrl }` — used by the menubar app's update check | +| `GET` | `/version` | `{ current, latest, updateAvailable, downloadUrl }` | -See `requests.http` for ready-to-run `curl`/JetBrains-HTTP examples. +See [`requests.http`](requests.http) for ready-to-run examples. + +
--- -## What's not here (yet) +## What's not here -- **Apple Search Ads popularity scores.** v1 derives `difficulty` and `entryBarrier` from search results alone. ASA integration is a hookable seam — the credentials slot in `/api/v1/settings/asa` is already wired, just no fetcher consuming it yet. -- **Push / email rank-change alerts.** The data's all in the history table; a small job consumer would do it. -- **Auto-updates of the menubar app itself.** The server can update independently (the menubar app pulls service tarballs from GitHub Releases — see `mac/Sources/Keywordista/`), but bumping the .app version still means downloading a new DMG. +- **Apple Search Ads popularity scores.** v1 derives `difficulty` and `entryBarrier` from search results alone. ASA credentials slot in at `/api/v1/settings/asa` is wired, just no fetcher consuming it yet. +- **Chart-position history / sparklines.** The watchdog stores only the latest position per `(app, country, chart, genre)`; a `chart_position_history` table is the v2 follow-up. +- **Auto-updates of the menu-bar app itself.** The server can update independently (it pulls service tarballs from GitHub Releases), but bumping the `.app` version still means downloading a new DMG. - **Linux / Windows.** Vapor runs everywhere; `Keywordista.app` is macOS-only. Linux users can clone + `./keywordista` from source. --- ## Contributing -Bug reports and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). The ASO scoring heuristic in particular (`Sources/App/Services/KeywordScorer.swift`) is a documented best-effort approximation; sharper formulas with citations are explicitly invited. +Bug reports and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). The ASO scoring heuristic in [`KeywordScorer.swift`](Sources/App/Domain/KeywordScorer.swift) is a documented best-effort approximation; sharper formulas with citations are explicitly invited. Found a vulnerability? See [SECURITY.md](SECURITY.md). ---- - -## License - [Apache License 2.0](LICENSE). © 2026 Astemir Boziev. diff --git a/docs/dashboard.png b/docs/dashboard.png new file mode 100644 index 0000000..fb70851 Binary files /dev/null and b/docs/dashboard.png differ