mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 15:48:36 +00:00
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>
226 lines
12 KiB
Markdown
226 lines
12 KiB
Markdown
# 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.
|