Compare commits
13 Commits
0bf15e528d
...
freno-dev/
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ff31346ff | |||
| 25ec84ceb4 | |||
| dd368d21e7 | |||
|
|
d44133fcb3 | ||
|
|
578ee58f9c | ||
|
|
d2c2e6a4ee | ||
|
|
c718370674 | ||
|
|
210a7e6e75 | ||
|
|
c1c498e37f | ||
| 134f41eac4 | |||
| 881190d1d4 | |||
| f492132bf2 | |||
| a524f23be2 |
@@ -4,13 +4,39 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
|
||||
artifact invents its own framing, fix the artifact or fix this file — never both
|
||||
silently diverging.
|
||||
|
||||
Primary source: `~/Code/the-nook/docs/marketing-strategy.md`.
|
||||
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 correction,
|
||||
against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354], [FRE-997]), the Chief
|
||||
of Staff's price decision (2026-10-29 04:01), the Plan B launch route ([FRE-346] →
|
||||
[FRE-203] `plan-b`), and `The-Nook` master (app floor is **14.2**, not 14.0 —
|
||||
`freno-dev` `881190d`, site merge `134f41e`). The previous version of this file
|
||||
named an upstream `the-nook/docs/marketing-strategy.md` that no longer exists; the
|
||||
verdicts below are the source of truth it was pointing at.
|
||||
|
||||
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
|
||||
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
|
||||
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
|
||||
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
|
||||
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
|
||||
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
|
||||
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
|
||||
|
||||
## The sentence
|
||||
|
||||
> **Your Mac and your agents, under control.** The Nook puts your whole coding-agent
|
||||
> fleet in the notch — thirteen agents, local or over SSH — so you see what is running,
|
||||
> unblock it in one tap, and keep the machine from overheating while it works.
|
||||
There is no single approved public sentence yet, and saying otherwise is the one
|
||||
mistake this file exists to prevent. What exists is a **launch claim set** — two
|
||||
sentences, both from one 15-second take, recorded as the chosen route by the Chief
|
||||
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
|
||||
|
||||
| Slot | Exact sentence | Status today |
|
||||
|---|---|---|
|
||||
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
|
||||
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
|
||||
|
||||
Working line for internal writing, not for publication:
|
||||
|
||||
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
|
||||
> see what is running, you unblock what is waiting, and the machine stays cool
|
||||
> while it works.
|
||||
|
||||
## The wedge
|
||||
|
||||
@@ -22,15 +48,18 @@ The thing to say over and over, in every channel, in slightly different words:
|
||||
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
|
||||
Allow, work resumes. Ten seconds, no explanation needed.
|
||||
|
||||
## The bundling reframe
|
||||
## The consolidation reframe
|
||||
|
||||
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
||||
$10 app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||
app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||
|
||||
> One $10 app instead of a notch toy, a fan utility, a battery limiter, a menu-bar
|
||||
> stats strip, and a notification hack. Same one-time purchase, no subscription.
|
||||
> One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
|
||||
> strip, and a notification hack. One purchase, no subscription.
|
||||
|
||||
Price rises to $15 at 1.0 — dated and honest, not a permanent beta.
|
||||
**No price, no discount and no future price in copy.** The price is $10, it stays
|
||||
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
|
||||
price change is a Chief of Staff + sponsor call; it is never a marketing decision
|
||||
and never a launch asset.
|
||||
|
||||
## Hard rules
|
||||
|
||||
@@ -48,24 +77,54 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
|
||||
community replies written by a human, where quoting what a user said about another
|
||||
tool is normal conversation.
|
||||
|
||||
### 2. A claim requires a test to exist
|
||||
### 2. A claim needs a verdict, not just a test
|
||||
|
||||
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
|
||||
If a claim cannot be pointed at code or a test, it does not go in copy.
|
||||
Copy that says "every agent", "13 agents", "no data leaves the device" must point at
|
||||
both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
|
||||
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
|
||||
tables below, it does not go in copy.
|
||||
|
||||
### 3. Honest privacy scoping
|
||||
|
||||
Telemetry and the trial email **do** leave the device. The defensible claim is:
|
||||
Telemetry and the licence check **do** leave the device: Sentry runs on **every
|
||||
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
|
||||
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
|
||||
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
|
||||
|
||||
> **No prompt or code content leaves the device.**
|
||||
- "no data leaves the device" / "local-only" / "no telemetry" / "no account"
|
||||
- "nothing leaves your Mac"
|
||||
|
||||
Never "no data leaves the device".
|
||||
The one favourable engine sentence — about the **hook and bridge only**, never
|
||||
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
|
||||
service**. Do not write "talk to nothing but your Mac" (too absolute: the
|
||||
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
|
||||
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
|
||||
it to "over SSH" (a tailnet peer dials directly).
|
||||
|
||||
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
|
||||
**held** until the disclosures land: Sentry gated behind a preference or removed,
|
||||
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
|
||||
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
|
||||
false ones is worse than no sentence.
|
||||
|
||||
### 4. Sell what works today
|
||||
|
||||
Phone approvals are deferred until a shipped iOS app exists — no "(coming soon)"
|
||||
parentheticals. The launch story is remote SSH: agents running on other machines,
|
||||
visible and unblockable from the Mac's notch.
|
||||
An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
|
||||
(`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
|
||||
one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
|
||||
all, and never claim approvals-from-phone as a shipped capability without its own
|
||||
CTO verdict — no "(coming soon)" parentheticals either way.
|
||||
|
||||
Remote SSH is **publishable**, and the site sells it again ([FRE-997]). The
|
||||
copy that was pulled on 2026-10-29 had been checked against claims it never made:
|
||||
hosts come from `~/.ssh/config` and Tailscale (`RemoteHostDiscovery`), the
|
||||
one-time `ssh-copy-id` is a prerequisite the row hands the user as a command
|
||||
(`RemoteHostStatus.fixCommand`), the per-host credential is minted and copied
|
||||
over (`RemoteMirror`), and a remote host mirrors this Mac's integrations
|
||||
(`RemoteMirrorReconcile`). Two things in the old copy were wrong and were fixed
|
||||
rather than restored: the "every hooked agent" overstatement, and a count that
|
||||
said thirteen against a registry of eighteen. Muse, Cline and Amp are
|
||||
`.unsupported` for remote in `AgentSpec.remote`, and the copy names them.
|
||||
|
||||
### 5. Humans hold every account
|
||||
|
||||
@@ -73,23 +132,30 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
|
||||
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
||||
nothing posts itself.
|
||||
|
||||
## Approved claims (each backed by code)
|
||||
## Claims: status at 2026-11-03
|
||||
|
||||
| Claim | Backing |
|
||||
|---|---|
|
||||
| Thirteen hooked agents, one glance | `AgentSpec.all` — claude, codex, opencode, pi, omp, muse, hermes, openclaw, zai, gemini, cline, qwen, deepseek |
|
||||
| Remote agents over SSH | `hook/setup.go` remote install path; `NOOK_HOST` TCP transport |
|
||||
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
|
||||
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
|
||||
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
|
||||
| macOS 14+ | 27 source files use the `@Observable` framework |
|
||||
| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
|
||||
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|
||||
|
||||
| Claim | Status | Evidence / next step |
|
||||
|---|---|---|
|
||||
| Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
|
||||
| Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
|
||||
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
|
||||
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
|
||||
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
|
||||
| Agent coverage numbers ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable, and `every` / `all` are still banned without naming the three local-only agents. |
|
||||
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
|
||||
| Remote agents over SSH, one click to install | **Publishable, with the three local-only agents named** | [FRE-997]: `RemoteHostDiscovery` (ssh config + tailnet), `RemoteMirror` (hook push, credential, mirroring), `RemoteMirrorReconcile`, `RemoteHostStatus.fixCommand` (the one-time `ssh-copy-id` the row shows). `AgentSpec.remote` is `.unsupported` for Muse, Cline and Amp — the landing bullet and /agents paragraph name them; never say "every hooked agent". |
|
||||
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
|
||||
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
|
||||
|
||||
## Voice
|
||||
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives ("thirteen agents",
|
||||
"one tap", "$10 once").
|
||||
- The notch/island is the product's home; agents are the product's point.
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives — the only count
|
||||
that ships is eighteen, and it is the registry's own size (see the coverage row
|
||||
above).
|
||||
- The notch is the product's home; agents are the product's point.
|
||||
- Never apologize for being a utility. The utility is the point.
|
||||
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
|
||||
"game-changer", "seamlessly". Unverifiable superlatives generally.
|
||||
"game-changer", "seamlessly", any unverifiable superlative — and the specific
|
||||
banned sentences in rule 3 and the table above.
|
||||
|
||||
21
scripts/backfill-downloads.ts
Normal file
21
scripts/backfill-downloads.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ConnectionFactory } from "~/server/db-connections";
|
||||
import { backfillDownloads } from "~/server/backfill-downloads";
|
||||
|
||||
// One-off: derive canonical /api/downloads/<asset> rows from download history
|
||||
// recorded before `trackDownload` existed. Idempotent — re-running adds
|
||||
// nothing, because derived rows carry a `backfill-<source id>` primary key.
|
||||
//
|
||||
// bun --env-file=.env scripts/backfill-downloads.ts
|
||||
|
||||
const result = await backfillDownloads(ConnectionFactory());
|
||||
|
||||
if (result.length === 0) {
|
||||
console.log("Nothing to backfill: no legacy download rows left to derive.");
|
||||
} else {
|
||||
for (const { asset, added } of result) {
|
||||
console.log(`${asset}: +${added}`);
|
||||
}
|
||||
console.log(
|
||||
`Total: +${result.reduce((sum, row) => sum + row.added, 0)} derived download rows.`
|
||||
);
|
||||
}
|
||||
@@ -959,7 +959,7 @@ export default function FeatureBreakdowns() {
|
||||
title="Your whole fleet, in one pixel strip"
|
||||
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
||||
bullets={[
|
||||
"13 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
|
||||
"18 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
|
||||
"Custom agents: your own coding agent can wire itself in from the published contract",
|
||||
"Slot rankings — you choose which data earns the notch",
|
||||
"You don't need a notch — external displays get the same island, floating top-center"
|
||||
@@ -976,7 +976,7 @@ export default function FeatureBreakdowns() {
|
||||
"Approval cards pin above the session list; questions answer inside their row",
|
||||
"Allow for session waves through the rest of that session's requests",
|
||||
"Answer from iPhone or Apple Watch too, now in TestFlight beta",
|
||||
"Works across every hooked agent, local or remote"
|
||||
"Works across local and remote sessions; Muse, Cline and Amp are this Mac only"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
|
||||
@@ -722,7 +722,7 @@ function LayoutMock() {
|
||||
/* ── Marquee ─────────────────────────────────────────────────────── */
|
||||
|
||||
const CHIPS_A = [
|
||||
"13 agents",
|
||||
"18 agents",
|
||||
"Custom agents",
|
||||
"Six chiptunes",
|
||||
"Focus-aware slots",
|
||||
|
||||
@@ -12,6 +12,35 @@
|
||||
* Tests inject a fake `api` and a capture sink.
|
||||
*/
|
||||
|
||||
/** Canonical analytics path for one download event. One label per asset, so a
|
||||
* click on a landing page and a Sparkle update of the same app land in the
|
||||
* same bucket on the analytics page. */
|
||||
export const DOWNLOAD_PATH_PREFIX = "/api/downloads/";
|
||||
|
||||
export function downloadPath(asset: string): string {
|
||||
return `${DOWNLOAD_PATH_PREFIX}${asset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group download rows by the asset named in their canonical path, and total
|
||||
* them. Split out from the query so the labelling has a unit test without a
|
||||
* database — the writer and the reader share `DOWNLOAD_PATH_PREFIX`, so the
|
||||
* two cannot drift apart.
|
||||
*/
|
||||
export function summariseDownloads(
|
||||
rows: Array<{ path: string; count: number }>
|
||||
): { total: number; byAsset: Array<{ asset: string; count: number }> } {
|
||||
const byAsset = rows.map((row) => ({
|
||||
asset: row.path.slice(DOWNLOAD_PATH_PREFIX.length),
|
||||
count: row.count
|
||||
}));
|
||||
|
||||
return {
|
||||
total: byAsset.reduce((sum, row) => sum + row.count, 0),
|
||||
byAsset
|
||||
};
|
||||
}
|
||||
|
||||
/** Structural shape of the tRPC downloads proxy this helper depends on. */
|
||||
export interface DownloadApi {
|
||||
downloads: {
|
||||
|
||||
@@ -202,6 +202,32 @@ function FrenomePanel(props: FrenomePanelProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Downloads — every door logs one row at /api/downloads/<asset> */}
|
||||
<Show when={data.downloads.total > 0}>
|
||||
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
|
||||
<div class="border-surface1 border-b p-6">
|
||||
<h2 class="text-text text-2xl font-bold">Downloads</h2>
|
||||
<p class="text-subtext0 mt-1 text-sm">
|
||||
{formatNumber(data.downloads.total)} events in the last
|
||||
{props.days === 1 ? " 24 hours" : ` ${props.days} days`} —
|
||||
landing page clicks and files served, by product
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3 p-6">
|
||||
<For each={data.downloads.byAsset}>
|
||||
{(download) => (
|
||||
<StatBar
|
||||
label={download.asset}
|
||||
count={download.count}
|
||||
total={data.downloads.total}
|
||||
color="bg-teal-600"
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Performance */}
|
||||
<Show when={performanceStats()} keyed>
|
||||
{(ps) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { env } from "~/env/server";
|
||||
import { trackDownload } from "~/server/track-download";
|
||||
|
||||
/**
|
||||
* Serves macOS app DMG files and delta updates from S3
|
||||
@@ -27,10 +28,20 @@ export async function GET(event: APIEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
const validPrefixes = ["Gaze", "InputHalo", "TheNook", "Nook"];
|
||||
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
|
||||
// Prefix → canonical asset key, so a version bump stays one asset in
|
||||
// analytics rather than one per release.
|
||||
const assetKeys: Record<string, string> = {
|
||||
Gaze: "gaze",
|
||||
InputHalo: "inputhalo",
|
||||
TheNook: "thenook",
|
||||
Nook: "thenook"
|
||||
};
|
||||
|
||||
const asset = Object.keys(assetKeys).find((prefix) =>
|
||||
filename.startsWith(prefix)
|
||||
);
|
||||
if (
|
||||
!isValidPrefix ||
|
||||
!asset ||
|
||||
(!filename.endsWith(".dmg") &&
|
||||
!filename.endsWith(".delta") &&
|
||||
!filename.endsWith(".zip"))
|
||||
@@ -84,13 +95,20 @@ export async function GET(event: APIEvent) {
|
||||
|
||||
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
||||
|
||||
return new Response(body, {
|
||||
await trackDownload(event, assetKeys[asset]!);
|
||||
|
||||
// SAFETY: `transformToByteArray` yields `Uint8Array<ArrayBufferLike>`, which the
|
||||
// DOM lib's `BufferSource` (ArrayBuffer-backed) does not accept, though undici
|
||||
// takes it as-is at runtime. State the intent at the one boundary it hits.
|
||||
return new Response(body as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": body.length.toString(),
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
||||
// Deliberately open: these are public installer files with no cookies or
|
||||
// credentials, fetched by Sparkle and by direct browser navigation.
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,14 +19,24 @@ const SOURCE = readFileSync(
|
||||
"utf8"
|
||||
);
|
||||
|
||||
/**
|
||||
* The allowlist moved from an array to a `prefix → asset key` map when download
|
||||
* tracking was added, so read the keys out of the map rather than matching
|
||||
* quoted strings.
|
||||
*/
|
||||
const ALLOWLIST_KEYS = [
|
||||
...(/const assetKeys: Record<string, string> = \{([^}]*)\}/.exec(SOURCE)?.[1] ?? "")
|
||||
.matchAll(/"?([A-Za-z]+)"?\s*:/g)
|
||||
].map((match) => match[1]);
|
||||
|
||||
describe("Downloads proxy filename allowlist (NOOK-337)", () => {
|
||||
it("allows the Nook app-bundle prefix used by Sparkle delta names", () => {
|
||||
expect(SOURCE).toContain('"Nook"');
|
||||
expect(ALLOWLIST_KEYS).toContain("Nook");
|
||||
});
|
||||
|
||||
it("still allows every previously served prefix", () => {
|
||||
for (const prefix of ["Gaze", "InputHalo", "TheNook"]) {
|
||||
expect(SOURCE).toContain(`"${prefix}"`);
|
||||
expect(ALLOWLIST_KEYS).toContain(prefix);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("GET /api/the-nook/download", () => {
|
||||
|
||||
expect(logged.length).toBe(1);
|
||||
expect(logged[0]).toMatchObject({
|
||||
path: "/api/the-nook/download",
|
||||
path: "/api/downloads/thenook",
|
||||
method: "GET",
|
||||
referrer: "https://nook.freno.me/"
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import { logVisit, enrichAnalyticsEntry, flushAnalytics } from "~/server/analytics";
|
||||
import { parseUtm } from "~/server/utm";
|
||||
import { trackDownload } from "~/server/track-download";
|
||||
import { resolveLatestDownload } from "./_download";
|
||||
|
||||
/**
|
||||
@@ -15,13 +13,11 @@ import { resolveLatestDownload } from "./_download";
|
||||
* Why a redirect and not a direct S3 link: the appcast route and the DMG
|
||||
* route never logged anything, so step 2 of the funnel (download click →
|
||||
* DMG served) was invisible. Every click now lands one row in
|
||||
* VisitorAnalytics under the path below, which is what `topApiCalls`
|
||||
* already surfaces.
|
||||
* VisitorAnalytics under `/api/downloads/thenook`, alongside the clicks and
|
||||
* served files of every other product — see `~/server/track-download`.
|
||||
*
|
||||
* Country is read from the edge headers Vercel/Cloudflare set; the
|
||||
* analytics buffer carries a `country` column that nothing populated
|
||||
* until now. `?c=` is accepted as an override for previews and manual
|
||||
* verification.
|
||||
* Referrer, campaign tags, edge country (with `?c=` override) and user agent
|
||||
* are read by `trackDownload`, which is shared with the other download doors.
|
||||
*
|
||||
* ONLY the handler is exported here: Vinxi's route tree-shake deletes
|
||||
* exported declarations that are not in the router's `pick=` list, so a
|
||||
@@ -29,47 +25,10 @@ import { resolveLatestDownload } from "./_download";
|
||||
* referenced it. All resolution helpers live in `./_download.ts`.
|
||||
*/
|
||||
|
||||
function firstHeader(request: Request, ...names: string[]): string | null {
|
||||
for (const name of names) {
|
||||
const value = request.headers.get(name);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(event: APIEvent): Promise<Response> {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
const forwarded = firstHeader(request, "x-forwarded-for");
|
||||
const ipAddress =
|
||||
getRequestIP(event.nativeEvent) ??
|
||||
(forwarded ? forwarded.split(",")[0]!.trim() : null);
|
||||
|
||||
// Best-effort: a failed analytics write must never block a download.
|
||||
// Flushed (not merely buffered) because a download click is low-volume and
|
||||
// the batch timer is not guaranteed to survive the response on serverless.
|
||||
try {
|
||||
const referrer = firstHeader(request, "referer", "referrer");
|
||||
await logVisit(
|
||||
enrichAnalyticsEntry({
|
||||
path: "/api/the-nook/download",
|
||||
method: "GET",
|
||||
referrer,
|
||||
userAgent: firstHeader(request, "user-agent"),
|
||||
ipAddress,
|
||||
// The click carries its own tags when a campaign links straight here;
|
||||
// otherwise the landing page's tags ride along in the referer.
|
||||
...parseUtm(request.url, referrer),
|
||||
country:
|
||||
url.searchParams.get("c") ??
|
||||
firstHeader(request, "x-vercel-ip-country", "cf-ipcountry")
|
||||
})
|
||||
);
|
||||
await flushAnalytics();
|
||||
} catch (error) {
|
||||
console.error("Failed to log download click:", error);
|
||||
}
|
||||
// Recorded before resolving: a click that dies at resolution is exactly the
|
||||
// failure the funnel needs to show.
|
||||
await trackDownload(event, "thenook");
|
||||
|
||||
let target: string | null = null;
|
||||
try {
|
||||
|
||||
@@ -110,7 +110,7 @@ function MainDownloadsPage() {
|
||||
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<span class="text-subtext0 font-mono text-sm">
|
||||
platform: macOS (14.6+)
|
||||
platform: macOS (14.2+)
|
||||
</span>
|
||||
<Button
|
||||
variant="download"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Regression test for the /agents roster (NOOK-336).
|
||||
* Regression test for the /agents roster and its coverage count.
|
||||
*
|
||||
* The page mirrors the app's registry, `AgentSpec.all` in
|
||||
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
|
||||
@@ -8,6 +8,12 @@
|
||||
* them. The registry lives in another repo, so the expected roster is
|
||||
* restated here; when an agent is added there, add it here and on the page.
|
||||
*
|
||||
* FRE-997 restored the count. The page said thirteen while the registry held
|
||||
* eighteen, so the count was dropped rather than corrected (FRE-359 verdict B);
|
||||
* now that all eighteen rows are on the page, the roster check is an equality
|
||||
* check again and the page states eighteen. A subset check would let a shipped
|
||||
* agent go missing behind a passing suite, which is the NOOK-336 failure.
|
||||
*
|
||||
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
@@ -16,7 +22,13 @@ import { join } from "node:path";
|
||||
|
||||
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
|
||||
|
||||
/** AgentSpec.all display names, in registry order. */
|
||||
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
|
||||
/** JSX wraps prose across lines; match a sentence, not its indentation. */
|
||||
const PROSE = COPY.replace(/\s+/g, " ");
|
||||
|
||||
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
|
||||
const REGISTRY = [
|
||||
"Claude Code",
|
||||
"Codex",
|
||||
@@ -30,25 +42,49 @@ const REGISTRY = [
|
||||
"Gemini CLI",
|
||||
"Cline",
|
||||
"Qwen Code",
|
||||
"DeepSeek"
|
||||
"DeepSeek",
|
||||
"Kimi",
|
||||
"GitHub Copilot CLI",
|
||||
"Crush",
|
||||
"Cursor",
|
||||
"Amp"
|
||||
];
|
||||
|
||||
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen"];
|
||||
|
||||
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
|
||||
|
||||
describe("Nook /agents roster (NOOK-336)", () => {
|
||||
it("lists every registry agent, in registry order", () => {
|
||||
describe("Nook /agents roster", () => {
|
||||
it("lists every registry agent, once, in registry order", () => {
|
||||
expect(entries).toEqual(REGISTRY);
|
||||
});
|
||||
|
||||
it("states the same count everywhere it names one", () => {
|
||||
const word = NUMBER_WORDS[entries.length - 12];
|
||||
for (const other of NUMBER_WORDS.filter((w) => w !== word)) {
|
||||
expect(SOURCE.toLowerCase()).not.toContain(other);
|
||||
}
|
||||
expect(SOURCE).toContain(`all ${word}`);
|
||||
expect(SOURCE).toContain(`The ${entries.length} agents`);
|
||||
expect(SOURCE).toContain(`watches ${word} coding agents`);
|
||||
it("states the registry's size", () => {
|
||||
expect(entries.length).toBe(18);
|
||||
expect(COPY).toContain("all eighteen");
|
||||
expect(COPY).toContain("The 18 agents");
|
||||
expect(COPY).toContain("eighteen coding agents");
|
||||
});
|
||||
|
||||
it("carries no stale count", () => {
|
||||
expect(COPY.toLowerCase()).not.toContain("thirteen");
|
||||
expect(COPY).not.toMatch(/\b13 agents\b/);
|
||||
expect(COPY).not.toContain("all thirteen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nook /agents remote coverage", () => {
|
||||
it("says agents can run on remote machines over SSH", () => {
|
||||
expect(PROSE).toContain("remote machines");
|
||||
expect(PROSE).toContain("remote sessions report into the same grid");
|
||||
});
|
||||
|
||||
it("names the three agents that stay on this Mac", () => {
|
||||
expect(PROSE).toContain("Muse, Cline and Amp run on this Mac only");
|
||||
});
|
||||
|
||||
it("discloses the local-only rows where they are listed", () => {
|
||||
const localOnly = [
|
||||
...SOURCE.matchAll(/This Mac only — remote hosts skip it/g)
|
||||
];
|
||||
expect(localOnly.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* `/agents` on the Nook subdomain — supported agents and their event coverage.
|
||||
* `/agents` on the Nook subdomain — all eighteen agents in the registry and
|
||||
* each row's event coverage.
|
||||
*
|
||||
* The registry below mirrors `AgentSpec.all` in the app
|
||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
|
||||
* installed hook surface (hook/setup.go) or plugin event subscription
|
||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentRegistry.swift) and each
|
||||
* agent's installed hook surface (hook/setup.go) or plugin event subscription
|
||||
* (plugins/*), normalized onto the shared event vocabulary
|
||||
* (~/Code/the-nook/docs/agent-interface.md). Copy follows
|
||||
* marketing/positioning.md: category nouns, honest scoping, no unverifiable
|
||||
@@ -208,7 +209,8 @@ const AGENTS: AgentEntry[] = [
|
||||
},
|
||||
{
|
||||
name: "Cline",
|
||||
wiring: "One hook file per event in its hooks directory",
|
||||
wiring:
|
||||
"One hook file per event in its hooks directory. This Mac only — remote hosts skip it",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
@@ -259,6 +261,93 @@ const AGENTS: AgentEntry[] = [
|
||||
],
|
||||
gating:
|
||||
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
||||
},
|
||||
{
|
||||
name: "Kimi",
|
||||
wiring: "Hook rules in its TOML config, one per event",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"AgentStart",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PermissionResolved",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"Notification"
|
||||
],
|
||||
gating:
|
||||
"Observation — every event appears as a card; its PreToolUse fires before its own permission check, so the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "GitHub Copilot CLI",
|
||||
wiring: "One hook file of ours in its hooks directory",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"ErrorOccurred",
|
||||
"PreCompact",
|
||||
"Notification"
|
||||
],
|
||||
gating:
|
||||
"Observation — every event appears as a card; its permission request runs before its own policy check, so the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "Crush",
|
||||
wiring:
|
||||
"One entry in its JSONC config, the only event it documents",
|
||||
events: ["PreToolUse"],
|
||||
gating:
|
||||
"Observation — tool calls appear as cards; its own permission prompts decide"
|
||||
},
|
||||
{
|
||||
name: "Cursor",
|
||||
wiring:
|
||||
"Hook entries in its hooks.json, merged with your own. No permission hook, on purpose",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Stop",
|
||||
"SessionError",
|
||||
"AfterAgentResponse",
|
||||
"SubagentStop",
|
||||
"PreCompact"
|
||||
],
|
||||
gating:
|
||||
"Observation — a permission hook that stays silent blocks the action here, so Cursor keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "Amp",
|
||||
wiring:
|
||||
"In-process extension package. This Mac only — remote hosts skip it",
|
||||
events: [
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SessionError"
|
||||
],
|
||||
gating:
|
||||
"Observation — its plugin API has no permission-wait event, so the agent keeps its own prompting"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -283,8 +372,8 @@ export default function NookAgentsPage() {
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Supported agents"
|
||||
description="Every coding agent The Nook supports — all thirteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
||||
ogTitle="The 13 agents The Nook supports, and what each hook sees"
|
||||
description="Every coding agent The Nook supports — all eighteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
||||
ogTitle="The 18 agents The Nook supports, and what each hook sees"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
@@ -292,7 +381,7 @@ export default function NookAgentsPage() {
|
||||
Supported agents
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
|
||||
The Nook watches thirteen coding agents in one place — your menu bar and
|
||||
The Nook watches eighteen coding agents in one place — your menu bar and
|
||||
notch show every session that is running, thinking, or blocked, and a
|
||||
card answers permission prompts without leaving your keyboard.
|
||||
</p>
|
||||
@@ -301,7 +390,8 @@ export default function NookAgentsPage() {
|
||||
means the agent hands the decision to The Nook while it waits;
|
||||
"Observe" means every event still appears live, but the agent keeps
|
||||
its own prompting. Agents can run on this Mac or on remote machines
|
||||
over SSH — remote sessions report into the same grid.
|
||||
over SSH — remote sessions report into the same grid. Muse, Cline and
|
||||
Amp run on this Mac only.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
|
||||
@@ -37,9 +37,9 @@ const FAQ: FaqEntry[] = [
|
||||
"The app itself runs unprivileged. Fan control and charge limiting go through a small helper component that macOS requires to run as root for hardware access — the app installs it on first use and you approve it once with your password. The hooks live in each agent's own config directory; you can inspect or remove every file it writes."
|
||||
},
|
||||
{
|
||||
question: "Why macOS 14 or later?",
|
||||
question: "Why macOS 14.2 or later?",
|
||||
answer:
|
||||
"The interface is built on Apple's current observation framework, which ships with macOS 14 (Sonoma)."
|
||||
"The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma)."
|
||||
},
|
||||
{
|
||||
question: "Does it work offline?",
|
||||
@@ -56,7 +56,7 @@ export default function NookFaqPage() {
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Frequently asked questions"
|
||||
description="Does The Nook read your prompts? Do you need a notch? Battery cost, permissions, the macOS 14 requirement, offline use — answered straight."
|
||||
description="Does The Nook read your prompts? Do you need a notch? Battery cost, permissions, the macOS 14.2 requirement, offline use — answered straight."
|
||||
ogTitle="The Nook — FAQ"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function NookLanding() {
|
||||
UI.
|
||||
</p>
|
||||
<p class="text-subtext1 mb-8 text-sm">
|
||||
macOS 14+ · 14-day free trial · up to 3 devices
|
||||
macOS 14.2+ · 14-day free trial · up to 3 devices
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
|
||||
|
||||
112
src/routes/nook/remote-claims.test.ts
Normal file
112
src/routes/nook/remote-claims.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Guard for the remote / SSH story on the Nook site, and for the two phrasings
|
||||
* that must not come back with it (FRE-323 → FRE-997).
|
||||
*
|
||||
* FRE-323 removed the remote copy because it had been checked against claims
|
||||
* the copy never made: the app does list hosts from `~/.ssh/config` and
|
||||
* Tailscale, a one-time `ssh-copy-id` is a normal prerequisite the row explains,
|
||||
* and the per-host credential is minted and copied automatically
|
||||
* (`RemoteHostDiscovery`, `RemoteHostStatus.fixCommand`, `RemoteMirror`). Two
|
||||
* things in the old copy were genuinely wrong, and this file guards those
|
||||
* instead of the copy as a whole:
|
||||
*
|
||||
* - "Works across every hooked agent, local or remote" overstated it. Muse,
|
||||
* Cline and Amp are `.unsupported` for remote in `AgentSpec.remote`, so the
|
||||
* landing bullet and the /agents paragraph name them.
|
||||
* - The count said thirteen while `AgentSpec.all` holds eighteen. The fix was
|
||||
* to say eighteen, not to drop the count.
|
||||
*
|
||||
* So the removal guards from `d44133f` are gone: a removed sentence is exactly
|
||||
* what this task restores. What stays is the pair of bans, because either one
|
||||
* coming back would repeat the original defect.
|
||||
*
|
||||
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
|
||||
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "../../..");
|
||||
|
||||
/** Shipped page and component source. */
|
||||
const AUDITED = [
|
||||
"src/routes/nook/index.tsx",
|
||||
"src/routes/nook/agents.tsx",
|
||||
"src/routes/nook/faq.tsx",
|
||||
"src/components/nook/FeatureCollage.tsx",
|
||||
"src/components/nook/FeatureBreakdowns.tsx",
|
||||
"src/components/nook/IslandExtras.tsx"
|
||||
];
|
||||
|
||||
/** The overstatements the restored copy must never carry again. */
|
||||
const BANNED_REMOTE_CLAIMS = [
|
||||
// a universal claim: three registry agents cannot mirror over SSH
|
||||
"every hooked agent, local or remote",
|
||||
"Works across every hooked agent",
|
||||
// the stale count this task replaced
|
||||
"13 agents",
|
||||
"all thirteen",
|
||||
"thirteen coding agents"
|
||||
];
|
||||
|
||||
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||
function copyOf(file: string): string {
|
||||
return readFileSync(join(ROOT, file), "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
describe("the restored remote copy is on the site (FRE-997)", () => {
|
||||
it("the landing collage sells agents on other machines", () => {
|
||||
const copy = copyOf("src/components/nook/FeatureCollage.tsx");
|
||||
for (const phrase of [
|
||||
"Agents on other machines",
|
||||
"one click installs the nook-hook",
|
||||
"Remote"
|
||||
]) {
|
||||
expect(copy).toContain(phrase);
|
||||
}
|
||||
});
|
||||
|
||||
it("the landing breakdown keeps the SSH section and its demo", () => {
|
||||
const copy = copyOf("src/components/nook/FeatureBreakdowns.tsx");
|
||||
for (const phrase of [
|
||||
"Any SSH box, one click away",
|
||||
"Pick a host from your ssh config or your tailnet and click once",
|
||||
"Remote sessions are ordinary rows",
|
||||
"RemoteHostDemo"
|
||||
]) {
|
||||
expect(copy).toContain(phrase);
|
||||
}
|
||||
});
|
||||
|
||||
it("the chip is back", () => {
|
||||
expect(copyOf("src/components/nook/IslandExtras.tsx")).toContain(
|
||||
"One-click SSH setup"
|
||||
);
|
||||
});
|
||||
|
||||
it("/agents says remote sessions report into the same grid", () => {
|
||||
const copy = copyOf("src/routes/nook/agents.tsx");
|
||||
expect(copy).toContain("remote machines");
|
||||
expect(copy).toContain("remote sessions report into the same grid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the two original overstatements stay off the site (FRE-323, FRE-359)", () => {
|
||||
for (const file of AUDITED) {
|
||||
it(`${file} claims no universal remote coverage and no stale count`, () => {
|
||||
const copy = copyOf(file);
|
||||
for (const phrase of BANNED_REMOTE_CLAIMS) {
|
||||
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("the landing names the agents that stay local", () => {
|
||||
expect(copyOf("src/components/nook/FeatureBreakdowns.tsx")).toContain(
|
||||
"Muse, Cline and Amp are this Mac only"
|
||||
);
|
||||
});
|
||||
});
|
||||
139
src/routes/smarter-remote/privacy.tsx
Normal file
139
src/routes/smarter-remote/privacy.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* A Smarter Remote privacy policy — `freno.me/smarter-remote/privacy`.
|
||||
*
|
||||
* Linked from the iOS app's paywall (App Review 3.1.2). Reflects what the app
|
||||
* actually does: TV control stays on the local network, pairing data stays on
|
||||
* the phone, purchases go through Apple and RevenueCat, and sharing is a QR
|
||||
* code passed between phones.
|
||||
*/
|
||||
import { A } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
|
||||
export default function SmarterRemotePrivacyPolicy() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="A Smarter Remote Privacy Policy"
|
||||
description="Privacy policy for A Smarter Remote, a universal TV remote app that works over your local Wi-Fi network."
|
||||
/>
|
||||
<div class="bg-base">
|
||||
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<div class="py-4 text-xl">A Smarter Remote's Privacy Policy</div>
|
||||
<div class="py-2">Last Updated: November 2, 2026</div>
|
||||
<div class="py-2">
|
||||
Welcome to A Smarter Remote ('We', 'Us',
|
||||
'Our'). The app controls your TVs over your own Wi-Fi
|
||||
network. It has no accounts, no ads, and no analytics. This policy
|
||||
explains the little data it does handle.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">1.</span> Data We Collect
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) Your TVs:</div> The app finds TVs on
|
||||
your local network and stores their names, network addresses,
|
||||
and platform on your phone. Pairing codes and tokens are kept
|
||||
in your phone's Keychain. This data is sent only to the
|
||||
TV itself, over your local network. It never reaches us.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Purchases:</div> Payments are handled
|
||||
by Apple. We use RevenueCat to check whether you own A Smarter
|
||||
Remote Pro. RevenueCat receives an anonymous app user ID, your
|
||||
purchase and subscription history, and basic device and app
|
||||
details such as OS version, app version, and region. It does
|
||||
not receive your name, email, or payment details.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Voice input:</div> If you use voice
|
||||
search, your speech is converted to text by Apple's
|
||||
speech recognition. Apple may process it on its servers. The
|
||||
resulting text is sent only to your TV.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Sharing:</div> If you share a TV with
|
||||
someone, its name, address, and platform are put into a QR
|
||||
code on your screen for them to scan. It goes from one phone
|
||||
to the other and does not pass through our servers.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">2.</span> How We Use It
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
Data about your TVs is used only to control them. Purchase
|
||||
records are used only to unlock Pro. We do not use any of it for
|
||||
advertising, and we do not sell it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">3.</span> Local Network Access
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
iOS asks for permission before the app can use your local
|
||||
network. The app needs it to find and control your TVs. If you
|
||||
decline, the app cannot reach your TVs. You can change this in
|
||||
Settings > Privacy & Security > Local Network.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">4.</span> Deleting Your Data
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
Removing a TV in the app deletes its stored details and
|
||||
credentials from your phone. Deleting the app removes all of
|
||||
them. Subscriptions are managed and cancelled in your App Store
|
||||
account settings.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">5.</span> Children's Privacy
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
The app does not knowingly collect personal information from
|
||||
anyone, including children under 13.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">6.</span> Changes to the Privacy Policy
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
We may update this policy periodically. Any changes are posted on
|
||||
this page, so please review it from time to time.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">7.</span> Contact Us
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
If you have any questions about this policy, you can contact us{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
here
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
141
src/routes/smarter-remote/terms.tsx
Normal file
141
src/routes/smarter-remote/terms.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* A Smarter Remote terms of use — `freno.me/smarter-remote/terms`.
|
||||
*
|
||||
* Linked from the iOS app's paywall (App Review 3.1.2). Builds on Apple's
|
||||
* standard EULA and adds the Pro subscription and lifetime purchase terms.
|
||||
*/
|
||||
import { A } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
|
||||
export default function SmarterRemoteTerms() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="A Smarter Remote Terms of Use"
|
||||
description="Terms of use for A Smarter Remote and its Pro subscription."
|
||||
/>
|
||||
<div class="bg-base">
|
||||
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<div class="py-4 text-xl">A Smarter Remote's Terms of Use</div>
|
||||
<div class="py-2">Last Updated: November 2, 2026</div>
|
||||
<div class="py-2">
|
||||
These terms cover your use of the A Smarter Remote app ('the
|
||||
App'). By using the App you agree to them. The App is licensed
|
||||
to you under Apple's{" "}
|
||||
<a
|
||||
href="https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
Licensed Application End User License Agreement
|
||||
</a>
|
||||
, and the terms below add to it.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">1.</span> A Smarter Remote Pro
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) What it includes:</div> The free App
|
||||
controls one TV. Pro removes that limit and unlocks the
|
||||
features marked Pro in the App.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Yearly subscription:</div> Payment is
|
||||
charged to your Apple account when you confirm the purchase,
|
||||
or when any free trial ends. The subscription renews
|
||||
automatically at the same price unless you cancel at least 24
|
||||
hours before the end of the current period. Your account is
|
||||
charged for renewal within 24 hours before the period ends.
|
||||
Any unused part of a free trial is forfeited when you buy a
|
||||
subscription.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Lifetime purchase:</div> A one-time
|
||||
payment that unlocks Pro with no recurring charge.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Managing and refunds:</div> You can
|
||||
manage or cancel a subscription in your App Store account
|
||||
settings. Refunds are handled by Apple under its policies.
|
||||
Restore Purchases in the App re-unlocks Pro on another device
|
||||
signed in to the same Apple account.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">2.</span> Your TVs
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
The App controls TVs through the network features their makers
|
||||
provide. Which features work depends on your TV's model and
|
||||
software, which can change without notice. We do not guarantee
|
||||
that every feature works on every TV. Only control TVs you own
|
||||
or have permission to use.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">3.</span> No Warranty
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
The App is provided 'as is', without warranties of any
|
||||
kind, to the extent the law allows. To the extent the law allows,
|
||||
we are not liable for indirect or consequential damages arising
|
||||
from your use of the App.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">4.</span> Privacy
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
How the App handles data is described in the{" "}
|
||||
<A
|
||||
href="/smarter-remote/privacy"
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
Privacy Policy
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">5.</span> Changes to These Terms
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
We may update these terms periodically. Any changes are posted on
|
||||
this page. Continuing to use the App after a change means you
|
||||
accept the new terms.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">6.</span> Contact Us
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
If you have any questions about these terms, you can contact us{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
here
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConnectionFactory } from "./database";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import type { VisitorAnalytics, AnalyticsQuery } from "~/db/types";
|
||||
import { DOWNLOAD_PATH_PREFIX, summariseDownloads } from "~/lib/download-asset";
|
||||
import { CACHE_CONFIG } from "~/config";
|
||||
import {
|
||||
VISITOR_ANALYTICS_ADDED_COLUMNS,
|
||||
@@ -272,6 +273,10 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
|
||||
topReferrers: Array<{ referrer: string; count: number }>;
|
||||
deviceTypes: Array<{ type: string; count: number }>;
|
||||
browsers: Array<{ browser: string; count: number }>;
|
||||
downloads: {
|
||||
total: number;
|
||||
byAsset: Array<{ asset: string; count: number }>;
|
||||
};
|
||||
topCampaigns: Array<{
|
||||
source: string | null;
|
||||
medium: string | null;
|
||||
@@ -409,6 +414,24 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
|
||||
count: row.count as number
|
||||
}));
|
||||
|
||||
// Downloads: every door records one row under `/api/downloads/<asset>` via
|
||||
// `trackDownload`, so the asset is the path suffix and nothing has to be
|
||||
// parsed out of a query string.
|
||||
const downloadsResult = await conn.execute({
|
||||
sql: `SELECT path, COUNT(*) as count FROM VisitorAnalytics
|
||||
WHERE path LIKE '${DOWNLOAD_PATH_PREFIX}%'
|
||||
AND created_at >= datetime('now', '-${days} days')
|
||||
GROUP BY path
|
||||
ORDER BY count DESC`,
|
||||
args: []
|
||||
});
|
||||
const downloads = summariseDownloads(
|
||||
downloadsResult.rows.map((row) => ({
|
||||
path: String(row.path),
|
||||
count: row.count as number
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
totalVisits,
|
||||
totalPageVisits,
|
||||
@@ -420,7 +443,8 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{
|
||||
topReferrers,
|
||||
deviceTypes,
|
||||
browsers,
|
||||
topCampaigns
|
||||
topCampaigns,
|
||||
downloads
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env } from "~/env/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { trackDownload } from "~/server/track-download";
|
||||
|
||||
const assets: Record<string, string> = {
|
||||
lineage: "Life and Lineage.apk",
|
||||
@@ -73,7 +74,14 @@ const latestAssets: Record<
|
||||
export const downloadsRouter = createTRPCRouter({
|
||||
getDownloadUrl: publicProcedure
|
||||
.input(z.object({ asset_name: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Logged before signing: a click on a door that then fails to sign is
|
||||
// exactly the event the funnel needs to show. Unknown asset names are
|
||||
// skipped rather than recorded as a download that never happened.
|
||||
if (assets[input.asset_name] || latestAssets[input.asset_name]) {
|
||||
await trackDownload(ctx.event, input.asset_name);
|
||||
}
|
||||
|
||||
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||
|
||||
const credentials = {
|
||||
|
||||
186
src/server/backfill-downloads.test.ts
Normal file
186
src/server/backfill-downloads.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import {
|
||||
VISITOR_ANALYTICS_COLUMNS,
|
||||
VISITOR_ANALYTICS_DDL,
|
||||
VISITOR_ANALYTICS_INDEXES,
|
||||
VISITOR_ANALYTICS_INSERT
|
||||
} from "./analytics-schema";
|
||||
import { summariseDownloads } from "~/lib/download-asset";
|
||||
import { assetFromLegacyPath, backfillDownloads } from "./backfill-downloads";
|
||||
|
||||
// Why a real in-memory SQLite database rather than a stub: the backfill runs
|
||||
// real SQL against the live table shape, and a stub would happily accept a
|
||||
// column list the real table does not have. Same reasoning as
|
||||
// analytics-schema.test.ts.
|
||||
//
|
||||
// Nothing is module-mocked here on purpose: bun's `mock.module` is process-wide,
|
||||
// so stubbing the connection factory would leak into the rate-limit and CSRF
|
||||
// suites that share the same database module.
|
||||
|
||||
let db: Database;
|
||||
|
||||
/** Adapt bun:sqlite to the libsql `execute(stmt | sql)` contract. */
|
||||
const conn = {
|
||||
async execute(query: string | { sql: string; args?: unknown[] }) {
|
||||
const sql = typeof query === "string" ? query : query.sql;
|
||||
const args = (
|
||||
typeof query === "string" ? [] : (query.args ?? [])
|
||||
) as never[];
|
||||
const statement = db.query(sql);
|
||||
if (/^\s*(select|pragma|with)\b/i.test(sql)) {
|
||||
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
|
||||
}
|
||||
return {
|
||||
rows: [] as unknown[],
|
||||
rowsAffected: statement.run(...args).changes
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const LEGACY_NOOK = "/api/the-nook/download";
|
||||
|
||||
const trpcClick = (asset: string) =>
|
||||
`/api/trpc/downloads.getDownloadUrl?batch=1&input=%7B%220%22%3A%7B%22asset_name%22%3A%22${asset}%22%7D%7D`;
|
||||
|
||||
/** Columns whose value varies per row; everything else stays NULL. */
|
||||
const fixedColumns: Record<string, string> = {
|
||||
method: "GET",
|
||||
referrer: "https://gaze.freno.me/",
|
||||
user_agent: "Mozilla/5.0 (Macintosh)",
|
||||
ip_address: "203.0.113.7",
|
||||
country: "GB"
|
||||
};
|
||||
|
||||
function insertLegacy(id: string, path: string) {
|
||||
const values = VISITOR_ANALYTICS_COLUMNS.map((column) => {
|
||||
if (column === "id") return id;
|
||||
if (column === "path") return path;
|
||||
return fixedColumns[column] ?? null;
|
||||
});
|
||||
db.query(VISITOR_ANALYTICS_INSERT).run(...(values as never[]));
|
||||
}
|
||||
|
||||
function canonicalPaths(): string[] {
|
||||
return db
|
||||
.query<{ path: string }, []>(
|
||||
`SELECT path FROM VisitorAnalytics WHERE path LIKE '/api/downloads/%' ORDER BY path`
|
||||
)
|
||||
.all()
|
||||
.map((r: { path: string }) => r.path);
|
||||
}
|
||||
|
||||
function legacyRowCount(): number {
|
||||
return db
|
||||
.query<
|
||||
{ n: number },
|
||||
[string]
|
||||
>(`SELECT COUNT(*) AS n FROM VisitorAnalytics WHERE path = ?`)
|
||||
.get(LEGACY_NOOK)!.n;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
db.exec(VISITOR_ANALYTICS_DDL);
|
||||
for (const index of VISITOR_ANALYTICS_INDEXES) db.exec(index);
|
||||
});
|
||||
|
||||
describe("backfillDownloads", () => {
|
||||
it("derives a canonical row from each recoverable kind of history", async () => {
|
||||
insertLegacy("nook-1", LEGACY_NOOK);
|
||||
insertLegacy("gaze-1", trpcClick("gaze"));
|
||||
insertLegacy("halo-1", trpcClick("inputhalo"));
|
||||
insertLegacy("page-1", "/nook/");
|
||||
|
||||
const added = Object.fromEntries(
|
||||
(await backfillDownloads(conn)).map((r) => [r.asset, r.added])
|
||||
);
|
||||
|
||||
expect(added).toEqual({ thenook: 1, gaze: 1, inputhalo: 1 });
|
||||
expect(canonicalPaths()).toEqual([
|
||||
"/api/downloads/gaze",
|
||||
"/api/downloads/inputhalo",
|
||||
"/api/downloads/thenook"
|
||||
]);
|
||||
// A page visit is not download history and gains no derived row.
|
||||
expect(
|
||||
db
|
||||
.query(`SELECT * FROM VisitorAnalytics WHERE id = 'backfill-page-1'`)
|
||||
.all()
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the visitor detail of the row it derives from", async () => {
|
||||
insertLegacy("nook-1", LEGACY_NOOK);
|
||||
|
||||
await backfillDownloads(conn);
|
||||
|
||||
expect(
|
||||
db
|
||||
.query<
|
||||
{ country: string; referrer: string },
|
||||
[]
|
||||
>(`SELECT country, referrer FROM VisitorAnalytics WHERE id = 'backfill-nook-1'`)
|
||||
.get()
|
||||
).toEqual({ country: "GB", referrer: "https://gaze.freno.me/" });
|
||||
});
|
||||
|
||||
it("is idempotent and never rewrites the source rows", async () => {
|
||||
insertLegacy("nook-1", LEGACY_NOOK);
|
||||
|
||||
await backfillDownloads(conn);
|
||||
const second = await backfillDownloads(conn);
|
||||
|
||||
expect(second).toEqual([]);
|
||||
expect(canonicalPaths()).toHaveLength(1);
|
||||
expect(legacyRowCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("derives nothing when there is no legacy history", async () => {
|
||||
expect(await backfillDownloads(conn)).toEqual([]);
|
||||
expect(canonicalPaths()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assetFromLegacyPath", () => {
|
||||
it("maps the Nook door to its canonical asset", () => {
|
||||
expect(assetFromLegacyPath(LEGACY_NOOK)).toBe("thenook");
|
||||
});
|
||||
|
||||
it("reads the asset out of an encoded or a plain tRPC request URL", () => {
|
||||
expect(assetFromLegacyPath(trpcClick("gaze"))).toBe("gaze");
|
||||
expect(
|
||||
assetFromLegacyPath(
|
||||
'/api/trpc/downloads.getDownloadUrl?batch=1&input={"0":{"asset_name":"lineage"}}'
|
||||
)
|
||||
).toBe("lineage");
|
||||
});
|
||||
|
||||
it("returns null when there is nothing to derive", () => {
|
||||
expect(assetFromLegacyPath("/nook/")).toBeNull();
|
||||
expect(
|
||||
assetFromLegacyPath("/api/trpc/downloads.getDownloadUrl")
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseDownloads", () => {
|
||||
it("labels each row by the asset in its path and totals them", () => {
|
||||
expect(
|
||||
summariseDownloads([
|
||||
{ path: "/api/downloads/gaze", count: 4 },
|
||||
{ path: "/api/downloads/thenook", count: 1 }
|
||||
])
|
||||
).toEqual({
|
||||
total: 5,
|
||||
byAsset: [
|
||||
{ asset: "gaze", count: 4 },
|
||||
{ asset: "thenook", count: 1 }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("reports nothing rather than throwing on an empty table", () => {
|
||||
expect(summariseDownloads([])).toEqual({ total: 0, byAsset: [] });
|
||||
});
|
||||
});
|
||||
95
src/server/backfill-downloads.ts
Normal file
95
src/server/backfill-downloads.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { VISITOR_ANALYTICS_COLUMNS } from "~/server/analytics-schema";
|
||||
import { downloadPath } from "~/lib/download-asset";
|
||||
|
||||
/**
|
||||
* Backfill canonical `downloadPath(asset)` rows from download history that was
|
||||
* recorded before `trackDownload` existed.
|
||||
*
|
||||
* Two shapes of history are recoverable, and only these two:
|
||||
*
|
||||
* - the Nook door's own rows (`/api/the-nook/download`), and
|
||||
* - tRPC clicks, where the asset name still sits in the request URL as
|
||||
* `?input={"0":{"asset_name":"gaze"}}` (percent-encoded in practice).
|
||||
*
|
||||
* Every other door wrote nothing, so there is nothing to derive for them.
|
||||
*
|
||||
* Derived rows keep the source row's timestamp, visitor, campaign and country,
|
||||
* get `backfill-<source id>` as primary key and are written with
|
||||
* `INSERT OR IGNORE` — so a second run adds nothing, and the original rows are
|
||||
* left untouched (they are genuine API traffic, and the analytics page only
|
||||
* counts the canonical path).
|
||||
*/
|
||||
|
||||
const LEGACY_NOOK_PATH = "/api/the-nook/download";
|
||||
const TRPC_DOOR_PREFIX = "/api/trpc/downloads.getDownloadUrl";
|
||||
|
||||
/** The two libsql calls used here, narrow enough for an in-memory test client. */
|
||||
export interface DownloadBackfillConn {
|
||||
execute(query: {
|
||||
sql: string;
|
||||
// `any[]` not `unknown[]`: the libsql client types its bind args as InArg[],
|
||||
// and a narrower array type here would reject the real connection.
|
||||
args?: any[];
|
||||
}): Promise<{ rows: unknown[]; rowsAffected?: number }>;
|
||||
}
|
||||
|
||||
export interface DownloadBackfillResult {
|
||||
asset: string;
|
||||
added: number;
|
||||
}
|
||||
|
||||
/** One statement: copy every column of the source row, override id and path. */
|
||||
const INSERT_DERIVED = `INSERT OR IGNORE INTO VisitorAnalytics (${VISITOR_ANALYTICS_COLUMNS.join(", ")})
|
||||
SELECT ${VISITOR_ANALYTICS_COLUMNS.map((column) =>
|
||||
column === "id"
|
||||
? "'backfill-' || id"
|
||||
: column === "path"
|
||||
? "?"
|
||||
: `"${column}"`
|
||||
).join(", ")}
|
||||
FROM VisitorAnalytics
|
||||
WHERE id = ?`;
|
||||
|
||||
/** Pull the asset name back out of a logged tRPC request URL. */
|
||||
export function assetFromLegacyPath(path: string): string | null {
|
||||
if (path === LEGACY_NOOK_PATH) return "thenook";
|
||||
|
||||
let decoded = path;
|
||||
try {
|
||||
decoded = decodeURIComponent(path);
|
||||
} catch {
|
||||
// A malformed escape is not a reason to drop the row — match on the raw.
|
||||
}
|
||||
|
||||
return /"asset_name"\s*:\s*"([^"]+)"/.exec(decoded)?.[1] ?? null;
|
||||
}
|
||||
|
||||
export async function backfillDownloads(
|
||||
conn: DownloadBackfillConn
|
||||
): Promise<DownloadBackfillResult[]> {
|
||||
const legacy = await conn.execute({
|
||||
sql: `SELECT id, path FROM VisitorAnalytics WHERE path = ? OR path LIKE ?`,
|
||||
args: [LEGACY_NOOK_PATH, `${TRPC_DOOR_PREFIX}%`]
|
||||
});
|
||||
|
||||
const added = new Map<string, number>();
|
||||
|
||||
for (const row of legacy.rows) {
|
||||
const { id, path } = row as { id: string; path: string };
|
||||
const asset = assetFromLegacyPath(path);
|
||||
if (!asset) continue;
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: INSERT_DERIVED,
|
||||
args: [downloadPath(asset), id]
|
||||
});
|
||||
|
||||
if (result.rowsAffected) {
|
||||
added.set(asset, (added.get(asset) ?? 0) + result.rowsAffected);
|
||||
}
|
||||
}
|
||||
|
||||
return [...added.entries()]
|
||||
.map(([asset, count]) => ({ asset, added: count }))
|
||||
.sort((a, b) => b.added - a.added);
|
||||
}
|
||||
61
src/server/track-download.ts
Normal file
61
src/server/track-download.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { getRequestIP } from "vinxi/http";
|
||||
import {
|
||||
logVisit,
|
||||
enrichAnalyticsEntry,
|
||||
flushAnalytics
|
||||
} from "~/server/analytics";
|
||||
import { parseUtm } from "~/server/utm";
|
||||
import { downloadPath } from "~/lib/download-asset";
|
||||
|
||||
/**
|
||||
* Record one download event under the canonical path `/api/downloads/<asset>`.
|
||||
*
|
||||
* Every download door funnels through here so the analytics page has a single
|
||||
* label per asset to group on: the tRPC click proxy, the S3 file route that
|
||||
* serves Sparkle updates, and the Nook door.
|
||||
*
|
||||
* Best-effort by design — a failed analytics write must never block a
|
||||
* download. Flushed rather than merely buffered: a download is low-volume and
|
||||
* the 30s batch timer is not guaranteed to survive the response on serverless.
|
||||
*
|
||||
* `event.request` is expected to be a Fetch `Request` (true for Vinxi route
|
||||
* handlers and browser tRPC calls). A server-side `createCaller` hands over a
|
||||
* Node request, whose headers have no `get` — that throw is swallowed below,
|
||||
* so such a click is not recorded. No server-side caller downloads today.
|
||||
*/
|
||||
export async function trackDownload(
|
||||
event: APIEvent,
|
||||
asset: string
|
||||
): Promise<void> {
|
||||
const { request } = event;
|
||||
|
||||
try {
|
||||
const referrer =
|
||||
request.headers.get("referer") ?? request.headers.get("referrer");
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
|
||||
await logVisit(
|
||||
enrichAnalyticsEntry({
|
||||
path: downloadPath(asset),
|
||||
method: "GET",
|
||||
referrer,
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ipAddress:
|
||||
getRequestIP(event.nativeEvent) ??
|
||||
(forwarded ? forwarded.split(",")[0]!.trim() : null),
|
||||
// The click carries its own tags when a campaign links straight at the
|
||||
// door; otherwise the landing page's tags ride along in the referer.
|
||||
...parseUtm(request.url, referrer),
|
||||
// Edge header country, overridable with ?c= for manual verification.
|
||||
country:
|
||||
new URL(request.url).searchParams.get("c") ??
|
||||
request.headers.get("x-vercel-ip-country") ??
|
||||
request.headers.get("cf-ipcountry")
|
||||
})
|
||||
);
|
||||
await flushAnalytics();
|
||||
} catch (error) {
|
||||
console.error(`Failed to log download of ${asset}:`, error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user