Files
amethyst/desktopApp/plans/2026-07-10-scheduled-posts-manual-testing.md
nrobi144andClaude Opus 4.8 3f56d177d8 feat(desktop): note scheduling + NIP-37 opt-in draft sync
Adds note scheduling and NIP-37 opt-in encrypted draft sync to Amethyst
Desktop, and extracts the existing Android scheduled-post code into
`commons` so both platforms (and PowJobRestorer) share one implementation.

- Compose → clock icon → date/time picker (presets + exact-minute); the
  note is pre-signed and stored locally, then published at its time.
- Publishes while the app is open (45s in-app tick + launch catch-up) AND
  while fully closed: an OS job (launchd / schtasks / systemd, registered
  only while the queue is non-empty) relaunches the binary in a headless,
  key-free `--publish-scheduled` mode that opens a websocket and pushes the
  pre-signed bytes.
- A "Scheduled" deck destination (tabs Scheduled / Drafts / Articles):
  status, cancel, publish-now, edit (cancel + reopen prefilled).
- Drafts: save-as-draft with a default-OFF "Sync across devices
  (encrypted)" toggle publishing a NIP-37 DraftWrapEvent (kind 31234,
  NIP-44 to self); drafts sync down on a fresh device.

Extraction / de-dup: ScheduledPost → commons/commonMain; ScheduledPostStore
+ ScheduledPostPublisher → commons/jvmAndroid (Jackson/java.io.File are
gate-forbidden in commonMain). The commons store is a strict superset of
upstream's parallel Android store (account-scoped claim, CLAIM_TTL crash
recovery, PUBLISHING-only status guards, reload-before-claim); upstream's
new ScheduledPostWorkGate gating is adopted to drive it. Single-writer file
lock + reload-before-claim so the in-app timer and headless process never
double-publish. Store file 0600, dir 0700.

macOS verified on the packaged app-image (compose+schedule, in-app publish,
app-closed launchd firing, Scheduled screen, NIP-37 draft round-trip).
Windows/Linux OS-integration authored but untested; headless has no Tor
routing yet — both documented in the PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:26:12 +03:00

226 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Desktop Note Scheduling & Draft Sync — Manual Test Sheet
Covers the whole feature (composer scheduling, in-app publishing, the
Drafts & Scheduled screen, app-closed OS firing, and NIP-37 draft sync) across
**macOS / Windows / Linux**. The maintainers test **macOS**; the Windows and
Linux sections are for community testers.
Most of the feature is testable from a normal `./gradlew :desktopApp:run` — but
**app-closed firing (Section D) requires a packaged build**, because the OS job
is only registered when the app is launched from a jpackage bundle (it reads the
`jpackage.app-path` system property; in a dev run it is absent and OS
registration is a deliberate no-op).
---
## How to record results
Copy this block into the PR (or a comment) and fill it in. One row per platform.
| Platform | Build type | Tester | Date | A | B | C | D | E | F | G | Notes |
|----------|-----------|--------|------|---|---|---|---|---|---|---|-------|
| macOS 14 (arm64) | app-image | | | | | | | | | | |
| macOS 13 (x64) | dmg | | | | | | | | | | |
| Windows 11 | msi | | | | | | | | | | |
| Ubuntu 24.04 (systemd) | deb | | | | | | | | | | |
| Fedora 40 (systemd) | rpm | | | | | | | | | | |
Mark each section **✅ pass / ❌ fail / ⚠️ partial / — skipped**. Put failure
detail + logs in Notes.
---
## Build
**Fastest testable bundle (recommended for local testing):**
```bash
./gradlew :desktopApp:createDistributable
```
Output (native app-image, embeds a JRE, sets `jpackage.app-path`):
- **macOS:** `desktopApp/build/compose/binaries/main/app/Amethyst.app`
→ run `open desktopApp/build/compose/binaries/main/app/Amethyst.app`
- **Windows:** `desktopApp\build\compose\binaries\main\app\Amethyst\Amethyst.exe`
- **Linux:** `desktopApp/build/compose/binaries/main/app/Amethyst/bin/Amethyst`
**Full installers** (what end users get — use for a final pass):
`./gradlew :desktopApp:packageDmg` · `:packageMsi` · `:packageDeb` · `:packageRpm`.
**Dev run** (Sections A–C, E–F only; **D will not register a job by design**):
`./gradlew :desktopApp:run` — look for the log line
`scheduled-post OS job not registered: no packaged app binary in dev mode`.
**Preconditions for all sections:** logged in with an account that has working
**write relays** (NIP-65 outbox, or at least connected relays). For sync tests
(F) you need the account's signer available (local nsec, or bunker online).
Data locations used by the feature:
`~/.amethyst/scheduled/scheduled_posts.json` (store),
`~/.amethyst/scheduled/scheduled.lock` (single-writer lock),
`~/.amethyst/scheduled/scheduler.log` (headless run log).
---
## Section A — Scheduling from the composer
- [ ] **A1** Open the composer (New Note). A **clock/schedule icon** is present in
the action row.
- [ ] **A2** Click it → a **date/time picker** opens in a dialog (not a broken
modal). Preset chips show: *in 1 hour*, *tomorrow 9am*, *next Monday 9am*.
- [ ] **A3** Pick a preset → the composer shows the chosen time; the Publish
action becomes **Schedule**.
- [ ] **A4** Pick a custom date **then** time → confirm. Time is **rounded up to
the next 15-minute** boundary (expected — matches the ~5-min tick coarseness).
- [ ] **A5** Type note text, hit **Schedule** → dialog closes; a row appears in
**Drafts & Scheduled → Scheduled** with status **PENDING** and the chosen time.
- [ ] **A6** Verify the note did **not** post immediately (check your feed / a relay).
- [ ] **A7** (Picture posts) With an image attached, the schedule button is hidden
(pictures are not schedulable in v1) — expected, note if otherwise.
- [ ] **A8 — bunker offline block** (only if you use a NIP-46 bunker signer):
take the bunker offline, try to Schedule → it is **refused with a message**
("Signer must be online to schedule…") and **no** row is stored. (Local-nsec
users can skip A8.)
## Section B — In-app publishing (app open + catch-up)
- [ ] **B1** Schedule a note for **~2 min** out. Keep the app **open**. Within one
45-second tick after the time passes, the row flips **PENDING → SENT** and the
note appears on a relay / in your feed.
- [ ] **B2 — created_at** The published note's timestamp is the **scheduled** time
(it sorts into the timeline at the intended moment), not the compose time.
- [ ] **B3 — launch catch-up** Schedule a note ~2 min out, then **quit the app
before** the time. Reopen the app **after** the time → it publishes on launch
(overdue catch-up).
- [ ] **B4 — far-future guard** Schedule a note for e.g. next week. When it
eventually fires (or via Publish-now), if a relay rejects a too-far-future
`created_at`, the row shows **FAILED** with a clear error (not a silent drop).
## Section C — Drafts & Scheduled management screen
- [ ] **C1** Sidebar → **Drafts & Scheduled** opens a tabbed screen
(**Drafts / Articles / Scheduled**).
- [ ] **C2** Scheduled tab lists your account's rows: content preview, absolute +
relative time ("… (in 3h)"), and a **status chip** (PENDING/PUBLISHING/SENT/
FAILED/CANCELLED).
- [ ] **C3 — Cancel** On a PENDING row, overflow menu → **Cancel** → status becomes
CANCELLED and it stops firing.
- [ ] **C4 — Publish now** On a PENDING/FAILED row → **Publish now** → it publishes
within a tick (or immediately) and flips to SENT.
- [ ] **C5 — live updates** Actions update the list **without** re-navigating
(reactive off the store).
- [ ] **C6 — Edit** *(known limitation)* Edit currently **cancels** the row; there
is no composer-prefill reopen yet. Confirm it cancels; log if it does more.
- [ ] **C7** Empty state ("No scheduled posts") shows when the list is empty.
## Section D — App-closed firing (OS scheduler) — **packaged build required**
> If you ran a dev build, D is expected to be skipped (no OS job registered).
**Common flow:**
1. **D1 — register on schedule:** with a packaged app, schedule a note ~10 min out.
The PENDING row triggers `osScheduler.ensureRegistered()`.
2. **D2 — job exists:** verify per-OS (below).
3. **D3 — close the app entirely** (Quit, not just close the window).
4. **D4 — wait** past the publish time **and** the next ~5-min OS tick.
5. **D5 — confirm publish:** `~/.amethyst/scheduled/scheduler.log` shows a drain
with `published=1`; the note is on a relay; the row is SENT.
6. **D6 — unregister on drain:** reopen the app (or let the headless run finish)
with no PENDING/PUBLISHING rows left → `osScheduler.unregister()`; verify the
OS job is **gone**.
### macOS (launchd)
- [ ] Registered: `launchctl print gui/$UID/com.vitorpamplona.amethyst.scheduler` exits 0.
- [ ] Plist at `~/Library/LaunchAgents/com.vitorpamplona.amethyst.scheduler.plist`,
perms `-rw-------`; `ProgramArguments[0]` = absolute app path,
`StartInterval` 300, `RunAtLoad` false.
- [ ] Force a run: `launchctl kickstart -k gui/$UID/com.vitorpamplona.amethyst.scheduler` → publishes.
- [ ] Unregistered: `launchctl print …` non-zero **and** plist deleted.
### Windows (schtasks + hidden VBS)
- [ ] Registered: `schtasks /query /tn "Amethyst\Scheduler"` exits 0.
- [ ] VBS at `%LOCALAPPDATA%\Amethyst\amethyst-scheduler-launch.vbs`.
- [ ] **No console flash:** when the task fires, **no black window** pops up.
- [ ] Force a run: `schtasks /run /tn "Amethyst\Scheduler"` → publishes.
- [ ] Unregistered: query says "task does not exist"; VBS deleted.
### Linux (systemd --user; cron fallback)
- [ ] systemd present: `systemctl --user is-enabled amethyst-scheduler.timer` → `enabled`;
`systemctl --user list-timers amethyst-scheduler.timer` lists it.
- [ ] Units at `~/.config/systemd/user/amethyst-scheduler.{service,timer}`, perms `-rw-------`.
- [ ] **Lingering (required for app-closed firing while logged out):**
`loginctl show-user $USER -p Linger` → `Linger=yes`. If registration warned it
couldn't enable it, run `sudo loginctl enable-linger $USER` and retest.
- [ ] Force a run: `systemctl --user start amethyst-scheduler.service` → publishes.
- [ ] Unregistered: `is-enabled` non-zero; unit files deleted.
- [ ] **cron fallback** (no systemd): `crontab -l` shows a
`# BEGIN amethyst-scheduler … # END amethyst-scheduler` block with a
`*/5 * * * *` line invoking the **absolute** app path + `--publish-scheduled`;
after drain the block is removed and other cron entries are untouched.
## Section E — Single-writer lock (no double-publish)
- [ ] **E1** With the app **open** (45s timer running), manually trigger the OS job
(the per-OS "force a run" command). The headless process should log
`Drain skipped: another process holds the lock …` and exit 0 — the note is
published **exactly once**, never twice. (Exercises
`~/.amethyst/scheduled/scheduled.lock`.)
- [ ] **E2 — crash recovery** *(optional/advanced)* If a headless run is killed
mid-publish (leaving a row PUBLISHING), the row auto-reverts to PENDING after
the claim TTL (~10 min) and publishes on a later tick — it is never stranded.
## Section F — NIP-37 draft sync (opt-in)
- [ ] **F1** Composer → **Save as draft** (without the sync toggle) → the draft
appears in **Drafts & Scheduled → Drafts** (local, **no** cloud badge).
- [ ] **F2** The **"Sync across devices (encrypted)"** toggle is **OFF by default**.
- [ ] **F3** Enable the toggle → Save as draft → the draft shows a **cloud/synced
badge**. A kind **31234** event was published (verify on a relay if you can).
- [ ] **F4 — cross-device** On a **second** device/profile logged into the **same**
account, the synced draft appears in the Drafts tab (subscribed + decrypted).
- [ ] **F5 — dedup** The local copy and the synced copy of the same draft show as
**one** row (deduped by dTag), not two.
- [ ] **F6 — sync failure is soft** With relays unreachable, enabling sync + Save
still **saves locally** and shows "Draft saved locally, but sync failed: …".
- [ ] **F7 — privacy** Draft **content** is never sent in plaintext (NIP-44
encrypted to your own pubkey). Note: kind 31234 *does* publicly reveal that a
draft of some kind exists at a time for your pubkey — expected, documented.
## Section G — Security spot-checks (packaged, Section D context)
- [ ] **G1** `plist` / `.vbs` / systemd unit / `scheduled_posts.json` are **`0600`**
(owner-only).
- [ ] **G2** The registered OS command uses an **absolute** app path (no bare
`java`/`gradle`/`PATH` lookup).
- [ ] **G3** A headless drain **never** prompts for a signer/keychain unlock (it only
pushes pre-signed bytes). Watch for any macOS keychain dialog during D5 — there
should be none.
---
## Multi-account note
The store keys rows by account pubkey and claims are account-scoped. If you test
with multiple accounts, confirm each account only publishes **its own** scheduled
rows, and switching accounts restarts the in-app timer for the new account.
## Known limitations (v1 — not test failures)
- Opening a saved/synced **draft back into the composer** is not wired yet (no
prefill entry point) — Drafts list shows them but clicking doesn't reopen.
- **Edit scheduled post** = Cancel (same prefill gap).
- OS integration is **compile-verified**; this sheet is its first real-world run.
- Windows/Linux are **untested by the authors** — results from Section D there are
especially valuable.
## Cleanup after testing
Remove any leftover OS jobs and test data:
- macOS: `launchctl bootout gui/$UID/com.vitorpamplona.amethyst.scheduler; rm -f ~/Library/LaunchAgents/com.vitorpamplona.amethyst.scheduler.plist`
- Windows: `schtasks /delete /tn "Amethyst\Scheduler" /f` and delete the VBS.
- Linux: `systemctl --user disable --now amethyst-scheduler.timer; rm -f ~/.config/systemd/user/amethyst-scheduler.*; systemctl --user daemon-reload` (or remove the crontab marker block).
- `rm -rf ~/.amethyst/scheduled/` to clear the store/lock/log.