From 3f56d177d8a20afe519e2c6320d78ca9e0916aef Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 15 Jul 2026 11:26:12 +0300 Subject: [PATCH] feat(desktop): note scheduling + NIP-37 opt-in draft sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../com/vitorpamplona/amethyst/AppModules.kt | 2 +- .../calendar/CalendarReminderNotifier.kt | 2 +- .../notifications/NotificationChannels.kt | 4 +- .../amethyst/service/pow/PowJobRestorer.kt | 4 +- ...ier.kt => AndroidScheduledPostNotifier.kt} | 66 +- .../scheduledposts/ScheduledPostWorkGate.kt | 2 + .../scheduledposts/ScheduledPostWorker.kt | 59 +- .../drawer/AccountSwitchBottomSheet.kt | 2 +- .../ui/navigation/drawer/DrawerContent.kt | 2 +- .../loggedIn/home/ShortNotePostViewModel.kt | 2 +- .../scheduledposts/ScheduledPostMedia.kt | 2 +- .../scheduledposts/ScheduledPostsScreen.kt | 4 +- .../scheduledposts/ScheduledPostsViewModel.kt | 6 +- .../ScheduledPostWorkGateTest.kt | 20 +- .../scheduledposts/ScheduledPostMediaTest.kt | 4 +- .../commons}/scheduledposts/ScheduledPost.kt | 2 +- .../scheduledposts/ScheduledPostNotifier.kt | 39 ++ .../scheduledposts/ScheduledPostPublisher.kt | 178 ++++++ .../scheduledposts/ScheduledPostStore.kt | 276 +++++++-- .../LoggingScheduledPostNotifier.kt | 45 ++ .../scheduledposts/ScheduledPostStoreTest.kt | 213 ++++++- ...26-07-10-scheduled-posts-manual-testing.md | 225 +++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 121 +++- .../service/drafts/DesktopNoteDraftStore.kt | 212 +++++++ .../DesktopScheduledPostScheduler.kt | 125 ++++ .../DesktopScheduledPostStore.kt | 87 +++ .../HeadlessScheduledPublish.kt | 179 ++++++ .../service/scheduledposts/OsScheduler.kt | 538 +++++++++++++++++ .../scheduledposts/ScheduledPostLock.kt | 106 ++++ .../amethyst/desktop/ui/ComposeNoteDialog.kt | 351 ++++++++++- .../desktop/ui/deck/DeckColumnContainer.kt | 11 +- .../desktop/ui/deck/DeckColumnType.kt | 2 +- .../amethyst/desktop/ui/deck/DeckLayout.kt | 2 + .../desktop/ui/deck/SinglePaneLayout.kt | 2 + .../DraftsAndScheduledScreen.kt | 103 ++++ .../ui/scheduledposts/NoteDraftsList.kt | 331 ++++++++++ .../ui/scheduledposts/ScheduledPostsScreen.kt | 386 ++++++++++++ .../ui/scheduling/DesktopScheduleAtButton.kt | 49 ++ .../ui/scheduling/DesktopScheduleAtPicker.kt | 281 +++++++++ .../ui/scheduling/DesktopSchedulePresets.kt | 67 +++ .../scheduling/DesktopScheduleAtPickerTest.kt | 101 ++++ ...07-10-feat-desktop-note-scheduling-plan.md | 569 ++++++++++++++++++ 42 files changed, 4605 insertions(+), 177 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/{ScheduledPostNotifier.kt => AndroidScheduledPostNotifier.kt} (72%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons}/scheduledposts/ScheduledPost.kt (96%) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons}/scheduledposts/ScheduledPostStore.kt (50%) create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/LoggingScheduledPostNotifier.kt rename {amethyst/src/test/java/com/vitorpamplona/amethyst/service => commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons}/scheduledposts/ScheduledPostStoreTest.kt (68%) create mode 100644 desktopApp/plans/2026-07-10-scheduled-posts-manual-testing.md create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopNoteDraftStore.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostScheduler.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/OsScheduler.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/ScheduledPostLock.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/DraftsAndScheduledScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/NoteDraftsList.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/ScheduledPostsScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtButton.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtPicker.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopSchedulePresets.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtPickerTest.kt create mode 100644 docs/plans/2026-07-10-feat-desktop-note-scheduling-plan.md diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0450df1ce6..393e9b64d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -29,6 +29,7 @@ import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue @@ -103,7 +104,6 @@ import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.service.safeCacheDir -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt index 5cc2d5a8fd..4d6f9a71fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.amethyst.ui.stringRes /** * Posts user-visible "starting soon" notifications for NIP-52 appointments the user has RSVP'd - * to as ACCEPTED. Mirrors the shape of [com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier] + * to as ACCEPTED. Mirrors the shape of [com.vitorpamplona.amethyst.service.scheduledposts.AndroidScheduledPostNotifier] * so the two notification surfaces stay consistent. */ object CalendarReminderNotifier { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt index c538def017..945481bc13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationChannels.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.service.call.notification.CallNotifier -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostNotifier +import com.vitorpamplona.amethyst.service.scheduledposts.AndroidScheduledPostNotifier import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.Log @@ -104,7 +104,7 @@ object NotificationChannels { nameRes = R.string.app_notification_scheduled_posts_channel_name, icon = MaterialSymbols.Schedule, channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) }, - ensure = { ScheduledPostNotifier.ensureChannel(it) }, + ensure = { AndroidScheduledPostNotifier.ensureChannel(it) }, ), Entry( nameRes = R.string.app_notification_calls_channel_name, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt index 9341cb5109..1d714ce29e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.service.pow +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/AndroidScheduledPostNotifier.kt similarity index 72% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/AndroidScheduledPostNotifier.kt index d545ae7760..633a0d64ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/AndroidScheduledPostNotifier.kt @@ -28,28 +28,22 @@ import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostNotifier import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview import com.vitorpamplona.amethyst.ui.stringRes /** - * Posts user-visible system notifications when a scheduled post completes - * (sent or failed). Without this, a worker firing in background offers zero - * diagnostic to the user — see the silent-publish bug the ack-aware worker - * now guards against; the notification closes the loop. + * Android [ScheduledPostNotifier]: posts a user-visible system notification when a + * scheduled post completes (sent or failed). Lives in the app module because it + * depends on app resources (strings, drawables) and [MainActivity], which are not + * available to `commons`. */ -object ScheduledPostNotifier { - // @Volatile so the channel reference is visible across the WorkManager IO - // thread pool — two workers firing in the same window can race on - // ensureChannel. createNotificationChannel itself is idempotent. - @Volatile - private var channel: NotificationChannel? = null - private const val SCHEDULED_POST_NOT_ID_BASE = 0x70000 - - fun notifySent( - context: Context, - post: ScheduledPost, - ) { +class AndroidScheduledPostNotifier( + private val context: Context, +) : ScheduledPostNotifier { + override fun notifySent(post: ScheduledPost) { ensureChannel(context) post( context = context, @@ -59,8 +53,7 @@ object ScheduledPostNotifier { ) } - fun notifyFailed( - context: Context, + override fun notifyFailed( post: ScheduledPost, error: String?, ) { @@ -122,20 +115,29 @@ object ScheduledPostNotifier { } } - fun ensureChannel(context: Context) { - if (channel != null) return - channel = - NotificationChannel( - stringRes(context, R.string.app_notification_scheduled_posts_channel_id), - stringRes(context, R.string.app_notification_scheduled_posts_channel_name), - NotificationManager.IMPORTANCE_DEFAULT, - ).apply { - description = stringRes(context, R.string.app_notification_scheduled_posts_channel_description) - } - val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.createNotificationChannel(channel!!) - } - // Distinct id per post so multiple completions don't collapse onto one row. private fun idFor(postId: String): Int = SCHEDULED_POST_NOT_ID_BASE xor postId.hashCode() + + companion object { + // @Volatile so the channel reference is visible across the WorkManager IO + // thread pool — two workers firing in the same window can race on + // ensureChannel. createNotificationChannel itself is idempotent. + @Volatile + private var channel: NotificationChannel? = null + private const val SCHEDULED_POST_NOT_ID_BASE = 0x70000 + + fun ensureChannel(context: Context) { + if (channel != null) return + channel = + NotificationChannel( + stringRes(context, R.string.app_notification_scheduled_posts_channel_id), + stringRes(context, R.string.app_notification_scheduled_posts_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = stringRes(context, R.string.app_notification_scheduled_posts_channel_description) + } + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel(channel!!) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt index dd0542050a..a0e4e1fc92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.scheduledposts +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt index 40d3e4fae8..aa2de5fc11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -31,13 +31,14 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostPublisher +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.delay import java.util.concurrent.TimeUnit /** @@ -68,8 +69,6 @@ class ScheduledPostWorker( private const val TAG = "ScheduledPostWorker" private const val WORK_NAME = "scheduled_post_worker" private const val WORK_NAME_CATCH_UP = "scheduled_post_worker_catch_up" - private const val OK_TIMEOUT_SEC = 30L - private const val OK_POLL_MS = 500L fun schedule(context: Context) { val constraints = @@ -138,6 +137,7 @@ class ScheduledPostWorker( return try { val appModules = Amethyst.instance val store = appModules.scheduledPostStore + val notifier = AndroidScheduledPostNotifier(applicationContext) val all = store.list() val pending = all.count { it.status == ScheduledPostStatus.PENDING } @@ -169,26 +169,35 @@ class ScheduledPostWorker( val extras = post.extraEventsJson.map { Event.fromJson(it) } Log.d(TAG) { "client.publish(${post.id}) starting on ${relays.size} relay(s)" } - account.client.publish(event, relays) - account.consumePostEvent(event, relays, extras) - val acks = waitForOk(account.client, event.id, relays.size) + val results = account.client.publishAndConfirmDetailed(event, relays) + val acks = results.count { it.value } if (acks > 0) { + // Only feed the event into the local cache once at least one relay + // acked — a failed publish must not appear in the user's timeline. + account.consumePostEvent(event, relays, extras) store.markSent(post.id) - ScheduledPostNotifier.notifySent(applicationContext, post) + notifier.notifySent(post) Log.d(TAG) { "client.publish(${post.id}) acked by $acks/${relays.size}; marked SENT" } } else { - val msg = "no relay acknowledged within ${OK_TIMEOUT_SEC}s" - store.markFailed(post.id, msg) - ScheduledPostNotifier.notifyFailed(applicationContext, post, msg) - Log.w(TAG) { "client.publish(${post.id}) failed: $msg" } + // Transient no-ack: release the claim for a later retry rather than + // stranding the post as FAILED, until MAX_PUBLISH_ATTEMPTS is hit. + val msg = "no relay acknowledged" + if (post.attemptCount < ScheduledPostPublisher.MAX_PUBLISH_ATTEMPTS) { + store.releaseClaim(post.id) + Log.w(TAG) { "client.publish(${post.id}) got no ack (attempt ${post.attemptCount}); released for retry" } + } else { + store.markFailed(post.id, msg) + notifier.notifyFailed(post, msg) + Log.w(TAG) { "client.publish(${post.id}) failed after max attempts: $msg" } + } } } catch (e: CancellationException) { throw e } catch (e: Exception) { Log.e(TAG, "Failed to publish scheduled post ${post.id}", e) store.markFailed(post.id, e.message) - ScheduledPostNotifier.notifyFailed(applicationContext, post, e.message) + notifier.notifyFailed(post, e.message) } } @@ -201,28 +210,4 @@ class ScheduledPostWorker( Result.retry() } } - - /** - * Polls [INostrClient.pendingPublishRelaysFor] until at least one relay sends - * an OK ack or [OK_TIMEOUT_SEC] elapses. Returns the number of relays that - * acked. The publish call itself is fire-and-forget; without this wait the - * worker can be torn down before the websocket finishes delivery. - */ - private suspend fun waitForOk( - client: INostrClient, - eventId: String, - totalRelays: Int, - ): Int { - val deadline = System.currentTimeMillis() + OK_TIMEOUT_SEC * 1000 - while (System.currentTimeMillis() < deadline) { - // null means the outbox dropped the entry — every relay either OK'd - // or hit the discard cap (replaced/pow/deleted/invalid). Treat as full ack. - val pending = client.pendingPublishRelaysFor(eventId) ?: return totalRelays - val acked = totalRelays - pending.size - if (acked > 0) return acked - delay(OK_POLL_MS) - } - val pending = client.pendingPublishRelaysFor(eventId) ?: return totalRelays - return totalRelays - pending.size - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index a91a29a47e..234c513041 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -58,10 +58,10 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.note.toShortDisplay diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 6d17929ebe..f0221e563d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -94,6 +94,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User @@ -101,7 +102,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index f18b41f796..ea22e857a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord @@ -57,7 +58,6 @@ import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus import com.vitorpamplona.amethyst.service.ai.WritingResult import com.vitorpamplona.amethyst.service.ai.WritingTone import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt index be44d23640..a6e9538c97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMedia.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.utils.Log diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt index 1a9fe31435..d8bc4921b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsScreen.kt @@ -94,8 +94,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation import com.vitorpamplona.amethyst.ui.components.util.setText diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt index cf24d233a0..ef10398aea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostsViewModel.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt index 85f85d0515..b20a8c57b1 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.scheduledposts +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope @@ -107,16 +109,22 @@ class ScheduledPostWorkGateTest { runCurrent() assertEquals(listOf(false), decisions) - store.add(samplePost(id = "a")) + store.add(samplePost(id = "a", publishAtSec = 1_000)) runCurrent() assertEquals(listOf(false, true), decisions) // A second pending post must not re-fire (distinctUntilChanged). - store.add(samplePost(id = "b")) + store.add(samplePost(id = "b", publishAtSec = 1_000)) runCurrent() assertEquals(listOf(false, true), decisions) - // Draining both pending posts cancels the periodic chain. + // Draining both pending posts cancels the periodic chain. A row must be + // claimed (PENDING -> PUBLISHING) before it can be marked terminal — the + // store only allows markSent/markFailed from PUBLISHING, mirroring the + // real worker flow. + store.claimDuePosts(nowSec = 2_000) + runCurrent() + assertEquals(listOf(false, true), decisions) store.markSent("a") runCurrent() assertEquals(listOf(false, true), decisions) @@ -156,9 +164,13 @@ class ScheduledPostWorkGateTest { fun publishNowOnFailedPost_reschedulesTheWorker() = runTest { val store = newStore() - store.add(samplePost(id = "a")) + store.add(samplePost(id = "a", publishAtSec = 1_000)) startGate(store) runCurrent() + // The worker claims (PENDING -> PUBLISHING) then fails the publish. A row + // may only be marked FAILED from PUBLISHING, so claim it first. + store.claimDuePosts(nowSec = 2_000) + runCurrent() store.markFailed("a", "relay down") runCurrent() assertEquals(listOf(true, false), decisions) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt index c20a9a03be..7d6992af0d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/scheduledposts/ScheduledPostMediaTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPost -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt index 320782d470..5f37378765 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.scheduledposts +package com.vitorpamplona.amethyst.commons.scheduledposts enum class ScheduledPostStatus { PENDING, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt new file mode 100644 index 0000000000..b4ce97a9f7 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.scheduledposts + +/** + * Emits a user-visible signal when a scheduled post completes (sent or failed). + * Without it, a background publish offers zero diagnostic to the user. + * + * Kept as an interface (not expect/actual) because the Android implementation + * depends on app-module resources (strings, MainActivity, drawables) that live + * outside `commons`, so it is constructed in the `amethyst` module and injected. + * The `commons` module ships a log-only JVM/desktop implementation for now. + */ +interface ScheduledPostNotifier { + fun notifySent(post: ScheduledPost) + + fun notifyFailed( + post: ScheduledPost, + error: String?, + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt new file mode 100644 index 0000000000..1acc4af671 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.scheduledposts + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.Log + +/** + * A single, self-contained detail row describing what happened to one claimed post. + */ +data class DrainDetail( + val id: String, + val eventId: String?, + val status: ScheduledPostStatus, + val publishedTo: List, + val error: String?, +) + +/** + * Aggregate result of a [ScheduledPostPublisher.drainDue] pass. + * + * @param published rows that got at least one relay ack and were marked SENT. + * @param failed rows permanently marked FAILED this pass. + * @param skipped rows that hit a transient no-ack and were released back to PENDING + * for a later retry (not counted as failed). + */ +data class DrainReport( + val published: Int, + val failed: Int, + val skipped: Int, + val details: List, +) + +/** + * Pure, platform-agnostic drain/publish loop shared by the Android worker and the + * (future) Desktop scheduler. Claims due posts from [store], re-verifies each + * signed event, publishes it via the supplied [client], and records the outcome + * back into the store. All platform-specific concerns (notifications, cache + * consumption) are injected as callbacks so this class stays free of any UI or + * framework dependency. + */ +class ScheduledPostPublisher( + private val store: ScheduledPostStore, + private val client: INostrClient, + private val resolveRelays: (ScheduledPost) -> Set, + private val onSent: suspend (ScheduledPost) -> Unit = {}, + private val onFailed: suspend (ScheduledPost, String?) -> Unit = { _, _ -> }, + private val consume: suspend (event: Event, relays: Set, extras: List) -> Unit = { _, _, _ -> }, +) { + /** + * Claim and publish every post due at [nowSec]. When [accountPubkey] is non-null + * only that account's posts are drained; when null, all accounts are drained + * (used by the Android worker). Returns a [DrainReport] summarizing outcomes. + */ + suspend fun drainDue( + nowSec: Long, + accountPubkey: String? = null, + ): DrainReport { + val claimed = + if (accountPubkey != null) { + store.claimDuePosts(nowSec, accountPubkey) + } else { + store.claimDuePosts(nowSec) + } + + if (claimed.isEmpty()) { + return DrainReport(published = 0, failed = 0, skipped = 0, details = emptyList()) + } + + var published = 0 + var failed = 0 + var skipped = 0 + val details = mutableListOf() + + for (post in claimed) { + try { + val event = Event.fromJson(post.signedEventJson) + + if (event.pubKey != post.accountPubkey) { + val msg = "pubkey mismatch" + store.markFailed(post.id, msg) + onFailed(post, msg) + failed++ + details.add(DrainDetail(post.id, event.id, ScheduledPostStatus.FAILED, emptyList(), msg)) + continue + } + + if (!event.verify()) { + val msg = "invalid signature" + store.markFailed(post.id, msg) + onFailed(post, msg) + failed++ + details.add(DrainDetail(post.id, event.id, ScheduledPostStatus.FAILED, emptyList(), msg)) + continue + } + + val relays = + resolveRelays(post).ifEmpty { + post.relayUrls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + } + val extras = post.extraEventsJson.map { Event.fromJson(it) } + + val results = client.publishAndConfirmDetailed(event, relays) + val acceptedBy = results.filter { it.value }.keys.map { it.url } + + if (acceptedBy.isNotEmpty()) { + // Only feed the event into the local cache (user's own timeline) once + // at least one relay acked — a failed publish must not appear locally. + consume(event, relays, extras) + store.markSent(post.id) + onSent(post) + published++ + details.add(DrainDetail(post.id, event.id, ScheduledPostStatus.SENT, acceptedBy, null)) + Log.d(TAG) { "Published ${post.id} to ${acceptedBy.size}/${relays.size} relay(s)" } + } else { + // Transient failure (sockets not reconnected, brief offline). Don't + // strand the post as FAILED forever: release the claim so the next + // tick re-claims it, until we've burned through MAX_PUBLISH_ATTEMPTS. + val msg = "no relay acknowledged" + if (post.attemptCount < MAX_PUBLISH_ATTEMPTS) { + store.releaseClaim(post.id) + skipped++ + details.add(DrainDetail(post.id, event.id, ScheduledPostStatus.PENDING, emptyList(), msg)) + Log.w(TAG) { "Publish ${post.id} got no ack (attempt ${post.attemptCount}); released for retry" } + } else { + store.markFailed(post.id, msg) + onFailed(post, msg) + failed++ + details.add(DrainDetail(post.id, event.id, ScheduledPostStatus.FAILED, emptyList(), msg)) + Log.w(TAG) { "Publish ${post.id} failed after $MAX_PUBLISH_ATTEMPTS attempts: $msg" } + } + } + } catch (e: Exception) { + store.markFailed(post.id, e.message) + onFailed(post, e.message) + failed++ + details.add(DrainDetail(post.id, null, ScheduledPostStatus.FAILED, emptyList(), e.message)) + Log.e(TAG, "Failed to publish scheduled post ${post.id}", e) + } + } + + return DrainReport(published = published, failed = failed, skipped = skipped, details = details) + } + + companion object { + private const val TAG = "ScheduledPostPublisher" + + /** + * Max number of publish attempts for a post before a transient "no relay + * acknowledged" outcome is treated as a permanent FAILED. attemptCount is + * bumped on every claim, so it naturally caps the release-for-retry loop. + */ + const val MAX_PUBLISH_ATTEMPTS = 5 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt index 7d86510ed3..539f759fce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.scheduledposts +package com.vitorpamplona.amethyst.commons.scheduledposts import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -30,6 +30,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission class ScheduledPostStore( private val storageFile: File, @@ -55,11 +57,25 @@ class ScheduledPostStore( persist() } + /** + * Cancel a scheduled post. Only PENDING or FAILED rows may be cancelled — a + * PUBLISHING row is mid-send and must not be pulled out from under the drainer, + * so cancelling one is a no-op that returns false. Reloads first so the decision + * is made against the other process's committed writes. + */ suspend fun cancel(id: String): Boolean = mutex.withLock { ensureLoaded() + reloadFromDiskLocked() val now = nowSec() - val updated = mutate(id) { it.copy(status = ScheduledPostStatus.CANCELLED, terminatedAtSec = now) } + val updated = + mutate(id) { + if (it.status == ScheduledPostStatus.PENDING || it.status == ScheduledPostStatus.FAILED) { + it.copy(status = ScheduledPostStatus.CANCELLED, terminatedAtSec = now) + } else { + it + } + } if (updated) persist() updated } @@ -80,51 +96,114 @@ class ScheduledPostStore( * Atomically claim posts due at or before [nowSec]: each PENDING post with * publishAtSec <= now is flipped to PUBLISHING and returned. A concurrent * claim from another worker will see those posts as PUBLISHING and skip them. + * + * This all-accounts overload is used by the Android worker, which publishes on + * behalf of every loaded account in a single pass. */ suspend fun claimDuePosts(nowSec: Long): List = mutex.withLock { ensureLoaded() - val dueIds = - posts - .filter { it.status == ScheduledPostStatus.PENDING && it.publishAtSec <= nowSec } - .map { it.id } - .toSet() - if (dueIds.isEmpty()) return@withLock emptyList() - dueIds.forEach { id -> - mutate(id) { - it.copy( - status = ScheduledPostStatus.PUBLISHING, - lastAttemptAtSec = nowSec, - attemptCount = it.attemptCount + 1, - ) - } - } - persist() - posts.filter { it.id in dueIds } + reloadFromDiskLocked() + recoverStuckClaims(nowSec) + claimLocked(nowSec) { true } } - suspend fun markSent(id: String) = + /** + * Same as [claimDuePosts] but scoped to a single account: only rows whose + * accountPubkey equals [accountPubkey] are claimed. Used by callers that drive + * publishing per-account (e.g. Desktop) so one account's drain never touches + * another account's due posts. + */ + suspend fun claimDuePosts( + nowSec: Long, + accountPubkey: String, + ): List = mutex.withLock { ensureLoaded() - val now = nowSec() - if (mutate(id) { it.copy(status = ScheduledPostStatus.SENT, lastError = null, terminatedAtSec = now) }) persist() + reloadFromDiskLocked() + recoverStuckClaims(nowSec) + claimLocked(nowSec) { it.accountPubkey == accountPubkey } } - suspend fun markFailed( - id: String, - error: String?, - ) = mutex.withLock { + private fun claimLocked( + nowSec: Long, + predicate: (ScheduledPost) -> Boolean, + ): List { ensureLoaded() - if (mutate(id) { it.copy(status = ScheduledPostStatus.FAILED, lastError = error) }) persist() + val dueIds = + posts + .filter { it.status == ScheduledPostStatus.PENDING && it.publishAtSec <= nowSec && predicate(it) } + .map { it.id } + .toSet() + if (dueIds.isEmpty()) return emptyList() + dueIds.forEach { id -> + mutate(id) { + it.copy( + status = ScheduledPostStatus.PUBLISHING, + lastAttemptAtSec = nowSec, + attemptCount = it.attemptCount + 1, + ) + } + } + persist() + return posts.filter { it.id in dueIds } } + /** + * Mark a claimed row SENT. Only a PUBLISHING row may transition to SENT — this + * guards against a stale caller stamping SENT over a row another process already + * re-claimed. Reloads first so the guard sees the other process's committed state. + */ + suspend fun markSent(id: String): Boolean = + mutex.withLock { + ensureLoaded() + reloadFromDiskLocked() + val now = nowSec() + val updated = + mutate(id) { + if (it.status == ScheduledPostStatus.PUBLISHING) { + it.copy(status = ScheduledPostStatus.SENT, lastError = null, terminatedAtSec = now) + } else { + it + } + } + if (updated) persist() + updated + } + + /** + * Mark a claimed row FAILED. Only a PUBLISHING row may transition to FAILED, for the + * same reason as [markSent]. Reloads first to adopt the other process's writes. + */ + suspend fun markFailed( + id: String, + error: String?, + ): Boolean = + mutex.withLock { + ensureLoaded() + reloadFromDiskLocked() + val updated = + mutate(id) { + if (it.status == ScheduledPostStatus.PUBLISHING) { + it.copy(status = ScheduledPostStatus.FAILED, lastError = error) + } else { + it + } + } + if (updated) persist() + updated + } + /** * Force a post to publish immediately by setting its publishAtSec to [nowSec] - * and resetting status to PENDING. Handles two cases with one method: + * and resetting status to PENDING. Handles cases with one method: * - PENDING (future-scheduled): user wants to push it out now * - FAILED: user wants to retry - * Caller is expected to enqueue ScheduledPostWorker.scheduleCatchUp() afterwards - * so the worker picks it up promptly. + * - CANCELLED: user wants to re-activate a cancelled row + * A PUBLISHING row is mid-send and is left untouched (returns false) so a UI + * action can't stomp a live claim. Reloads first so the guard reflects the other + * process's committed writes. Caller is expected to enqueue + * ScheduledPostWorker.scheduleCatchUp() afterwards so the worker picks it up promptly. */ suspend fun publishNow( id: String, @@ -132,14 +211,19 @@ class ScheduledPostStore( ): Boolean = mutex.withLock { ensureLoaded() + reloadFromDiskLocked() val updated = mutate(id) { - it.copy( - publishAtSec = nowSec, - status = ScheduledPostStatus.PENDING, - lastError = null, - terminatedAtSec = null, - ) + if (it.status != ScheduledPostStatus.PUBLISHING) { + it.copy( + publishAtSec = nowSec, + status = ScheduledPostStatus.PENDING, + lastError = null, + terminatedAtSec = null, + ) + } else { + it + } } if (updated) persist() updated @@ -168,6 +252,7 @@ class ScheduledPostStore( suspend fun releaseClaim(id: String) = mutex.withLock { ensureLoaded() + reloadFromDiskLocked() val changed = mutate(id) { if (it.status == ScheduledPostStatus.PUBLISHING) { @@ -192,6 +277,72 @@ class ScheduledPostStore( return true } + /** + * Revert PUBLISHING rows that have been stuck longer than [CLAIM_TTL_SEC] back + * to PENDING. A post is flipped to PUBLISHING the moment it is claimed; if the + * process crashes (or the worker is torn down) mid-publish, that row would stay + * PUBLISHING forever and never be re-claimed — silently stranded. Reverting an + * expired claim lets the next cycle retry it. Returns true if any row changed. + * + * Runs both at first load (via [ensureLoaded], to unstick rows written by a + * previous process) and at the start of every claim (to unstick rows stranded + * within the current process). [now] must be on the same time base as the + * rows' lastAttemptAtSec — the claim path passes the caller-supplied nowSec so + * the TTL is compared against the same clock that stamped the claim. Must be + * called under [mutex]; persist is the caller's responsibility to keep the + * single-write-per-op contract. + */ + private fun recoverStuckClaimsLocked(now: Long): Boolean { + var changed = false + posts.indices.forEach { idx -> + val post = posts[idx] + val lastAttempt = post.lastAttemptAtSec + if (post.status == ScheduledPostStatus.PUBLISHING && lastAttempt != null && now - lastAttempt > CLAIM_TTL_SEC) { + posts[idx] = post.copy(status = ScheduledPostStatus.PENDING) + changed = true + } + } + return changed + } + + private fun recoverStuckClaims(now: Long): Boolean { + ensureLoaded() + val changed = recoverStuckClaimsLocked(now) + if (changed) persist() + return changed + } + + /** + * Re-read the on-disk snapshot into [posts] and refresh [_flow]. + * + * Cross-process reload: the running app and the headless `--publish-scheduled` + * process share this file but each keeps its own in-memory copy. Without a reload + * the app never sees status writes made by the headless process, so it could + * re-claim and double-publish an already-SENT row. Because every mutation persists + * immediately (in-memory == disk after every op), adopting the disk snapshot at the + * start of a cross-process-sensitive op only pulls in the *other* process's + * committed changes — it can never lose one of our own writes. + * + * Tolerant of a missing/corrupt file: on any parse failure the current in-memory + * [posts] is kept untouched. Must be called under [mutex], after [ensureLoaded]. + */ + private fun reloadFromDiskLocked() { + val fromDisk = + try { + if (storageFile.exists() && storageFile.length() > 0) { + mapper.readValue(storageFile).posts.toMutableList() + } else { + // File vanished — treat as no external state; keep current in-memory. + return + } + } catch (e: Exception) { + Log.e(TAG, "Failed to reload scheduled posts from $storageFile", e) + return + } + posts = fromDisk + _flow.value = posts.toList() + } + private fun ensureLoaded() { if (loaded) return posts = @@ -206,28 +357,15 @@ class ScheduledPostStore( mutableListOf() } loaded = true - val recovered = releaseStaleClaims() - val purged = purgeStale(nowSec()) + // Secure a pre-existing file up front (an older build may have left it at the + // 0644 umask default) so it's owner-only even if nothing mutates the store + // this session. + if (storageFile.exists()) restrictToOwner(storageFile) + var dirty = purgeStale(nowSec()) + // Recover claims stranded by a crash-mid-publish so they aren't lost forever. + if (recoverStuckClaimsLocked(nowSec())) dirty = true _flow.value = posts.toList() - if (purged || recovered) persist() - } - - /** - * A PUBLISHING row found at initial disk load is a claim from a previous - * process — the worker that held it died (crash, cancellation) before - * markSent/markFailed/releaseClaim ran. No worker in THIS process can - * hold a claim before the first load, so reset them to PENDING for the - * next cycle to retry. Returns true if any row was recovered. - */ - private fun releaseStaleClaims(): Boolean { - var recovered = false - posts.forEachIndexed { idx, post -> - if (post.status == ScheduledPostStatus.PUBLISHING) { - posts[idx] = post.copy(status = ScheduledPostStatus.PENDING) - recovered = true - } - } - return recovered + if (dirty) persist() } /** @@ -271,6 +409,10 @@ class ScheduledPostStore( val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") try { mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot)) + // Restrict to owner-only BEFORE the rename so the store is never briefly + // world-readable. It holds pre-signed events + the account's pubkey, which + // must not leak to other local users on a shared machine. + restrictToOwner(tmp) if (!tmp.renameTo(storageFile)) { if (!storageFile.delete()) { Log.w(TAG) { "Failed to delete existing $storageFile before rename retry" } @@ -290,10 +432,30 @@ class ScheduledPostStore( } } + /** + * Best-effort chmod to `0600` (owner read/write only). No-op on filesystems + * without POSIX permissions (e.g. Windows), where confidentiality relies on the + * per-user home directory instead. Never throws. + */ + private fun restrictToOwner(file: File) { + try { + Files.setPosixFilePermissions( + file.toPath(), + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + ) + } catch (e: Exception) { + Log.w(TAG) { "Could not restrict permissions on $file: ${e.message}" } + } + } + companion object { private const val TAG = "ScheduledPostStore" const val FILE_NAME = "scheduled_posts.json" private const val SENT_RETENTION_SEC = 7L * 24 * 3600 private const val CANCELLED_RETENTION_SEC = 30L * 24 * 3600 + + // A row stuck in PUBLISHING longer than this is assumed to be a crash-mid- + // publish leftover and is reverted to PENDING so it can be retried. + private const val CLAIM_TTL_SEC = 10L * 60 } } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/LoggingScheduledPostNotifier.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/LoggingScheduledPostNotifier.kt new file mode 100644 index 0000000000..31d7f0d19b --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/LoggingScheduledPostNotifier.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.scheduledposts + +import com.vitorpamplona.quartz.utils.Log + +/** + * Desktop/JVM [ScheduledPostNotifier] that only logs. Desktop has no system + * notification channel wired for scheduled posts yet; this keeps the publish + * loop functional and observable until one is added. + */ +class LoggingScheduledPostNotifier : ScheduledPostNotifier { + override fun notifySent(post: ScheduledPost) { + Log.i(TAG) { "Scheduled post ${post.id} sent" } + } + + override fun notifyFailed( + post: ScheduledPost, + error: String?, + ) { + Log.w(TAG) { "Scheduled post ${post.id} failed: ${error ?: "unknown error"}" } + } + + companion object { + private const val TAG = "ScheduledPostNotifier" + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt similarity index 68% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt index 4389ea6b78..6033715624 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt @@ -18,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.scheduledposts +package com.vitorpamplona.amethyst.commons.scheduledposts import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -32,6 +33,8 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission class ScheduledPostStoreTest { @get:Rule @@ -89,6 +92,23 @@ class ScheduledPostStoreTest { assertEquals(ScheduledPostStatus.PENDING, reloaded[0].status) } + @Test + fun persisted_file_is_owner_only() = + runTest { + newStore().add(samplePost()) + + val perms = + try { + Files.getPosixFilePermissions(file.toPath()) + } catch (e: UnsupportedOperationException) { + // Non-POSIX filesystem (e.g. Windows CI) — nothing to assert. + return@runTest + } + assertTrue("owner should read/write", perms.contains(PosixFilePermission.OWNER_READ)) + assertFalse("group must not read the store", perms.contains(PosixFilePermission.GROUP_READ)) + assertFalse("others must not read the store", perms.contains(PosixFilePermission.OTHERS_READ)) + } + @Test fun claimDuePosts_returns_only_due_pending_posts() = runTest { @@ -148,8 +168,13 @@ class ScheduledPostStoreTest { fun markSent_updates_status_and_clears_error() = runTest { val store = newStore() - store.add(samplePost()) + store.add(samplePost(publishAtSec = 1000)) + // markFailed/markSent only transition PUBLISHING rows, so claim first. + store.claimDuePosts(nowSec = 1000) store.markFailed("id-1", "earlier error") + // Re-claim to get back to PUBLISHING before the successful send. + store.publishNow("id-1", nowSec = 1000) + store.claimDuePosts(nowSec = 1000) store.markSent("id-1") val all = store.list() @@ -161,7 +186,8 @@ class ScheduledPostStoreTest { fun markFailed_records_error() = runTest { val store = newStore() - store.add(samplePost()) + store.add(samplePost(publishAtSec = 1000)) + store.claimDuePosts(nowSec = 1000) // -> PUBLISHING store.markFailed("id-1", "boom") val all = store.list() @@ -269,7 +295,8 @@ class ScheduledPostStoreTest { fun publishNow_clears_failed_state_for_retry() = runTest { val store = newStore() - store.add(samplePost()) + store.add(samplePost(publishAtSec = 1000)) + store.claimDuePosts(nowSec = 1000) // -> PUBLISHING so markFailed applies store.markFailed("id-1", "earlier failure") store.publishNow("id-1", nowSec = 5000) @@ -389,8 +416,9 @@ class ScheduledPostStoreTest { fun removeForAccount_purges_terminal_states_too() = runTest { val store = newStore() - store.add(samplePost(id = "p1", accountPubkey = "pk-a")) + store.add(samplePost(id = "p1", publishAtSec = 1000, accountPubkey = "pk-a")) store.add(samplePost(id = "p2", accountPubkey = "pk-a")) + store.claimDuePosts(nowSec = 1000) // p1 -> PUBLISHING so markSent applies store.markSent("p1") store.cancel("p2") @@ -555,4 +583,179 @@ class ScheduledPostStoreTest { assertNotNull(reloaded.relayUrls) assertEquals(2, reloaded.relayUrls.size) } + + // --- Phase 0 store fixes --- + + @Test + fun stuck_publishing_row_reverts_to_pending_on_load() = + runTest { + val claimTime = 1_700_000_000L + // Claim a post so it becomes PUBLISHING with lastAttemptAtSec = claimTime. + newStore { claimTime }.also { + it.add(samplePost(id = "stuck", publishAtSec = claimTime)) + it.claimDuePosts(claimTime) + } + + // A fresh store instance loads that stuck row 11 minutes later (> CLAIM_TTL of 10m). + val elevenMinutesLater = claimTime + 11L * 60 + val reloaded = newStore { elevenMinutesLater } + + val row = reloaded.list().single() + assertEquals(ScheduledPostStatus.PENDING, row.status) + } + + @Test + fun stuck_publishing_row_reverts_and_is_reclaimable() = + runTest { + val claimTime = 1_700_000_000L + newStore { claimTime }.also { + it.add(samplePost(id = "stuck", publishAtSec = claimTime)) + it.claimDuePosts(claimTime) + } + + // The same store reloaded past the TTL should be able to claim the row again. + val elevenMinutesLater = claimTime + 11L * 60 + val reloaded = newStore { elevenMinutesLater } + + val claimedAgain = reloaded.claimDuePosts(elevenMinutesLater) + assertEquals(1, claimedAgain.size) + assertEquals("stuck", claimedAgain[0].id) + } + + @Test + fun fresh_publishing_row_is_not_reverted_before_ttl() = + runTest { + val claimTime = 1_700_000_000L + newStore { claimTime }.also { + it.add(samplePost(id = "fresh-claim", publishAtSec = claimTime)) + it.claimDuePosts(claimTime) + } + + // Only 5 minutes elapsed — under the 10 minute TTL, so it stays PUBLISHING. + val fiveMinutesLater = claimTime + 5L * 60 + val reloaded = newStore { fiveMinutesLater } + + assertEquals(ScheduledPostStatus.PUBLISHING, reloaded.list().single().status) + } + + @Test + fun accountScoped_claim_only_returns_matching_account() = + runTest { + val store = newStore() + store.add(samplePost(id = "a-due", publishAtSec = 1000, accountPubkey = "pk-a")) + store.add(samplePost(id = "b-due", publishAtSec = 1000, accountPubkey = "pk-b")) + + val claimed = store.claimDuePosts(nowSec = 1000, accountPubkey = "pk-a") + + assertEquals(setOf("a-due"), claimed.map { it.id }.toSet()) + // pk-b's due post should remain PENDING, untouched by the scoped claim. + val bRow = store.list().single { it.id == "b-due" } + assertEquals(ScheduledPostStatus.PENDING, bRow.status) + } + + @Test + fun accountScoped_claim_leaves_other_account_claimable() = + runTest { + val store = newStore() + store.add(samplePost(id = "a-due", publishAtSec = 1000, accountPubkey = "pk-a")) + store.add(samplePost(id = "b-due", publishAtSec = 1000, accountPubkey = "pk-b")) + + store.claimDuePosts(nowSec = 1000, accountPubkey = "pk-a") + val bClaim = store.claimDuePosts(nowSec = 1000, accountPubkey = "pk-b") + + assertEquals(setOf("b-due"), bClaim.map { it.id }.toSet()) + } + + // --- Fix 1: cross-process reload before mutation --- + + @Test + fun claim_adopts_external_sent_written_by_another_process() = + runTest { + // Two store instances over the same file model the running app + the headless + // publisher. Instance A loads the store, then instance B (the "other process") + // claims and marks the post SENT — writing that to disk. When A next claims, it + // must reload B's SENT write and NOT re-claim the already-published post. + val storeA = newStore() + val storeB = newStore() + + storeA.add(samplePost(id = "shared", publishAtSec = 1000)) + // Pull A's in-memory state in (simulates A having loaded earlier). + storeA.list() + + // B claims and publishes the post out from under A. + val bClaim = storeB.claimDuePosts(nowSec = 1000) + assertEquals(1, bClaim.size) + storeB.markSent("shared") + + // A now claims; reload picks up B's SENT so nothing due remains. + val aClaim = storeA.claimDuePosts(nowSec = 1000) + assertEquals("A must not re-claim a post another process already SENT", 0, aClaim.size) + assertEquals(ScheduledPostStatus.SENT, storeA.list().single().status) + } + + // --- Fix 5: status guards --- + + @Test + fun cancel_on_publishing_row_is_noop_and_returns_false() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + store.claimDuePosts(nowSec = 1000) // -> PUBLISHING + + val ok = store.cancel("id-1") + + assertFalse("cancel must not touch a PUBLISHING row", ok) + assertEquals(ScheduledPostStatus.PUBLISHING, store.list().single().status) + } + + @Test + fun cancel_on_failed_row_succeeds() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + store.claimDuePosts(nowSec = 1000) + store.markFailed("id-1", "boom") + + val ok = store.cancel("id-1") + + assertTrue(ok) + assertEquals(ScheduledPostStatus.CANCELLED, store.list().single().status) + } + + @Test + fun markSent_only_transitions_from_publishing() = + runTest { + val store = newStore() + store.add(samplePost()) // stays PENDING, never claimed + + val ok = store.markSent("id-1") + + assertFalse("markSent must only transition a PUBLISHING row", ok) + assertEquals(ScheduledPostStatus.PENDING, store.list().single().status) + } + + @Test + fun markFailed_only_transitions_from_publishing() = + runTest { + val store = newStore() + store.add(samplePost()) // PENDING + + val ok = store.markFailed("id-1", "boom") + + assertFalse("markFailed must only transition a PUBLISHING row", ok) + assertEquals(ScheduledPostStatus.PENDING, store.list().single().status) + } + + @Test + fun publishNow_on_publishing_row_is_noop_and_returns_false() = + runTest { + val store = newStore() + store.add(samplePost(publishAtSec = 1000)) + store.claimDuePosts(nowSec = 1000) // -> PUBLISHING + + val ok = store.publishNow("id-1", nowSec = 2000) + + assertFalse("publishNow must not touch a PUBLISHING row", ok) + assertEquals(ScheduledPostStatus.PUBLISHING, store.list().single().status) + } } diff --git a/desktopApp/plans/2026-07-10-scheduled-posts-manual-testing.md b/desktopApp/plans/2026-07-10-scheduled-posts-manual-testing.md new file mode 100644 index 0000000000..d85c7b532b --- /dev/null +++ b/desktopApp/plans/2026-07-10-scheduled-posts-manual-testing.md @@ -0,0 +1,225 @@ +# 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. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index d537406c78..90c52eeffe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSetti import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady import com.vitorpamplona.amethyst.commons.wot.LocalWoTService import com.vitorpamplona.amethyst.desktop.account.AccountManager @@ -101,6 +102,11 @@ import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameSe import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinPreferences import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinService +import com.vitorpamplona.amethyst.desktop.service.scheduledposts.DesktopScheduledPostScheduler +import com.vitorpamplona.amethyst.desktop.service.scheduledposts.DesktopScheduledPostStore +import com.vitorpamplona.amethyst.desktop.service.scheduledposts.LocalScheduledPostStore +import com.vitorpamplona.amethyst.desktop.service.scheduledposts.OsScheduler +import com.vitorpamplona.amethyst.desktop.service.scheduledposts.runHeadlessPublish import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen @@ -152,6 +158,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext private val isMacOS = com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS @@ -215,7 +222,16 @@ sealed class DesktopScreen { @Volatile private var activeTorManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager? = null -fun main() { +fun main(args: Array) { + // Headless publish mode: launched by the OS scheduler (~every 5 min) while the + // desktop app is closed. Drains due, pre-signed scheduled posts and exits + // WITHOUT touching Compose/AWT, the signer, or the keychain. Must run before any + // window/AWT init so it stays a lightweight, GUI-free process. + if (args.contains("--publish-scheduled")) { + val code = runHeadlessPublish() + kotlin.system.exitProcess(code) + } + // macOS: route the app's MenuBar to the system menu bar at the top of the // screen and set the application name shown in the apple-menu. Both must be // set before AWT initializes (i.e. before any Swing/AWT class loads). @@ -266,6 +282,11 @@ fun main() { var appRestartKey by remember { mutableStateOf(0) } var showComposeDialog by remember { mutableStateOf(false) } var replyToNote by remember { mutableStateOf(null) } + // Prefill state for opening the composer to edit an existing draft / scheduled post. + // Cleared on dismiss so the plain "New Note" path never inherits a stale prefill. + var composeEditContent by remember { mutableStateOf(null) } + var composeEditDraftTag by remember { mutableStateOf(null) } + var composeEditScheduledForSec by remember { mutableStateOf(null) } val deckScope = rememberCoroutineScope() val deckState = remember { DeckState(deckScope).also { it.load() } } val workspaceManager = remember { WorkspaceManager(deckScope).also { it.load() } } @@ -597,7 +618,7 @@ fun main() { Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) - Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) }) + Item("Scheduled & Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) }) Item("Highlights", onClick = { deckState.addColumn(DeckColumnType.MyHighlights) }) Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) @@ -643,10 +664,22 @@ fun main() { replyToNote = event showComposeDialog = true }, + onEditInComposer = { content, draftDTag, scheduledForSec -> + composeEditContent = content + composeEditDraftTag = draftDTag + composeEditScheduledForSec = scheduledForSec + showComposeDialog = true + }, onDismissComposeDialog = { showComposeDialog = false replyToNote = null + composeEditContent = null + composeEditDraftTag = null + composeEditScheduledForSec = null }, + composeEditContent = composeEditContent, + composeEditDraftTag = composeEditDraftTag, + composeEditScheduledForSec = composeEditScheduledForSec, onDismissAppDrawer = { showAppDrawer = false }, onShowAppDrawer = { showAppDrawer = true }, replyToNote = replyToNote, @@ -678,10 +711,14 @@ fun App( showAppDrawer: Boolean, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onEditInComposer: (content: String, draftDTag: String?, scheduledForSec: Long?) -> Unit = { _, _, _ -> }, onDismissComposeDialog: () -> Unit, onDismissAppDrawer: () -> Unit, onShowAppDrawer: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, + composeEditContent: String? = null, + composeEditDraftTag: String? = null, + composeEditScheduledForSec: Long? = null, showImportFollowListDialog: Boolean = false, onShowImportFollowListDialog: () -> Unit = {}, onDismissImportFollowListDialog: () -> Unit = {}, @@ -730,10 +767,14 @@ fun App( showAppDrawer = showAppDrawer, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onEditInComposer = onEditInComposer, onDismissComposeDialog = onDismissComposeDialog, onDismissAppDrawer = onDismissAppDrawer, onShowAppDrawer = onShowAppDrawer, replyToNote = replyToNote, + composeEditContent = composeEditContent, + composeEditDraftTag = composeEditDraftTag, + composeEditScheduledForSec = composeEditScheduledForSec, showImportFollowListDialog = showImportFollowListDialog, onShowImportFollowListDialog = onShowImportFollowListDialog, onDismissImportFollowListDialog = onDismissImportFollowListDialog, @@ -761,10 +802,14 @@ private fun AppInner( showAppDrawer: Boolean, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onEditInComposer: (content: String, draftDTag: String?, scheduledForSec: Long?) -> Unit, onDismissComposeDialog: () -> Unit, onDismissAppDrawer: () -> Unit, onShowAppDrawer: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, + composeEditContent: String?, + composeEditDraftTag: String?, + composeEditScheduledForSec: Long?, showImportFollowListDialog: Boolean, onShowImportFollowListDialog: () -> Unit, onDismissImportFollowListDialog: () -> Unit, @@ -848,6 +893,20 @@ private fun AppInner( val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + // Shared scheduled-post store — single writer per app, reached by the composer + // and the in-app drain timer. + val scheduledPostStore = + remember { + DesktopScheduledPostStore.create() + } + + // Shared short-note draft store — written by the composer, read by the Drafts tab. + val noteDraftStore = + remember { + com.vitorpamplona.amethyst.desktop.service.drafts + .DesktopNoteDraftStore(scope) + } + // Hashtag-spam filter settings, persisted in the shared java.util.prefs // node so the `amy` CLI binary observes the same toggle. val hashtagSpamSettings = remember { PreferencesHashtagSpamSettings() } @@ -1211,6 +1270,8 @@ private fun AppInner( com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore provides localRelayStore, + LocalScheduledPostStore provides scheduledPostStore, + com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore provides noteDraftStore, LocalHashtagSpamSettings provides hashtagSpamSettings, com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationDispatcher provides notifDispatcher, ) { @@ -1360,6 +1421,7 @@ private fun AppInner( torStatus = currentTorStatus, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onEditInComposer = onEditInComposer, onShowAppDrawer = onShowAppDrawer, onOpenFeedsDrawer = { appDrawerInitialTab = @@ -1393,6 +1455,9 @@ private fun AppInner( account = account, localCache = localCache, replyTo = replyToNote, + draftDTag = composeEditDraftTag, + draftInitialContent = composeEditContent, + initialScheduledForSec = composeEditScheduledForSec, ) } @@ -1486,6 +1551,7 @@ fun MainContent( torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onEditInComposer: (content: String, draftDTag: String?, scheduledForSec: Long?) -> Unit, onShowAppDrawer: () -> Unit, onOpenFeedsDrawer: () -> Unit = onShowAppDrawer, onShowImportFollowListDialog: () -> Unit = {}, @@ -1551,6 +1617,55 @@ fun MainContent( .DesktopDraftStore(appScope) } + // In-app scheduled-post drain: catch-up on start, then tick every ~45s while the + // app runs. Restarts on account switch so each account only publishes its own rows, + // resolving relays from its NIP-65 outbox (falling back to connected relays). + val scheduledPostStore = LocalScheduledPostStore.current + val scheduledPostScheduler = + remember(scheduledPostStore, appScope) { + DesktopScheduledPostScheduler(scheduledPostStore, appScope) + } + DisposableEffect(scheduledPostScheduler, iAccount, relayManager) { + scheduledPostScheduler.start( + client = relayManager.client, + accountPubkey = account.pubKeyHex, + resolveRelays = { + iAccount.nip65RelayList.outboxFlow.value + .ifEmpty { relayManager.connectedRelays.value } + }, + ) + onDispose { scheduledPostScheduler.stop() } + } + + // OS-level scheduler: keep a native timer job registered only while pending posts + // exist, so scheduled posts fire even when the app is fully closed. The job + // relaunches the app binary in headless `--publish-scheduled` mode. In dev + // (`./gradlew run`) resolveAppLaunchCommand() is null → the scheduler no-ops. + val osScheduler = + remember { + OsScheduler( + OsScheduler.resolveAppLaunchCommand() ?: emptyList(), + ) + } + LaunchedEffect(osScheduler, scheduledPostStore) { + // Level-triggered reconcile once on startup, then edge-triggered on the + // pending-count crossing 0. Idempotent register/unregister make this safe. + var lastHadPending: Boolean? = null + scheduledPostStore.flow.collect { posts -> + val hasPending = + posts.any { + it.status == ScheduledPostStatus.PENDING || + it.status == ScheduledPostStatus.PUBLISHING + } + if (hasPending != lastHadPending) { + lastHadPending = hasPending + withContext(Dispatchers.IO) { + if (hasPending) osScheduler.ensureRegistered() else osScheduler.unregister() + } + } + } + } + // Bootstrap subscription: fetch relay config events (kinds 10002, 10050, 10007, 10006). // Subscribes immediately — `NostrClient` / `RelayPool` queue REQs that arrive before a // relay connection is up and flush them on connect (verified by @@ -1985,6 +2100,7 @@ fun MainContent( onOpenFeedsDrawer = onOpenFeedsDrawer, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onEditInComposer = onEditInComposer, onShowImportFollowListDialog = onShowImportFollowListDialog, onZapFeedback = onZapFeedback, signerConnectionState = signerConnectionState, @@ -2010,6 +2126,7 @@ fun MainContent( appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onEditInComposer = onEditInComposer, onZapFeedback = onZapFeedback, onNavigateToRelays = { if (deckState.hasColumnOfType(DeckColumnType.Relays)) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopNoteDraftStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopNoteDraftStore.kt new file mode 100644 index 0000000000..057b84c653 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopNoteDraftStore.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.drafts + +import androidx.compose.runtime.staticCompositionLocalOf +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermission + +/** + * A single short-note draft row. + * + * @param dTag the stable NIP-37 replaceable identifier. Used both as the local key + * AND as the `d` tag of the kind-31234 sync event so a local row and its synced + * twin dedup to one entry. + * @param content the plaintext note body the composer was holding. + * @param updatedAt epoch-second of last save. + * @param synced whether this draft was also published as an encrypted NIP-37 event. + * @param accountPubkey hex pubkey of the account that authored the draft. Scopes the + * draft to its owner so account A's plaintext drafts never surface — or publish — + * under account B. Empty on legacy rows written before this field existed; those are + * scoped out (hidden from every account) since we can't attribute them safely. + */ +data class NoteDraft( + val dTag: String, + val content: String = "", + val updatedAt: Long = 0L, + val synced: Boolean = false, + val accountPubkey: String = "", +) + +/** + * Local, per-machine storage for short-note (kind-1) drafts written from the compose + * dialog. Mirrors [DesktopDraftStore]'s JSON + [Mutex] + owner-only (0600) file pattern + * but is keyed by NIP-37 `dTag` instead of an article slug. + * + * This store is intentionally simple: one `notes.json` map of `dTag -> NoteDraft`. + * NIP-37 *sync* (publishing a kind-31234 event) is orthogonal — this store only records + * whether a given draft was synced so the UI can badge it. + */ +class DesktopNoteDraftStore( + private val scope: CoroutineScope, +) { + private val mapper = jacksonObjectMapper() + private val mutex = Mutex() + private var cached: MutableMap? = null + + private val _drafts = MutableStateFlow>(emptyList()) + val drafts: StateFlow> = _drafts.asStateFlow() + + private val draftsDir: File by lazy { + val dir = File(System.getProperty("user.home"), ".amethyst/note-drafts") + if (!dir.exists()) { + dir.mkdirs() + setDirPermissions(dir) + } + dir + } + + private val indexFile: File get() = File(draftsDir, "notes.json") + + init { + scope.launch(Dispatchers.IO) { + cached = null + load() + } + } + + /** + * Saves or updates a draft under [dTag]. Reusing an existing dTag overwrites in place. + */ + suspend fun save(draft: NoteDraft) { + mutex.withLock { + val map = loadMap() + map[draft.dTag] = draft + atomicWriteIndex(map) + cached = map + publish(map) + } + } + + suspend fun delete(dTag: String) { + mutex.withLock { + val map = loadMap() + map.remove(dTag) + atomicWriteIndex(map) + cached = map + publish(map) + } + } + + suspend fun load(dTag: String): NoteDraft? = + mutex.withLock { + loadMap()[dTag] + } + + private fun publish(map: Map) { + _drafts.value = map.values.sortedByDescending { it.updatedAt } + } + + private suspend fun load() { + mutex.withLock { publish(loadMap()) } + } + + private fun loadMap(): MutableMap { + cached?.let { return it } + val loaded: MutableMap = + if (!indexFile.exists()) { + mutableMapOf() + } else { + try { + mapper.readValue>(indexFile) + } catch (e: Exception) { + System.err.println("Failed to read note drafts index: ${e.message}") + mutableMapOf() + } + } + cached = loaded + return loaded + } + + private fun atomicWriteIndex(map: Map) { + val json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(map) + atomicWrite(indexFile, json) + } + + private fun atomicWrite( + file: File, + content: String, + ) { + val tempFile = File(file.parentFile, "${file.name}.tmp") + try { + tempFile.writeText(content) + setFilePermissions(tempFile) + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } finally { + if (tempFile.exists()) tempFile.delete() + } + } + + private fun setDirPermissions(dir: File) { + try { + Files.setPosixFilePermissions( + dir.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows + } + } + + private fun setFilePermissions(file: File) { + try { + Files.setPosixFilePermissions( + file.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ), + ) + } catch (_: UnsupportedOperationException) { + // Windows + } + } +} + +/** + * Provided at [com.vitorpamplona.amethyst.desktop.App] level so the composer (writer) + * and the Drafts tab (reader) share one store instance. + */ +val LocalNoteDraftStore = + staticCompositionLocalOf { + error("LocalNoteDraftStore not provided") + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostScheduler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostScheduler.kt new file mode 100644 index 0000000000..ad832fe9fd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostScheduler.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.scheduledposts + +import com.vitorpamplona.amethyst.commons.scheduledposts.LoggingScheduledPostNotifier +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostPublisher +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex + +/** + * In-app scheduler that publishes due scheduled posts while Amethyst Desktop runs. + * + * Phase 2 scope: the app is the sole writer, so no cross-process lockfile is needed. + * On start it runs one catch-up drain (to publish anything overdue after the app was + * closed), then ticks every [TICK_INTERVAL_MS]. All drains are account-scoped so one + * account never publishes another account's rows. Overlapping ticks are prevented by + * a non-blocking [Mutex] guard. + */ +class DesktopScheduledPostScheduler( + private val store: ScheduledPostStore, + private val scope: CoroutineScope, +) { + private val notifier = LoggingScheduledPostNotifier() + private val drainGuard = Mutex() + private var job: Job? = null + + /** + * (Re)start the loop for [accountPubkey], publishing to that account's outbox via + * [client]. [resolveRelays] returns the account's current NIP-65 write relays; + * the shared publisher falls back to each post's stored relay URLs when it is + * empty. Calling this again (e.g. on account switch) cancels the prior loop. + */ + fun start( + client: INostrClient, + accountPubkey: String, + resolveRelays: () -> Set, + ) { + stop() + val publisher = + ScheduledPostPublisher( + store = store, + client = client, + resolveRelays = { resolveRelays() }, + onSent = { notifier.notifySent(it) }, + onFailed = { post, error -> notifier.notifyFailed(post, error) }, + ) + + job = + scope.launch(Dispatchers.IO) { + // Catch-up pass first, then steady-state ticking. + drainOnce(publisher, accountPubkey) + while (isActive) { + delay(TICK_INTERVAL_MS) + drainOnce(publisher, accountPubkey) + } + } + } + + fun stop() { + job?.cancel() + job = null + } + + private suspend fun drainOnce( + publisher: ScheduledPostPublisher, + accountPubkey: String, + ) { + // Skip this cycle if a previous drain is still running. + if (!drainGuard.tryLock()) return + try { + // Cross-process single-writer discipline: the headless `--publish-scheduled` + // process drains the same store. The file lock ensures only one of the two + // drains a due row at a time. If it's held, skip this tick (the other + // drainer will handle the due posts). + val report = + ScheduledPostLock.withDrainLock { + runBlocking { publisher.drainDue(TimeUtils.now(), accountPubkey) } + } ?: return + if (report.published > 0 || report.failed > 0 || report.skipped > 0) { + Log.i(TAG) { + "Drain: published=${report.published} failed=${report.failed} skipped=${report.skipped} " + + "for ${accountPubkey.take(8)}" + } + } + } catch (e: Exception) { + Log.e(TAG, "Scheduled post drain failed", e) + } finally { + drainGuard.unlock() + } + } + + companion object { + private const val TAG = "DesktopScheduledPostScheduler" + private const val TICK_INTERVAL_MS = 45_000L + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt new file mode 100644 index 0000000000..ebd05606b5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.scheduledposts + +import androidx.compose.runtime.staticCompositionLocalOf +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore +import com.vitorpamplona.quartz.utils.Log +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +/** + * App-level factory for the shared [ScheduledPostStore], persisted under + * `~/.amethyst/scheduled/scheduled_posts.json`. Mirrors the storage/permission + * conventions of `DesktopDraftStore` (owner-only directory). + * + * A single instance is created at app startup and provided down the tree via + * [LocalScheduledPostStore] so both the composer and the in-app drain timer share + * the same store (and therefore the same in-memory `flow`). + */ +object DesktopScheduledPostStore { + fun create(): ScheduledPostStore { + val dir = File(System.getProperty("user.home"), ".amethyst/scheduled") + if (!dir.exists()) { + dir.mkdirs() + } + // Re-apply on every launch, not just on first create — a dir left at the + // default 0755 by an earlier build (or created by another tool) gets locked + // down to owner-only 0700 too. + setDirPermissions(dir) + val file = File(dir, ScheduledPostStore.FILE_NAME) + // Lock down a pre-existing store file up front (older builds wrote it at the + // 0644 umask default). New writes are already 0600 in ScheduledPostStore. + if (file.exists()) { + setOwnerOnly(file, PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE) + } + return ScheduledPostStore(file) + } + + private fun setDirPermissions(dir: File) = + setOwnerOnly( + dir, + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ) + + private fun setOwnerOnly( + target: File, + vararg perms: PosixFilePermission, + ) { + try { + Files.setPosixFilePermissions(target.toPath(), perms.toSet()) + } catch (_: UnsupportedOperationException) { + // Windows — no POSIX perms; confidentiality relies on the user profile dir. + } catch (e: Exception) { + Log.w("DesktopScheduledPostStore") { "Failed to set permissions on $target: ${e.message}" } + } + } +} + +/** + * Provided at [com.vitorpamplona.amethyst.desktop.App] level so any composable (the + * composer, a future management screen) can reach the single shared store. + */ +val LocalScheduledPostStore = + staticCompositionLocalOf { + error("LocalScheduledPostStore not provided") + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt new file mode 100644 index 0000000000..9c5fb8ee77 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.scheduledposts + +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostPublisher +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** + * Headless entry point for the `--publish-scheduled` mode. + * + * The OS scheduler (see [OsScheduler]) launches the app binary in this mode roughly + * every 5 minutes while pending scheduled posts exist. This routine is KEY-FREE: it + * only opens websockets and pushes already-signed event bytes taken verbatim from + * the store. It NEVER loads the signer, keychain, account list, or any UI — the + * events were signed earlier (when the user scheduled them, with the app open) and + * are re-verified in [ScheduledPostPublisher] before send. + * + * Flow: + * 1. Acquire the single-writer drain lock (shared with the in-app timer). If it's + * held (app running, or another headless run), log and exit 0. + * 2. Load the shared store, compute the union of due posts' relay URLs. + * 3. Build a minimal [NostrClient] over a plain (non-Tor) OkHttp websocket builder, + * connect, wait briefly for sockets, then drain ALL accounts' due posts. + * 4. Exit 0 (or 1 if the drain reported failures). + */ +fun runHeadlessPublish(): Int { + val logFile = File(scheduledDir(), "scheduler.log") + + fun log(msg: String) { + val line = "${timestamp()} $msg" + System.err.println("[scheduled-publish] $line") + try { + logFile.parentFile?.mkdirs() + logFile.appendText("$line\n") + } catch (_: Exception) { + // Logging is best-effort; never fail the drain over a log write. + } + } + + val result = + ScheduledPostLock.withDrainLock { + headlessDrain(::log) + } + + if (result == null) { + log("Drain skipped: another process holds the lock (app running or headless run in flight)") + return 0 + } + return result +} + +private fun headlessDrain(log: (String) -> Unit): Int { + val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME)) + + // Build a plain OkHttp client for websockets — no Tor, no proxy. Headless mode + // reads no settings, so we cannot know Tor preferences; scheduled posts that + // require Tor simply won't publish here (documented limitation). + val okHttp = + OkHttpClient + .Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + + val client = NostrClient(BasicOkHttpWebSocket.Builder { okHttp }) + + return try { + runBlocking { + val now = TimeUtils.now() + + // Determine the relay set: union of due (PENDING & publishAtSec<=now) rows. + val dueRelays: Set = + store + .list() + .filter { it.status == ScheduledPostStatus.PENDING && it.publishAtSec <= now } + .flatMap { it.relayUrls } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + + if (dueRelays.isEmpty()) { + log("No due posts to publish") + return@runBlocking 0 + } + + log("Connecting to ${dueRelays.size} relay(s) for due posts") + client.connect() + + // publishAndConfirmDetailed (inside the publisher) triggers per-relay + // connect+send, but give the sockets a head start so the first confirm + // window isn't spent on the TCP/TLS/WS handshake. + client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) + awaitAnyConnection(client, dueRelays) + + val publisher = + ScheduledPostPublisher( + store = store, + client = client, + resolveRelays = { post -> + post.relayUrls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + }, + onSent = { log("Sent ${it.id} (kind event ${it.signedEventJson.length} bytes)") }, + onFailed = { post, error -> log("Failed ${post.id}: $error") }, + ) + + // All accounts: headless mode has no "current account". The publisher + // asserts event.pubKey == post.accountPubkey per row, so cross-account + // publishing is impossible even though we drain every row. + val report = publisher.drainDue(now) + log("Drain complete: published=${report.published} failed=${report.failed} skipped=${report.skipped}") + + if (report.failed > 0) 1 else 0 + } + } catch (e: Exception) { + log("Headless drain crashed: ${e.message}") + 1 + } finally { + try { + client.close() + } catch (_: Exception) { + } + } +} + +/** Waits up to [timeoutMs] for at least one target relay to connect. */ +private suspend fun awaitAnyConnection( + client: NostrClient, + relays: Set, + timeoutMs: Long = 8_000, +) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val connected = client.connectedRelaysFlow().value + if (relays.any { it in connected }) return + delay(200) + } +} + +private fun scheduledDir(): File { + val dir = File(System.getProperty("user.home"), ".amethyst/scheduled") + if (!dir.exists()) dir.mkdirs() + return dir +} + +private fun timestamp(): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/OsScheduler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/OsScheduler.kt new file mode 100644 index 0000000000..cb4172307e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/OsScheduler.kt @@ -0,0 +1,538 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.scheduledposts + +import com.vitorpamplona.quartz.utils.Log +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +/** + * Registers/unregisters a native OS timer job that relaunches this app in the + * headless `--publish-scheduled` mode roughly every 5 minutes, so scheduled posts + * fire even when the desktop app is closed. + * + * Per-OS backend: + * - macOS: a launchd LaunchAgent plist (`StartInterval` 300). + * - Windows: a `schtasks` per-minute (interval 5) task that runs a hidden `.vbs` + * launcher (so no console window flashes). + * - Linux: a systemd `--user` service+timer (`OnUnitActiveSec=5min`); falls back + * to a marker-block crontab entry when systemd is unavailable. + * + * [appLaunchCommand] is the ABSOLUTE argv that invokes this app in headless mode + * (see [resolveAppLaunchCommand]). In dev (`./gradlew run`) there is no stable app + * binary, so callers pass `null` there and construct the scheduler with an empty + * command; every method then becomes a logged no-op. + * + * All process invocations use argv arrays (never `sh -c` with interpolation), and + * every file we write (plist / vbs / unit) is owner-only (0600) with a + * parent-directory writability check. + */ +class OsScheduler( + private val appLaunchCommand: List, +) { + private val os: HostOs = detectOs() + + /** True when there is no packaged binary to point the OS job at (dev mode). */ + private val isNoOp: Boolean = appLaunchCommand.isEmpty() + + fun ensureRegistered() { + if (isNoOp) { + Log.w(TAG) { "scheduled-post OS job not registered: no packaged app binary in dev mode" } + return + } + val bad = appLaunchCommand.firstOrNull { hasControlChars(it) } + if (bad != null) { + Log.e(TAG) { "Refusing to register: launch command contains control characters" } + return + } + try { + when (os) { + HostOs.MAC -> registerMac() + HostOs.WINDOWS -> registerWindows() + HostOs.LINUX -> registerLinux() + HostOs.UNKNOWN -> Log.w(TAG) { "Unsupported OS; scheduled-post OS job not registered" } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to register scheduled-post OS job", e) + } + } + + fun unregister() { + if (isNoOp) return + try { + when (os) { + HostOs.MAC -> unregisterMac() + HostOs.WINDOWS -> unregisterWindows() + HostOs.LINUX -> unregisterLinux() + HostOs.UNKNOWN -> {} + } + } catch (e: Exception) { + Log.e(TAG, "Failed to unregister scheduled-post OS job", e) + } + } + + fun isRegistered(): Boolean { + if (isNoOp) return false + return try { + when (os) { + HostOs.MAC -> isRegisteredMac() + HostOs.WINDOWS -> isRegisteredWindows() + HostOs.LINUX -> isRegisteredLinux() + HostOs.UNKNOWN -> false + } + } catch (e: Exception) { + Log.w(TAG) { "Failed to query scheduled-post OS job: ${e.message}" } + false + } + } + + // --------------------------------------------------------------------------- + // macOS — launchd LaunchAgent + // --------------------------------------------------------------------------- + + private fun uid(): String = run(listOf("id", "-u")).stdout.trim().ifEmpty { "0" } + + private fun macPlistFile(): File = File(System.getProperty("user.home"), "Library/LaunchAgents/$LABEL.plist") + + private fun registerMac() { + val plist = macPlistFile() + if (!parentDirSafe(plist)) { + Log.e(TAG) { "Refusing to write plist: ${plist.parentFile} is group/world-writable" } + return + } + writeOwnerOnly(plist, buildPlist()) + + val uid = uid() + // Best-effort teardown of any prior registration; ignore failure. + run(listOf("launchctl", "bootout", "gui/$uid/$LABEL")) + val res = run(listOf("launchctl", "bootstrap", "gui/$uid", plist.absolutePath)) + if (res.exit != 0) { + Log.w(TAG) { "launchctl bootstrap exited ${res.exit}: ${res.stderr.trim()}" } + } else { + Log.i(TAG) { "Registered launchd agent $LABEL" } + } + } + + private fun unregisterMac() { + val uid = uid() + run(listOf("launchctl", "bootout", "gui/$uid/$LABEL")) + val plist = macPlistFile() + if (plist.exists() && !plist.delete()) { + Log.w(TAG) { "Failed to delete plist $plist" } + } + } + + private fun isRegisteredMac(): Boolean { + val uid = uid() + return run(listOf("launchctl", "print", "gui/$uid/$LABEL")).exit == 0 + } + + private fun buildPlist(): String { + val argsXml = + appLaunchCommand.joinToString(separator = "\n") { " ${xmlEscape(it)}" } + return """ + + + + Label + ${xmlEscape(LABEL)} + ProgramArguments + +$argsXml + + StartInterval + $INTERVAL_SECONDS + RunAtLoad + + ProcessType + Background + EnvironmentVariables + + LANG + en_US.UTF-8 + LC_ALL + en_US.UTF-8 + + + +""" + } + + // --------------------------------------------------------------------------- + // Windows — schtasks + hidden VBS launcher + // --------------------------------------------------------------------------- + + private fun windowsVbsFile(): File { + val base = System.getenv("LOCALAPPDATA") ?: System.getProperty("user.home") + return File(File(base, "Amethyst"), "amethyst-scheduler-launch.vbs") + } + + private fun registerWindows() { + val vbs = windowsVbsFile() + vbs.parentFile?.mkdirs() + if (!parentDirSafe(vbs)) { + Log.e(TAG) { "Refusing to write vbs: ${vbs.parentFile} is group/world-writable" } + return + } + writeOwnerOnly(vbs, buildVbs()) + + // /tr must be a single string; wscript runs the .vbs which in turn spawns + // the app hidden (window style 0), avoiding a console flash. + val tr = "wscript.exe \"${vbs.absolutePath}\"" + val res = + run( + listOf( + "schtasks", + "/create", + "/sc", + "minute", + "/mo", + "5", + "/tn", + TASK_NAME, + "/tr", + tr, + "/f", + ), + ) + if (res.exit != 0) { + Log.w(TAG) { "schtasks /create exited ${res.exit}: ${res.stderr.trim()}" } + } else { + Log.i(TAG) { "Registered scheduled task $TASK_NAME" } + } + } + + private fun unregisterWindows() { + run(listOf("schtasks", "/delete", "/tn", TASK_NAME, "/f")) + val vbs = windowsVbsFile() + if (vbs.exists() && !vbs.delete()) { + Log.w(TAG) { "Failed to delete vbs $vbs" } + } + } + + private fun isRegisteredWindows(): Boolean = run(listOf("schtasks", "/query", "/tn", TASK_NAME)).exit == 0 + + private fun buildVbs(): String { + // WScript.Shell.Run(command, windowStyle=0 (hidden), waitOnReturn=False) + val cmd = appLaunchCommand.joinToString(separator = " ") { "\"\"${it.replace("\"", "")}\"\"" } + return """Set WshShell = CreateObject("WScript.Shell") +WshShell.Run "$cmd", 0, False +""" + } + + // --------------------------------------------------------------------------- + // Linux — systemd --user (service + timer), cron fallback + // --------------------------------------------------------------------------- + + private fun systemdAvailable(): Boolean = File("/run/systemd/system").exists() + + private fun systemdDir(): File = File(System.getProperty("user.home"), ".config/systemd/user") + + private fun serviceFile(): File = File(systemdDir(), "$LINUX_UNIT.service") + + private fun timerFile(): File = File(systemdDir(), "$LINUX_UNIT.timer") + + private fun registerLinux() { + if (systemdAvailable()) { + registerSystemd() + } else { + registerCron() + } + } + + private fun unregisterLinux() { + if (systemdAvailable()) { + unregisterSystemd() + } + // Always attempt cron cleanup so a fallback entry from a prior boot is removed. + unregisterCron() + } + + private fun isRegisteredLinux(): Boolean = + if (systemdAvailable()) { + run(listOf("systemctl", "--user", "is-enabled", "$LINUX_UNIT.timer")).exit == 0 + } else { + currentCrontab().contains(CRON_MARKER_BEGIN) + } + + private fun registerSystemd() { + val dir = systemdDir() + dir.mkdirs() + if (!parentDirSafe(serviceFile())) { + Log.e(TAG) { "Refusing to write systemd unit: $dir is group/world-writable" } + return + } + writeOwnerOnly(serviceFile(), buildService()) + writeOwnerOnly(timerFile(), buildTimer()) + + run(listOf("systemctl", "--user", "daemon-reload")) + val res = run(listOf("systemctl", "--user", "enable", "--now", "$LINUX_UNIT.timer")) + if (res.exit != 0) { + Log.w(TAG) { "systemctl enable --now exited ${res.exit}: ${res.stderr.trim()}" } + } + + // App-closed firing needs the user to have lingering enabled; tolerate failure. + val user = System.getProperty("user.name") ?: "" + if (user.isNotEmpty()) { + val linger = run(listOf("loginctl", "enable-linger", user)) + if (linger.exit != 0) { + Log.w(TAG) { + "Could not enable-linger for $user (exit ${linger.exit}); scheduled posts will " + + "only fire while you are logged in. Run: loginctl enable-linger $user" + } + } + } + Log.i(TAG) { "Registered systemd --user timer $LINUX_UNIT.timer" } + } + + private fun unregisterSystemd() { + run(listOf("systemctl", "--user", "disable", "--now", "$LINUX_UNIT.timer")) + if (serviceFile().exists() && !serviceFile().delete()) { + Log.w(TAG) { "Failed to delete ${serviceFile()}" } + } + if (timerFile().exists() && !timerFile().delete()) { + Log.w(TAG) { "Failed to delete ${timerFile()}" } + } + run(listOf("systemctl", "--user", "daemon-reload")) + } + + private fun buildService(): String { + val execStart = appLaunchCommand.joinToString(separator = " ") { systemdEscape(it) } + return """[Unit] +Description=Amethyst scheduled-post publisher + +[Service] +Type=oneshot +ExecStart=$execStart +""" + } + + private fun buildTimer(): String = + """[Unit] +Description=Amethyst scheduled-post publisher timer + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min +Persistent=true + +[Install] +WantedBy=timers.target +""" + + // --- cron fallback --- + + private fun currentCrontab(): String { + val res = run(listOf("crontab", "-l")) + // Exit 1 with "no crontab" is normal for a user without one yet. + return if (res.exit == 0) res.stdout else "" + } + + private fun registerCron() { + val existing = currentCrontab() + val stripped = stripCronBlock(existing) + val cmd = appLaunchCommand.joinToString(separator = " ") { shellQuote(it) } + val block = + buildString { + append(CRON_MARKER_BEGIN).append('\n') + append("*/5 * * * * ").append(cmd).append('\n') + append(CRON_MARKER_END).append('\n') + } + val next = (if (stripped.isBlank()) "" else stripped.trimEnd() + "\n") + block + val res = runWithStdin(listOf("crontab", "-"), next) + if (res.exit != 0) { + Log.w(TAG) { "crontab install exited ${res.exit}: ${res.stderr.trim()}" } + } else { + Log.i(TAG) { "Registered cron fallback entry" } + } + } + + private fun unregisterCron() { + val existing = currentCrontab() + if (!existing.contains(CRON_MARKER_BEGIN)) return + val stripped = stripCronBlock(existing) + val res = + if (stripped.isBlank()) { + run(listOf("crontab", "-r")) + } else { + runWithStdin(listOf("crontab", "-"), stripped.trimEnd() + "\n") + } + if (res.exit != 0) { + Log.w(TAG) { "crontab uninstall exited ${res.exit}: ${res.stderr.trim()}" } + } + } + + private fun stripCronBlock(crontab: String): String { + if (!crontab.contains(CRON_MARKER_BEGIN)) return crontab + val lines = crontab.lines() + val out = StringBuilder() + var inside = false + for (line in lines) { + when { + line.trim() == CRON_MARKER_BEGIN -> inside = true + line.trim() == CRON_MARKER_END -> inside = false + !inside -> out.append(line).append('\n') + } + } + return out.toString() + } + + // --------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------- + + private data class ProcResult( + val exit: Int, + val stdout: String, + val stderr: String, + ) + + private fun run(argv: List): ProcResult = runWithStdin(argv, null) + + private fun runWithStdin( + argv: List, + stdin: String?, + ): ProcResult = + try { + val proc = ProcessBuilder(argv).redirectErrorStream(false).start() + if (stdin != null) { + proc.outputStream.use { it.write(stdin.toByteArray(Charsets.UTF_8)) } + } else { + proc.outputStream.close() + } + val out = proc.inputStream.readBytes().toString(Charsets.UTF_8) + val err = proc.errorStream.readBytes().toString(Charsets.UTF_8) + proc.waitFor() + ProcResult(proc.exitValue(), out, err) + } catch (e: Exception) { + Log.w(TAG) { "Command failed to run (${argv.firstOrNull()}): ${e.message}" } + ProcResult(-1, "", e.message ?: "") + } + + /** Writes [content] to [file] with owner read/write only (best-effort on Windows). */ + private fun writeOwnerOnly( + file: File, + content: String, + ) { + file.parentFile?.mkdirs() + file.writeText(content, Charsets.UTF_8) + try { + Files.setPosixFilePermissions( + file.toPath(), + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + ) + } catch (_: UnsupportedOperationException) { + // Windows — POSIX perms not supported; ACLs default to owner. + } catch (e: Exception) { + Log.w(TAG) { "Failed to set 0600 on $file: ${e.message}" } + } + } + + /** + * Refuses to write into a directory that is group- or world-writable (a + * classic privilege-escalation vector: an attacker could swap our plist/unit + * for one that runs their command). Best-effort — always safe on Windows + * (POSIX perms unsupported → treated as safe). + */ + private fun parentDirSafe(file: File): Boolean { + val dir = file.parentFile ?: return true + if (!dir.exists()) return true + return try { + val perms = Files.getPosixFilePermissions(dir.toPath()) + !perms.contains(PosixFilePermission.GROUP_WRITE) && + !perms.contains(PosixFilePermission.OTHERS_WRITE) + } catch (_: UnsupportedOperationException) { + true + } catch (e: Exception) { + Log.w(TAG) { "Could not check permissions on $dir: ${e.message}" } + true + } + } + + private fun hasControlChars(s: String): Boolean = s.any { it.isISOControl() } + + private fun xmlEscape(s: String): String = + buildString(s.length) { + for (c in s) { + when (c) { + '&' -> append("&") + '<' -> append("<") + '>' -> append(">") + '"' -> append(""") + else -> append(c) + } + } + } + + /** Escapes a token for a systemd ExecStart line (spaces/backslashes). */ + private fun systemdEscape(s: String): String = + if (s.any { it == ' ' || it == '\\' || it == '"' }) { + "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + } else { + s + } + + /** Single-quotes a token for a POSIX shell (cron reads /bin/sh). */ + private fun shellQuote(s: String): String = "'" + s.replace("'", "'\\''") + "'" + + private enum class HostOs { MAC, WINDOWS, LINUX, UNKNOWN } + + private fun detectOs(): HostOs { + val name = System.getProperty("os.name")?.lowercase() ?: "" + return when { + name.contains("mac") || name.contains("darwin") -> HostOs.MAC + name.contains("win") -> HostOs.WINDOWS + name.contains("nux") || name.contains("nix") -> HostOs.LINUX + else -> HostOs.UNKNOWN + } + } + + companion object { + private const val TAG = "OsScheduler" + + /** Canonical label (macOS launchd / Windows schtasks folder+name). */ + const val LABEL = "com.vitorpamplona.amethyst.scheduler" + private const val TASK_NAME = "Amethyst\\Scheduler" + private const val LINUX_UNIT = "amethyst-scheduler" + private const val INTERVAL_SECONDS = 300 + + private const val CRON_MARKER_BEGIN = "# BEGIN amethyst-scheduler" + private const val CRON_MARKER_END = "# END amethyst-scheduler" + + /** + * The absolute argv that invokes this app in headless publish mode, or + * `null` when running in dev (`./gradlew run`) where no stable app binary + * exists. Callers pass `null` through as an empty command so the scheduler + * no-ops rather than registering a job that points at gradle/java. + * + * `jpackage.app-path` is set by the JVM launcher inside a jpackage bundle + * to the absolute path of the app executable. + */ + fun resolveAppLaunchCommand(): List? { + val appPath = System.getProperty("jpackage.app-path") + if (appPath.isNullOrBlank()) return null + val f = File(appPath) + if (!f.isAbsolute || !f.exists()) return null + return listOf(f.absolutePath, "--publish-scheduled") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/ScheduledPostLock.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/ScheduledPostLock.kt new file mode 100644 index 0000000000..78b6843341 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/ScheduledPostLock.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.service.scheduledposts + +import com.vitorpamplona.quartz.utils.Log +import java.io.File +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Cross-process single-writer lock for the scheduled-post drain. + * + * Two things can drain the shared `scheduled_posts.json` at the same time: the + * in-app 45s timer (while the desktop app runs) and the headless + * `--publish-scheduled` process launched by the OS scheduler. If both drained at + * once they could double-publish a claimed row (or race on the file rename). + * + * [withDrainLock] serializes them with an OS-level advisory lock on + * `~/.amethyst/scheduled/scheduled.lock` via [FileChannel.tryLock]. The lock is + * non-blocking: if another process already holds it, [withDrainLock] returns + * `null` and the caller skips this cycle (the other drainer will handle the due + * rows). An in-process [AtomicBoolean] guard layers on top so two threads inside + * the same JVM never both try to acquire the file lock (a JVM cannot hold two + * overlapping locks on the same channel, and the file-lock is per-JVM anyway). + */ +object ScheduledPostLock { + private const val TAG = "ScheduledPostLock" + private const val LOCK_FILE_NAME = "scheduled.lock" + + // In-process guard: a single JVM must not attempt two overlapping file locks. + private val inProcessBusy = AtomicBoolean(false) + + private fun lockFile(): File { + val dir = File(System.getProperty("user.home"), ".amethyst/scheduled") + if (!dir.exists()) dir.mkdirs() + return File(dir, LOCK_FILE_NAME) + } + + /** + * Runs [block] while holding the exclusive drain lock. Returns the block's + * result, or `null` if the lock could not be acquired (someone else is + * draining) — in which case [block] is never invoked. + */ + fun withDrainLock(block: () -> T): T? { + // In-process fast path: bail if another thread in this JVM already holds it. + if (!inProcessBusy.compareAndSet(false, true)) { + Log.d(TAG) { "Drain skipped: another thread in this process holds the lock" } + return null + } + + var raf: RandomAccessFile? = null + var lock: FileLock? = null + try { + raf = RandomAccessFile(lockFile(), "rw") + lock = + try { + raf.channel.tryLock() + } catch (_: OverlappingFileLockException) { + null + } + + if (lock == null) { + Log.d(TAG) { "Drain skipped: lock held by another process" } + return null + } + + return block() + } catch (e: Exception) { + Log.e(TAG, "Failed to acquire scheduled-post drain lock", e) + return null + } finally { + try { + lock?.release() + } catch (e: Exception) { + Log.w(TAG) { "Failed to release drain lock: ${e.message}" } + } + try { + raf?.close() + } catch (e: Exception) { + Log.w(TAG) { "Failed to close lock file: ${e.message}" } + } + inProcessBusy.set(false) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 3c36d63e95..b98cbada5c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -36,10 +36,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -64,6 +67,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult @@ -74,6 +79,9 @@ import com.vitorpamplona.amethyst.desktop.ImageCompressionStore import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore +import com.vitorpamplona.amethyst.desktop.service.drafts.NoteDraft +import com.vitorpamplona.amethyst.desktop.service.scheduledposts.LocalScheduledPostStore import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker import com.vitorpamplona.amethyst.desktop.ui.compose.ComposeRelayPicker import com.vitorpamplona.amethyst.desktop.ui.compose.RelayPickerState @@ -85,6 +93,10 @@ import com.vitorpamplona.amethyst.desktop.ui.media.PreviewItem import com.vitorpamplona.amethyst.desktop.ui.media.QualitySelectorChip import com.vitorpamplona.amethyst.desktop.ui.media.buildPreview import com.vitorpamplona.amethyst.desktop.ui.media.cleanupPreviewTemps +import com.vitorpamplona.amethyst.desktop.ui.scheduling.DesktopScheduleAtButton +import com.vitorpamplona.amethyst.desktop.ui.scheduling.DesktopScheduleAtPicker +import com.vitorpamplona.amethyst.desktop.ui.scheduling.presetInOneHour +import com.vitorpamplona.amethyst.desktop.ui.scheduling.sanitizeScheduleTime import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -101,8 +113,10 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip18Reposts.quotes.quote import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.isClient import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -110,6 +124,7 @@ import java.awt.datatransfer.DataFlavor import java.awt.dnd.DnDConstants import java.awt.dnd.DropTargetDropEvent import java.io.File +import java.util.UUID private val MEDIA_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac") @@ -126,10 +141,20 @@ fun ComposeNoteDialog( localCache: com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache? = null, replyTo: Event? = null, quoteOf: Event? = null, + draftDTag: String? = null, + draftInitialContent: String? = null, + initialScheduledForSec: Long? = null, ) { + val noteDraftStore = LocalNoteDraftStore.current + + // Stable dTag for this composer session: reuse the edited draft's tag, else mint one. + val draftTag = remember(draftDTag) { draftDTag ?: UUID.randomUUID().toString() } + val initialContent = - remember(quoteOf) { - if (quoteOf != null) { + remember(quoteOf, draftInitialContent) { + if (draftInitialContent != null) { + draftInitialContent + } else if (quoteOf != null) { val relays = relayManager.connectedRelays.value.take(3) val nevent = NEvent.create(quoteOf.id, quoteOf.pubKey, quoteOf.kind, relays) "\nnostr:$nevent" @@ -183,6 +208,16 @@ fun ComposeNoteDialog( var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) } var postAsPicture by remember { mutableStateOf(false) } + // Scheduling — when non-null the note is stored for later publication instead of + // being sent immediately. Picture posts are not schedulable (out of scope for v1). + val scheduledPostStore = LocalScheduledPostStore.current + var scheduledForSec by remember { mutableStateOf(initialScheduledForSec) } + + // NIP-37 draft sync — opt-in, default OFF. When ON, "Save as draft" additionally + // publishes an encrypted kind-31234 event so the draft appears on other devices. + var syncDraft by remember { mutableStateOf(false) } + var isSavingDraft by remember { mutableStateOf(false) } + // Image compression: global default + optional per-post override. // Override resets after every successful send so the next post // starts from the saved default again. @@ -331,6 +366,7 @@ fun ComposeNoteDialog( } } + val scheduleAt = scheduledForSec if (postAsPicture) { val pictureMetas = buildPictureMetas(uploadResults) publishPicture( @@ -340,6 +376,23 @@ fun ComposeNoteDialog( relayManager = relayManager, relays = selectedRelays, ) + } else if (scheduleAt != null) { + // Scheduled note: pre-sign with a re-stamped createdAt and store + // for later publication instead of sending now. Image URLs and + // imeta are already folded into finalContent / imetaTags, so + // image notes are schedulable too. + val imetaTags = buildIMetaTags(uploadResults) + scheduleNote( + content = finalContent, + account = account, + relayManager = relayManager, + replyTo = replyTo, + quoteOf = quoteOf, + imetaTags = imetaTags, + relays = selectedRelays, + scheduledForSec = scheduleAt, + store = scheduledPostStore, + ) } else { val imetaTags = buildIMetaTags(uploadResults) publishNote( @@ -363,13 +416,17 @@ fun ComposeNoteDialog( } Dialog( - onDismissRequest = { if (!isPosting) onDismiss() }, + onDismissRequest = { if (!isPosting && !isSavingDraft) onDismiss() }, properties = DialogProperties(usePlatformDefaultWidth = false), ) { Card( modifier = Modifier .width(780.dp) + // Cap the dialog height so a tall composer (e.g. the schedule + // picker expanded) can't push the Cancel/Schedule buttons off + // screen — the body scrolls instead (see the content Column). + .heightIn(max = 760.dp) .padding(16.dp) .dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget) .then( @@ -380,7 +437,7 @@ fun ComposeNoteDialog( }, ), ) { - Column(modifier = Modifier.padding(24.dp)) { + Column(modifier = Modifier.padding(24.dp).verticalScroll(rememberScrollState())) { Text( when { replyTo != null -> "Reply" @@ -463,19 +520,52 @@ fun ComposeNoteDialog( Spacer(Modifier.height(8.dp)) - MediaAttachmentRow( - attachedFiles = attachedFiles, - isUploading = uploadState.isUploading, - onAttach = { - val files = DesktopFilePicker.pickMediaFiles() - attachedFiles.addAll(files) - }, - onPaste = { - val files = ClipboardPasteHandler.getClipboardFiles() - attachedFiles.addAll(files) - }, - onRemove = { attachedFiles.remove(it) }, - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + // MediaAttachmentRow fills its width, so give it a weighted slot; + // otherwise it consumes the whole Row and pushes the schedule + // button off the right edge (making it invisible). + Box(Modifier.weight(1f)) { + MediaAttachmentRow( + attachedFiles = attachedFiles, + isUploading = uploadState.isUploading, + onAttach = { + val files = DesktopFilePicker.pickMediaFiles() + attachedFiles.addAll(files) + }, + onPaste = { + val files = ClipboardPasteHandler.getClipboardFiles() + attachedFiles.addAll(files) + }, + onRemove = { attachedFiles.remove(it) }, + ) + } + + // Picture posts aren't schedulable in v1 — hide the toggle then. + if (!postAsPicture) { + DesktopScheduleAtButton( + isActive = scheduledForSec != null, + onClick = { + scheduledForSec = + if (scheduledForSec != null) { + null + } else { + sanitizeScheduleTime(presetInOneHour()) + } + }, + ) + } + } + + if (scheduledForSec != null && !postAsPicture) { + Spacer(Modifier.height(8.dp)) + DesktopScheduleAtPicker( + scheduledForSec = scheduledForSec ?: 0L, + onChanged = { scheduledForSec = it }, + ) + } // Server selector + per-post quality + post type — shown when files are attached if (attachedFiles.isNotEmpty()) { @@ -559,19 +649,103 @@ fun ComposeNoteDialog( Spacer(Modifier.height(8.dp)) + // NIP-37 draft-sync opt-in — only meaningful for plain notes. + if (!postAsPicture) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = syncDraft, + onCheckedChange = { syncDraft = it }, + enabled = !isPosting && !isSavingDraft, + ) + Column { + Text( + "Sync draft across devices (encrypted)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "Content stays encrypted to you, but the relay learns a " + + "draft exists at this time for your account.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(8.dp)) + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, ) { OutlinedButton( onClick = onDismiss, - enabled = !isPosting, + enabled = !isPosting && !isSavingDraft, ) { Text("Cancel") } Spacer(Modifier.width(8.dp)) + // Save-as-draft: always writes a local row; optionally publishes a + // NIP-37 encrypted event. Local save still succeeds if sync fails. + OutlinedButton( + onClick = { + if (content.isBlank()) { + errorMessage = "Draft cannot be empty" + return@OutlinedButton + } + scope.launch { + isSavingDraft = true + errorMessage = null + var syncError: String? = null + try { + if (syncDraft) { + syncError = + syncDraftToRelays( + content = content, + dTag = draftTag, + account = account, + relayManager = relayManager, + replyTo = replyTo, + quoteOf = quoteOf, + relays = selectedRelays, + ) + } + + noteDraftStore.save( + NoteDraft( + dTag = draftTag, + content = content, + updatedAt = TimeUtils.now(), + synced = syncDraft && syncError == null, + accountPubkey = account.pubKeyHex, + ), + ) + + if (syncError != null) { + errorMessage = "Draft saved locally, but sync failed: $syncError" + } else { + onDismiss() + } + } catch (e: Exception) { + errorMessage = "Failed to save draft: ${e.message}" + } finally { + isSavingDraft = false + } + } + }, + enabled = !isPosting && !isSavingDraft && content.isNotBlank(), + ) { + Text(if (isSavingDraft) "Saving..." else "Save as draft") + } + + Spacer(Modifier.width(8.dp)) + Button( onClick = { if (content.isBlank() && attachedFiles.isEmpty()) { @@ -601,12 +775,14 @@ fun ComposeNoteDialog( } runPublish(null, emptySet()) }, - enabled = !isPosting && (content.isNotBlank() || attachedFiles.isNotEmpty()), + enabled = !isPosting && !isSavingDraft && (content.isNotBlank() || attachedFiles.isNotEmpty()), ) { Text( when { + isPosting && scheduledForSec != null -> "Scheduling…" isPosting -> if (pendingPreview == null && hasImages) "Compressing…" else "Publishing…" hasImages && pendingPreview == null -> "Preview" + scheduledForSec != null -> "Schedule" else -> "Publish" }, ) @@ -850,6 +1026,143 @@ private suspend fun publishNote( } } +/** + * Build the same text-note template [publishNote] would, but stamped with + * [scheduledForSec] as createdAt so it surfaces at the intended time, sign it now, + * and persist it to [store] for the in-app drain timer to publish later. + * + * Signing happens up-front (not at fire time) so the note can go out even if a remote + * NIP-46 bunker signer is offline when the schedule fires. Because signing is a suspend + * call that can time out/fail for a remote signer, failures are surfaced to the caller + * (which shows the error and does NOT store the post). + */ +private suspend fun scheduleNote( + content: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + replyTo: Event?, + quoteOf: Event?, + imetaTags: List, + relays: Set, + scheduledForSec: Long, + store: ScheduledPostStore, +) { + withContext(Dispatchers.IO) { + if (account.isReadOnly) { + throw IllegalStateException("Cannot post in read-only mode") + } + + val template = + TextNoteEvent.build(content, createdAt = scheduledForSec) { + if (replyTo != null) { + val etag = ETag(replyTo.id) + etag.relay = null + etag.author = replyTo.pubKey + eTag(etag) + pTag(PTag(replyTo.pubKey, relayHint = null)) + } + if (quoteOf != null) { + quote(QEventTag(quoteOf.id, relayHint = null, authorPubKeyHex = quoteOf.pubKey)) + pTag(PTag(quoteOf.pubKey, relayHint = null)) + } + hashtags(findHashtags(content)) + references(findURLs(content)) + for (imeta in imetaTags) { + add(imeta.toTagArray()) + } + } + + // account.signer.sign is suspend and throws (SignerExceptions) on failure — + // e.g. a remote bunker being offline. Convert to a clear user-facing message. + val signedEvent = + try { + account.signer.sign(template) + } catch (e: Exception) { + throw IllegalStateException("Signer must be online to schedule a post", e) + } + + // Snapshot the relays the post should go to: the account's outbox if we have it, + // else whatever is currently connected. Stored so the drain uses them if the + // account's live relay flow is empty at fire time. + val targetRelays = relays.ifEmpty { relayManager.connectedRelays.value } + + store.add( + ScheduledPost( + id = UUID.randomUUID().toString(), + accountPubkey = signedEvent.pubKey, + signedEventJson = signedEvent.toJson(), + relayUrls = targetRelays.map { it.url }, + extraEventsJson = emptyList(), + publishAtSec = scheduledForSec, + createdAtSec = TimeUtils.now(), + ), + ) + } +} + +/** + * Publish the current note as a NIP-37 encrypted draft (kind 31234). + * + * The inner text note is built the same way [publishNote] builds it, signed, then + * wrapped by [DraftWrapEvent.create] which NIP-44-encrypts it *to the account's own + * pubkey* ([account] `.signer`, so `signer.pubKey == draftWrap.pubKey`) — never + * plaintext. Uses [dTag] as the replaceable identifier so re-saving the same draft + * replaces the prior sync event, and so a synced row dedups with its local twin. + * + * Signing is a suspend call that can fail when a remote NIP-46 signer is offline; the + * failure is returned as a message (not thrown) so the caller can still save locally. + * + * @return null on success, else a short human-readable failure reason. + */ +private suspend fun syncDraftToRelays( + content: String, + dTag: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + replyTo: Event?, + quoteOf: Event?, + relays: Set, +): String? = + withContext(Dispatchers.IO) { + if (account.isReadOnly) { + return@withContext "Cannot sync in read-only mode" + } + + try { + val innerTemplate = + TextNoteEvent.build(content) { + if (replyTo != null) { + val etag = ETag(replyTo.id) + etag.relay = null + etag.author = replyTo.pubKey + eTag(etag) + pTag(PTag(replyTo.pubKey, relayHint = null)) + } + if (quoteOf != null) { + quote(QEventTag(quoteOf.id, relayHint = null, authorPubKeyHex = quoteOf.pubKey)) + pTag(PTag(quoteOf.pubKey, relayHint = null)) + } + hashtags(findHashtags(content)) + references(findURLs(content)) + } + + val signedInner = account.signer.sign(innerTemplate) + val draftWrap = + DraftWrapEvent.create( + dTag = dTag, + draft = signedInner, + signer = account.signer, + createdAt = TimeUtils.now(), + ) + + val targetRelays = relays.ifEmpty { relayManager.connectedRelays.value } + relayManager.publish(draftWrap, targetRelays) + null + } catch (e: Exception) { + e.message ?: "signer unavailable" + } + } + @Composable private fun MentionSuggestionRow( user: User, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 12865e4f61..19f2a8248e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -72,7 +72,6 @@ import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen import com.vitorpamplona.amethyst.desktop.ui.CustomFeedScreen -import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen @@ -82,6 +81,7 @@ import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen import com.vitorpamplona.amethyst.desktop.ui.relay.RelayDashboardScreen +import com.vitorpamplona.amethyst.desktop.ui.scheduledposts.DraftsAndScheduledScreen import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow @@ -139,6 +139,7 @@ fun DeckColumnContainer( appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onEditInComposer: (content: String, draftDTag: String?, scheduledForSec: Long?) -> Unit = { _, _, _ -> }, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToRelays: () -> Unit = {}, onOpenNotificationSettings: () -> Unit = {}, @@ -236,6 +237,7 @@ fun DeckColumnContainer( compactMode = true, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onEditInComposer = onEditInComposer, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, @@ -341,6 +343,7 @@ internal fun RootContent( compactMode: Boolean = false, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onEditInComposer: (content: String, draftDTag: String?, scheduledForSec: Long?) -> Unit = { _, _, _ -> }, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, @@ -586,9 +589,13 @@ internal fun RootContent( } DeckColumnType.Drafts -> { - DraftsScreen( + DraftsAndScheduledScreen( draftStore = draftStore ?: remember { DesktopDraftStore(scope) }, + accountPubkeyHex = account.pubKeyHex, + relayManager = relayManager, + account = account, onOpenEditor = { slug -> onNavigateToEditor(slug) }, + onEditInComposer = onEditInComposer, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt index a52a6fb97d..aae7f5dce4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -100,7 +100,7 @@ sealed class DeckColumnType { Wallet -> "Wallet" is Article -> "Article" is Editor -> "New Article" - Drafts -> "Drafts" + Drafts -> "Scheduled" MyHighlights -> "Highlights" is Profile -> "Profile" is Thread -> "Thread" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index e2478af314..40fb463223 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -73,6 +73,7 @@ fun DeckLayout( appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onEditInComposer: (content: String, draftDTag: String?, scheduledForSec: Long?) -> Unit = { _, _, _ -> }, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToRelays: () -> Unit = {}, onOpenNotificationSettings: () -> Unit = {}, @@ -143,6 +144,7 @@ fun DeckLayout( appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onEditInComposer = onEditInComposer, onZapFeedback = onZapFeedback, onNavigateToRelays = onNavigateToRelays, onOpenNotificationSettings = onOpenNotificationSettings, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 1039b55d76..8b93969e34 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -73,6 +73,7 @@ fun SinglePaneLayout( onOpenFeedsDrawer: () -> Unit = onOpenAppDrawer, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onEditInComposer: (content: String, draftDTag: String?, scheduledForSec: Long?) -> Unit = { _, _, _ -> }, onShowImportFollowListDialog: () -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit, signerConnectionState: SignerConnectionState, @@ -136,6 +137,7 @@ fun SinglePaneLayout( appScope = appScope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onEditInComposer = onEditInComposer, onZapFeedback = onZapFeedback, onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/DraftsAndScheduledScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/DraftsAndScheduledScreen.kt new file mode 100644 index 0000000000..69b906669f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/DraftsAndScheduledScreen.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.scheduledposts + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore +import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen +import com.vitorpamplona.amethyst.desktop.ui.ReadingColumn + +/** + * Host for the "Drafts & Scheduled" deck column: a two-tab surface over the + * existing article [DraftsScreen] (which brings its own [ReadingColumn]) and the + * new [ScheduledPostsList] management list. + * + * @param accountPubkeyHex the logged-in account whose scheduled posts to show. + * The shared store holds every account's rows, so the list must be scoped here. + */ +@Composable +fun DraftsAndScheduledScreen( + draftStore: DesktopDraftStore, + accountPubkeyHex: String, + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, + onOpenEditor: (slug: String?) -> Unit, + onEditInComposer: (content: String, draftDTag: String?, scheduledForSec: Long?) -> Unit = { _, _, _ -> }, +) { + // Scheduled first — it's the destination's headline and matches the sidebar label. + var selectedTab by rememberSaveable { mutableStateOf(0) } + + Column(modifier = Modifier.fillMaxSize()) { + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { + Text("Scheduled", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text("Drafts", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) { + Text("Articles", modifier = Modifier.padding(12.dp)) + } + } + + Box(modifier = Modifier.weight(1f)) { + when (selectedTab) { + 0 -> + ReadingColumn { + ScheduledPostsList( + accountPubkeyHex = accountPubkeyHex, + onEditInComposer = onEditInComposer, + ) + } + 1 -> + ReadingColumn { + NoteDraftsList( + relayManager = relayManager, + account = account, + // Editing a draft re-opens the composer prefilled with its content + // and reuses its dTag, so re-saving replaces the same draft row. + onOpenDraft = { dTag, content -> onEditInComposer(content, dTag, null) }, + ) + } + else -> + DraftsScreen( + draftStore = draftStore, + onOpenEditor = onOpenEditor, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/NoteDraftsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/NoteDraftsList.kt new file mode 100644 index 0000000000..211c2b4c91 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/NoteDraftsList.kt @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.scheduledposts + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * One row in the merged drafts list. + * + * @param dTag NIP-37 identifier — the dedup key across the local store and synced events. + * @param content the plaintext body to preview / re-open. + * @param updatedAt epoch-second used for ordering. + * @param synced true if this draft has a kind-31234 twin on relays. + */ +private data class MergedDraft( + val dTag: String, + val content: String, + val updatedAt: Long, + val synced: Boolean, +) + +/** + * The short-note Drafts list: local drafts written by the composer merged with the + * user's synced NIP-37 (kind-31234) drafts. Rows are deduped by `dTag` (a local row and + * its synced twin collapse to one); synced rows get a small cloud badge. + * + * Subscription and decryption are scoped to this composable's lifecycle via + * [rememberSubscription]. Decryption failures are swallowed per-event so one bad draft + * doesn't blank the list. + */ +@Composable +fun NoteDraftsList( + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, + onOpenDraft: (dTag: String, content: String) -> Unit, +) { + val noteDraftStore = LocalNoteDraftStore.current + val localDrafts by noteDraftStore.drafts.collectAsState() + val scope = rememberCoroutineScope() + + val connectedRelays by relayManager.connectedRelays.collectAsState() + + // Decrypt cache keyed on the signer identity; re-created if the account changes. + val draftCache = remember(account.pubKeyHex) { DraftEventCache(account.signer) } + + // dTag -> decrypted synced draft. mutableStateMap so async decrypt results recompose. + val syncedByDTag = remember(account.pubKeyHex) { mutableStateMapOf() } + + // Subscribe to the account's own kind-31234 drafts. + rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + SubscriptionConfig( + subId = "note-drafts-${account.pubKeyHex.take(8)}", + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(account.pubKeyHex), + kinds = listOf(DraftWrapEvent.KIND), + limit = 100, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is DraftWrapEvent) { + val wrap: DraftWrapEvent = event + val dTag = wrap.dTag() + if (wrap.isDeleted()) { + syncedByDTag.remove(dTag) + } else { + scope.launch { + try { + val inner = draftCache.cachedDraft(wrap) + if (inner != null) { + val existing = syncedByDTag[dTag] + // Keep the newest for a given dTag. + if (existing == null || wrap.createdAt >= existing.updatedAt) { + syncedByDTag[dTag] = + MergedDraft( + dTag = dTag, + content = inner.content, + updatedAt = wrap.createdAt, + synced = true, + ) + } + } + } catch (_: Exception) { + // Undecryptable draft (e.g. signer offline) — skip it. + } + } + } + } + }, + ) + } else { + null + } + } + + // Merge local + synced by dTag. A dTag present in both shows once, flagged synced. + val merged: List = + remember(localDrafts, syncedByDTag.toMap(), account.pubKeyHex) { + val byTag = LinkedHashMap() + localDrafts + // Only show drafts owned by the current account. Legacy rows with a blank + // pubkey are scoped out entirely so account A's plaintext never leaks to B. + .filter { it.accountPubkey == account.pubKeyHex } + .forEach { d -> + byTag[d.dTag] = + MergedDraft( + dTag = d.dTag, + content = d.content, + updatedAt = d.updatedAt, + synced = d.synced, + ) + } + syncedByDTag.values.forEach { s -> + val existing = byTag[s.dTag] + if (existing == null) { + byTag[s.dTag] = s + } else { + // Local wins for content/updatedAt; mark synced true. + byTag[s.dTag] = existing.copy(synced = true) + } + } + byTag.values.sortedByDescending { it.updatedAt } + } + + if (merged.isEmpty()) { + EmptyState( + title = "No note drafts yet", + description = "Write a note and choose \"Save as draft\" to keep it here.", + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(merged, key = { it.dTag }) { draft -> + NoteDraftCard( + draft = draft, + onClick = { onOpenDraft(draft.dTag, draft.content) }, + onSend = { + scope.launch { + try { + publishDraftNow( + content = draft.content, + account = account, + relayManager = relayManager, + ) + // Only drop the draft once publish has succeeded. + noteDraftStore.delete(draft.dTag) + syncedByDTag.remove(draft.dTag) + } catch (e: Exception) { + Log.w(TAG, "Failed to publish draft ${draft.dTag}", e) + } + } + }, + onDelete = { + scope.launch { noteDraftStore.delete(draft.dTag) } + syncedByDTag.remove(draft.dTag) + }, + ) + } + } + } +} + +@Composable +private fun NoteDraftCard( + draft: MergedDraft, + onClick: () -> Unit, + onSend: () -> Unit, + onDelete: () -> Unit, +) { + OutlinedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = draft.content.ifBlank { "Empty draft" }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (draft.synced) { + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + MaterialSymbols.CloudSync, + contentDescription = "Synced draft", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.size(4.dp)) + Text( + text = "Synced", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + IconButton(onClick = onSend) { + Icon( + MaterialSymbols.AutoMirrored.Send, + contentDescription = "Publish draft now", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = onDelete) { + Icon( + MaterialSymbols.Delete, + contentDescription = "Delete draft", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +private const val TAG = "NoteDraftsList" + +/** + * Build, sign, and immediately publish a plain text note from a draft's body — the same + * template/signer/relay resolution the composer's publish path uses (see + * `ComposeNoteDialog.publishNote`). Publishing goes to the currently connected relays. + * + * Signing is a suspend call that can fail when a remote NIP-46 signer is offline; the + * exception propagates so the caller keeps the draft on failure and only deletes it on + * success. + */ +private suspend fun publishDraftNow( + content: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, +) { + withContext(Dispatchers.IO) { + if (account.isReadOnly) { + throw IllegalStateException("Cannot post in read-only mode") + } + + val template = + TextNoteEvent.build(content) { + hashtags(findHashtags(content)) + references(findURLs(content)) + } + + val signedEvent = account.signer.sign(template) + val relays = relayManager.connectedRelays.value + relayManager.publish(signedEvent, relays) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/ScheduledPostsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/ScheduledPostsScreen.kt new file mode 100644 index 0000000000..6db1137722 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduledposts/ScheduledPostsScreen.kt @@ -0,0 +1,386 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.scheduledposts + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.UrlParser +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus +import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.util.timeAbsolute +import com.vitorpamplona.amethyst.desktop.service.scheduledposts.LocalScheduledPostStore +import com.vitorpamplona.amethyst.desktop.ui.readingHorizontalPadding +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.launch + +/** + * Management list for scheduled posts belonging to [accountPubkeyHex]. Reads + * reactively off the shared [ScheduledPostStore.flow] provided via + * [LocalScheduledPostStore], so cancel / publish-now update the list live. + * + * Rows are grouped by lifecycle: PENDING/PUBLISHING (upcoming) first, then the + * terminal SENT/FAILED/CANCELLED rows; within each group they are ordered by the + * scheduled publish time ascending. + */ +@Composable +fun ScheduledPostsList( + accountPubkeyHex: String, + onEditInComposer: (content: String, draftDTag: String?, scheduledForSec: Long?) -> Unit = { _, _, _ -> }, +) { + val store = LocalScheduledPostStore.current + val allPosts by store.flow.collectAsState() + val scope = rememberCoroutineScope() + + val myPosts = + remember(allPosts, accountPubkeyHex) { + allPosts.filter { it.accountPubkey == accountPubkeyHex } + } + + val ordered = + remember(myPosts) { + myPosts.sortedWith( + compareBy { if (it.status.isTerminal()) 1 else 0 } + .thenBy { it.publishAtSec }, + ) + } + + if (ordered.isEmpty()) { + EmptyState( + title = "No scheduled posts", + description = "Posts you schedule from the composer will appear here.", + ) + return + } + + val sidePadding = readingHorizontalPadding() + LazyColumn( + contentPadding = PaddingValues(horizontal = sidePadding, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(ordered, key = { it.id }) { post -> + ScheduledPostCard( + post = post, + onPublishNow = { scope.launch { store.publishNow(post.id, TimeUtils.now()) } }, + onCancel = { scope.launch { store.cancel(post.id) } }, + onEdit = { + // Reopen the composer prefilled with this post's content and its original + // time (so the primary button reads "Schedule"), then cancel the old row. + // The user tweaks and re-queues from the composer. + val content = + Event + .fromJsonOrNull(post.signedEventJson) + ?.content + .orEmpty() + onEditInComposer(content, null, post.publishAtSec) + scope.launch { store.cancel(post.id) } + }, + ) + } + } +} + +@Composable +private fun ScheduledPostCard( + post: ScheduledPost, + onPublishNow: () -> Unit, + onCancel: () -> Unit, + onEdit: () -> Unit, +) { + val fullContent = + remember(post.signedEventJson) { + Event + .fromJsonOrNull(post.signedEventJson) + ?.content + ?.trim() + .orEmpty() + } + // Pull out image URLs so we can show a thumbnail instead of a raw Blossom link, + // and strip them from the text preview. + val imageUrls = + remember(fullContent) { + UrlParser().parseValidUrls(fullContent).withScheme.filter { RichTextParser.isImageUrl(it) } + } + val textPreview = + remember(fullContent, imageUrls) { + imageUrls.fold(fullContent) { acc, url -> acc.replace(url, "") }.trim() + } + + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(16.dp).fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + StatusChip(post.status) + RowActions( + post = post, + onPublishNow = onPublishNow, + onCancel = onCancel, + onEdit = onEdit, + ) + } + + Spacer(Modifier.height(8.dp)) + + if (textPreview.isNotBlank() || imageUrls.isEmpty()) { + Text( + text = textPreview.ifBlank { "(no text)" }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + if (imageUrls.isNotEmpty()) { + if (textPreview.isNotBlank()) Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + imageUrls.take(3).forEach { url -> + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(96.dp).clip(RoundedCornerShape(8.dp)), + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + MaterialSymbols.Schedule, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + Text( + text = scheduleLabel(post.publishAtSec), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (post.status == ScheduledPostStatus.FAILED) { + Spacer(Modifier.height(6.dp)) + Text( + text = "Failed after ${post.attemptCount} attempt(s): ${post.lastError ?: "unknown error"}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun RowActions( + post: ScheduledPost, + onPublishNow: () -> Unit, + onCancel: () -> Unit, + onEdit: () -> Unit, +) { + // Actions only apply to rows the user can still act on. + val canPublishNow = post.status == ScheduledPostStatus.PENDING || post.status == ScheduledPostStatus.FAILED + val canCancel = canPublishNow + val canEdit = post.status == ScheduledPostStatus.PENDING + if (!canPublishNow && !canCancel && !canEdit) return + + var menuOpen by remember { mutableStateOf(false) } + + Box { + IconButton(onClick = { menuOpen = true }, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.MoreVert, + contentDescription = "Actions", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (canPublishNow) { + DropdownMenuItem( + text = { Text("Publish now") }, + onClick = { + menuOpen = false + onPublishNow() + }, + leadingIcon = { + Icon( + MaterialSymbols.AutoMirrored.Send, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + ) + } + if (canEdit) { + DropdownMenuItem( + text = { Text("Edit (cancels schedule)") }, + onClick = { + menuOpen = false + // Reopens the composer prefilled with this post's content + time and + // cancels the old row; the user tweaks and re-queues from the composer. + onEdit() + }, + leadingIcon = { + Icon( + MaterialSymbols.Edit, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + ) + } + if (canCancel) { + DropdownMenuItem( + text = { Text("Cancel", color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + onCancel() + }, + leadingIcon = { + Icon( + MaterialSymbols.Cancel, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) + }, + ) + } + } + } +} + +@Composable +private fun StatusChip(status: ScheduledPostStatus) { + val (label, bg, fg) = + when (status) { + ScheduledPostStatus.PENDING -> + Triple("Scheduled", MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer) + ScheduledPostStatus.PUBLISHING -> + Triple("Publishing…", MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer) + ScheduledPostStatus.SENT -> + Triple("Sent", MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.colorScheme.onSecondaryContainer) + ScheduledPostStatus.FAILED -> + Triple("Failed", MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer) + ScheduledPostStatus.CANCELLED -> + Triple( + "Cancelled", + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Box( + modifier = + Modifier + .background(bg, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = fg, + ) + } +} + +private fun ScheduledPostStatus.isTerminal(): Boolean = + this == ScheduledPostStatus.SENT || + this == ScheduledPostStatus.FAILED || + this == ScheduledPostStatus.CANCELLED + +/** + * Absolute time (e.g. "May 28, 2:32 PM") plus a coarse relative hint. Unlike the + * shared past-oriented `timeAgo`, this renders future offsets like "in 3h". + */ +private fun scheduleLabel(publishAtSec: Long): String { + val absolute = timeAbsolute(publishAtSec, withDot = false) + val relative = relativeFuture(publishAtSec) + return if (relative != null) "$absolute ($relative)" else absolute +} + +private fun relativeFuture(publishAtSec: Long): String? { + val diff = publishAtSec - TimeUtils.now() + if (diff <= 0L) return "due" + return when { + diff < TimeUtils.ONE_HOUR -> "in ${(diff / TimeUtils.ONE_MINUTE).coerceAtLeast(1L)}m" + diff < TimeUtils.ONE_DAY -> "in ${diff / TimeUtils.ONE_HOUR}h" + else -> "in ${diff / TimeUtils.ONE_DAY}d" + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtButton.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtButton.kt new file mode 100644 index 0000000000..d3f9b275f4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtButton.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.scheduling + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols + +/** + * Clock toggle that turns scheduling on/off for the composer. Highlighted when a + * schedule is active. Adapted from the Android `ScheduleAtButton`. + */ +@Composable +fun DesktopScheduleAtButton( + isActive: Boolean, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + symbol = MaterialSymbols.Schedule, + contentDescription = if (isActive) "Remove schedule" else "Schedule for later", + modifier = Modifier.size(20.dp), + tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtPicker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtPicker.kt new file mode 100644 index 0000000000..a6ae186dbd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtPicker.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.scheduling + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.DatePicker +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.quartz.utils.TimeUtils +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +private val DISPLAY_FORMAT: DateTimeFormatter = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) + .withLocale(Locale.getDefault()) + +private fun formatScheduledFor(epochSec: Long): String = + Instant + .ofEpochSecond(epochSec) + .atZone(ZoneId.systemDefault()) + .format(DISPLAY_FORMAT) + +/** + * Millis that Material3's [DatePicker] should be seeded with so that the highlighted + * day is the *local* calendar day of [epochSec]. + * + * The DatePicker treats its `initialSelectedDateMillis` as a UTC instant and displays + * the UTC calendar day. Passing `epochSec*1000` directly (a local instant reinterpreted + * as UTC) shifts the shown day back by one in positive-offset zones (UTC+), so a + * confirm-without-changing-the-date would schedule ~24h early. Instead we compute the + * UTC-midnight of the local date, which the picker then renders as that same date. + * + * The confirm math in [DateTimePickerDialog] is the exact inverse: it reads + * `selectedDateMillis` (UTC-midnight), adds the hour/minute, then subtracts the + * system-default offset — so open→confirm-untouched round-trips to the same local day. + */ +internal fun datePickerInitialMillis(epochSec: Long): Long = + Instant + .ofEpochSecond(epochSec) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .atStartOfDay(ZoneOffset.UTC) + .toEpochSecond() * 1000 + +/** + * Desktop scheduling section. Shows quick presets and an inline summary row; tapping + * the summary opens a two-stage date + time [Dialog]. The chosen instant is rounded + * up to the next quarter-hour to match the periodic drain cadence. Adapted from the + * Android `ScheduleAtPicker` (which used Material3 dialog scaffolds unavailable in the + * same form on Desktop, so a plain [Dialog] is used here per Desktop dialog guidance). + */ +@Composable +fun DesktopScheduleAtPicker( + scheduledForSec: Long, + onChanged: (Long) -> Unit, +) { + var showPicker by remember { mutableStateOf(false) } + + Column(Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + ) { + Icon( + symbol = MaterialSymbols.Schedule, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Schedule", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = "Your note will be published from this app at the scheduled time (fires within about a minute while the app is open). The app must be running.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + + PresetChips(onPick = onChanged) + + OutlinedCard( + onClick = { showPicker = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(MaterialSymbols.Schedule, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(12.dp)) + if (scheduledForSec < TimeUtils.now()) { + Text("Pick a date and time", style = MaterialTheme.typography.bodyLarge) + } else { + Text( + text = "Publishes ${formatScheduledFor(scheduledForSec)}", + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + + if (showPicker) { + DateTimePickerDialog( + initialSec = if (scheduledForSec >= TimeUtils.now()) scheduledForSec else sanitizeScheduleTime(presetInOneHour()), + onDismiss = { showPicker = false }, + onConfirm = { + onChanged(it) + showPicker = false + }, + ) + } +} + +@Composable +private fun PresetChips(onPick: (Long) -> Unit) { + val scroll = rememberScrollState() + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(scroll) + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AssistChip( + onClick = { onPick(sanitizeScheduleTime(presetInOneHour())) }, + label = { Text("In 1 hour") }, + ) + AssistChip( + onClick = { onPick(sanitizeScheduleTime(presetTomorrowMorning())) }, + label = { Text("Tomorrow 9 AM") }, + ) + AssistChip( + onClick = { onPick(sanitizeScheduleTime(presetNextMondayMorning())) }, + label = { Text("Next Monday 9 AM") }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DateTimePickerDialog( + initialSec: Long, + onDismiss: () -> Unit, + onConfirm: (Long) -> Unit, +) { + var stage by remember { mutableStateOf(Stage.DATE) } + + val currentTime = + Instant + .ofEpochSecond(initialSec) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = datePickerInitialMillis(initialSec), + yearRange = currentTime.year..2050, + selectableDates = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86_400_000 + }, + ) + + val timePickerState = + rememberTimePickerState( + initialHour = currentTime.hour, + initialMinute = currentTime.minute, + is24Hour = false, + ) + + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.padding(16.dp)) { + Column(modifier = Modifier.padding(24.dp)) { + when (stage) { + Stage.DATE -> { + DatePicker(state = datePickerState) + Spacer(Modifier.size(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = onDismiss) { Text("Cancel") } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = { stage = Stage.TIME }) { Text("Next") } + } + } + + Stage.TIME -> { + TimePicker(state = timePickerState) + Spacer(Modifier.size(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = { stage = Stage.DATE }) { Text("Back") } + Spacer(Modifier.width(8.dp)) + TextButton( + onClick = { + val dayMillisUtc = datePickerState.selectedDateMillis ?: (TimeUtils.oneDayAhead() * 1000) + val datetimeLocalSec = + (dayMillisUtc / 1000) + + (timePickerState.hour * TimeUtils.ONE_HOUR) + + (timePickerState.minute * TimeUtils.ONE_MINUTE) + val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) + val rawSec = datetimeLocalSec - offset.totalSeconds + onConfirm(sanitizeScheduleTime(rawSec)) + }, + ) { Text("Confirm") } + } + } + } + } + } + } +} + +private enum class Stage { + DATE, + TIME, +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopSchedulePresets.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopSchedulePresets.kt new file mode 100644 index 0000000000..75179b99ad --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopSchedulePresets.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.scheduling + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters + +// Pure scheduling helpers adapted from the Android +// `ui.note.creators.scheduling.ScheduleAtPicker`. Kept UI-free so both the +// Desktop picker and any test can reuse them. + +/** Now + one hour, in epoch seconds. */ +fun presetInOneHour(): Long = (System.currentTimeMillis() / 1000) + 3600 + +/** Tomorrow at 09:00 local time, in epoch seconds. */ +fun presetTomorrowMorning(): Long { + val zone = ZoneId.systemDefault() + val tomorrow9am = LocalDate.now(zone).plusDays(1).atTime(LocalTime.of(9, 0)) + return tomorrow9am.atZone(zone).toEpochSecond() +} + +/** Next Monday at 09:00 local time (always at least one day ahead), in epoch seconds. */ +fun presetNextMondayMorning(): Long { + val zone = ZoneId.systemDefault() + val target = + LocalDate + .now(zone) + .plusDays(1) + .with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY)) + .atTime(LocalTime.of(9, 0)) + return target.atZone(zone).toEpochSecond() +} + +/** + * Normalizes a chosen schedule time to whole-minute precision (drops seconds) and + * guarantees it is strictly in the future. Unlike Android — which rounds to the next + * quarter-hour because WorkManager fires no more often than every 15 min — the + * Desktop publisher runs a 45s in-app tick (and a ~5-min OS tick when closed), so we + * honor the exact minute the user picks (e.g. 15:58 stays 15:58). + */ +fun sanitizeScheduleTime(epochSec: Long): Long { + val minute = 60L + val floored = (epochSec / minute) * minute + val nowSec = System.currentTimeMillis() / 1000 + return if (floored <= nowSec) ((nowSec / minute) * minute) + minute else floored +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtPickerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtPickerTest.kt new file mode 100644 index 0000000000..fe6fb98291 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/scheduling/DesktopScheduleAtPickerTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.scheduling + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopScheduleAtPickerTest { + /** + * Reconstructs the confirm math from [DateTimePickerDialog]: the DatePicker's + * selectedDateMillis is UTC-midnight; add the chosen hour/minute (local wall-clock), + * then subtract the system-default offset to get the epoch-second of that local + * date+time. This mirrors the production code so a round-trip is exercised end-to-end. + */ + private fun confirmSec( + pickerMillis: Long, + hour: Int, + minute: Int, + zone: ZoneId, + ): Long { + val datetimeLocalSec = (pickerMillis / 1000) + (hour * 3600L) + (minute * 60L) + val offset: ZoneOffset = zone.rules.getOffset(Instant.now()) + return datetimeLocalSec - offset.totalSeconds + } + + @Test + fun seed_millis_lands_on_the_local_calendar_day() { + // A UTC+ zone is where the naive `epochSec*1000` seed shifts the day back by one. + val zone = ZoneId.of("Australia/Sydney") // UTC+10/+11 + // 2026-06-01 08:30 local -> epoch second. + val localDateTime = LocalDate.of(2026, 6, 1).atTime(8, 30) + val epochSec = localDateTime.atZone(zone).toEpochSecond() + + val seed = datePickerInitialMillisForZone(epochSec, zone) + + // The picker interprets the seed as a UTC calendar day; it must be 2026-06-01. + val shownDate = + Instant + .ofEpochMilli(seed) + .atZone(ZoneOffset.UTC) + .toLocalDate() + assertEquals(LocalDate.of(2026, 6, 1), shownDate) + } + + @Test + fun round_trip_open_confirm_untouched_preserves_the_day() { + val zone = ZoneId.of("Australia/Sydney") + val localDateTime = LocalDate.of(2026, 6, 1).atTime(8, 30) + val epochSec = localDateTime.atZone(zone).toEpochSecond() + + val seed = datePickerInitialMillisForZone(epochSec, zone) + // Confirm untouched: same day, and the time the TimePicker was seeded with (8:30). + val confirmed = confirmSec(seed, hour = 8, minute = 30, zone = zone) + + val confirmedLocalDate = + Instant + .ofEpochSecond(confirmed) + .atZone(zone) + .toLocalDate() + assertEquals( + LocalDate.of(2026, 6, 1), + confirmedLocalDate, + "confirming without changing the date must keep the same local day", + ) + } + + // Zone-parameterised twin of the production helper so the test is deterministic + // regardless of the machine's default zone. + private fun datePickerInitialMillisForZone( + epochSec: Long, + zone: ZoneId, + ): Long = + Instant + .ofEpochSecond(epochSec) + .atZone(zone) + .toLocalDate() + .atStartOfDay(ZoneOffset.UTC) + .toEpochSecond() * 1000 +} diff --git a/docs/plans/2026-07-10-feat-desktop-note-scheduling-plan.md b/docs/plans/2026-07-10-feat-desktop-note-scheduling-plan.md new file mode 100644 index 0000000000..3b58d9d28a --- /dev/null +++ b/docs/plans/2026-07-10-feat-desktop-note-scheduling-plan.md @@ -0,0 +1,569 @@ +--- +title: Desktop Note Scheduling & "Drafts & Scheduled" Screen +type: feat +status: completed +date: 2026-07-10 +origin: docs/brainstorms/2026-07-09-feat-desktop-note-scheduling-brainstorm.md +--- + +# ✨ Desktop Note Scheduling & "Drafts & Scheduled" Screen + +## Overview + +Bring **note scheduling** and a unified **"Drafts & Scheduled"** screen to Amethyst +**Desktop**. A clock icon in the composer opens a date/time picker; scheduled notes +are **pre-signed** at schedule time and stored locally, then published at their +scheduled moment by an **OS-level job** so they fire even when the desktop app is +fully closed. A single sidebar destination lists Drafts and Scheduled notes with +manage actions. + +Amethyst **Android already ships full scheduling + NIP-37 drafts** — this is a +Desktop port that **extracts** Android's pure logic into `commons/`, replaces +Android's WorkManager background layer with an OS-scheduler + `amy` publisher, and +adds Desktop-native UI. (see brainstorm: `docs/brainstorms/2026-07-09-feat-desktop-note-scheduling-brainstorm.md`) + +## Problem Statement / Motivation + +Desktop users cannot schedule notes or manage drafts from a composer. Android has +had this since the `scheduledposts/` service landed. The gap is Desktop-only UI + +a Desktop background-publish mechanism (Desktop has no WorkManager equivalent). + +The **crux insight**: because the scheduled event is pre-signed and its JSON is +stored, the process that fires at time T is a *dumb pipe* — it opens a websocket +and pushes already-signed bytes. It **never touches the signing key**. This makes +an OS-level "publish even when app is closed" mechanism safe and small. + +## Proposed Solution + +1. **Extract** Android's pure scheduling code (`ScheduledPost`, `ScheduledPostStore`, + the drain/publish loop, time-preset utils, `ScheduleAtButton/Picker` logic) into + `commons/` so Desktop, Android, and `amy` share one implementation. +2. **Compose integration** (Desktop): add a clock icon + `ScheduleAtPicker` to + `ComposeNoteDialog`. On "schedule", pre-sign the event (block if a bunker signer + is offline), store it locally. +3. **Publish mechanism** (hybrid, lifecycle-managed): + - **In-app timer** fires due posts instantly while the app is open (re-resolves + write relays via NIP-65 outbox). + - **OS-level recurring job** (every ~5 min → `amy publish-scheduled`) is + **registered when the queue goes 0→1 and cancelled when it drains to 0**. + Fires when the app is closed. Both paths dedup via the store's atomic + `claimDuePosts()`. +4. **`amy publish-scheduled`** — new headless, key-free `cli` subcommand that drains + due pre-signed posts and publishes them. +5. **"Drafts & Scheduled" screen** — one Desktop sidebar destination, two tabs + (Drafts | Scheduled), rows with preview/time/status and actions. +6. **Overdue posts auto-publish** on next app launch / next OS-job tick. + +### Key decisions carried from brainstorm + +| Decision | Choice | Source | +|----------|--------|--------| +| Target | Desktop port (Android = reference) | brainstorm §Key Decisions | +| Publish model | OS-level, lifecycle-managed single recurring job + in-app timer | brainstorm §1 | +| Publisher | `amy publish-scheduled` (key-free, pre-signed) | brainstorm §2 | +| Signing | Pre-sign at schedule time | brainstorm §3 | +| Bunker offline at schedule | **Block with a message** | brainstorm §Resolved | +| Overdue posts | **Auto-publish** on next launch/tick | brainstorm §4 | +| Drafts storage | Local + **opt-in NIP-37 sync** | brainstorm §5 | +| Relay drift | **Re-resolve at publish** (best-effort; see wrinkle below) | brainstorm §Resolved | +| OS coverage v1 | **All three** (macOS launchd / Windows schtasks / Linux systemd) | brainstorm §6 | +| Screen shape | **One screen, two tabs** | brainstorm §7 | +| Editing | **Cancel + recompose** | brainstorm §8 | +| Draft sync UX | One list, **small "synced" badge** | brainstorm §Resolved | + +--- + +## Locked Decisions (post-deepen, user-confirmed 2026-07-10) + +- **Single all-in PR** — implement all phases together (user chose against staging). + The 3-PR table below is retained only as a risk map / suggested commit sequence + within the one PR. +- **App-closed mechanism = thin OS trigger → headless mode of the desktop binary.** + The OS job (launchd/schtasks/systemd) launches the desktop app in a + `--publish-scheduled` headless mode that reuses the commons publisher against the + same `~/.amethyst/` store. **Not** the separate `amy` binary (no data-dir mismatch, + no second bundle, no keychain-on-tick). `amy publish-scheduled` is out of scope for + this PR (may be added later as CLI parity). +- **Single-writer discipline** guards the store: the GUI in-app timer and the + OS-triggered headless process must never drain concurrently (lockfile/heartbeat so + one yields). Keeps the JSON store; SQLite migration not required. Still add the + `CLAIM_TTL` stuck-`PUBLISHING` recovery + account-scoped claim in Phase 0. + +## Enhancement Summary (deepened 2026-07-10) + +Deepened with 8 parallel research/review agents (architecture, security, simplicity, +KMP-placement, amy-CLI, cross-process-store, Compose-picker, OS-scheduler). **Three +independent reviewers (architecture, security, simplicity) converged on the same +verdict: the extraction is sound, but the two-process OS-publisher design is the +risky, over-engineered pillar.** Key changes below; details in "Research Insights". + +### Key improvements +1. **Staged into 3 PRs.** PR1 (Phases 0–2 + Scheduled tab) delivers the core value + with ~zero HIGH risks. PR2 adds app-closed OS firing. PR3 adds NIP-37 draft sync. +2. **Publish mechanism reconsidered.** The recommended app-closed mechanism is now a + **thin OS trigger that launches a headless publish mode of the desktop binary** + (single-writer, same `~/.amethyst/` dir, real relay re-resolution, no second + bundled binary, no keychain reads on tick) — with `amy publish-scheduled` kept as + *optional* CLI/interop parity, not the production path. This collapses four HIGH + risks at once (cross-process race, data-dir mismatch, amy bundling, keychain + exposure). +3. **Two latent correctness bugs found in the existing store** (inherited by Android + too): (a) no cross-process safety in `claimDuePosts` (in-process `Mutex` only), (b) + **no timeout recovery for stuck `PUBLISHING` rows** → silent permanent drop. Both + must be fixed in Phase 0. +4. **KMP placement corrected** — Jackson + `java.io.File` are **gate-forbidden in + `commonMain`** (`verifyKmpPurity`). Store/publisher go in `jvmAndroid`, not + commonMain. `OsScheduler` `expect` in `jvmAndroid`, not commons/jvmMain. +5. **Security hardening added** to acceptance criteria (0600 perms, absolute/canonical + trigger path, argv-arrays not `sh -c`, strict plist/unit escaping, key-free path). + +### New considerations discovered +- Material3 `DatePicker`/`TimePicker` **do work on Compose Desktop** (CMP 1.11.0) — + adapt Android's picker inside a `Dialog` (not `AlertDialog`); no custom picker needed. +- `claimDuePosts` is **not account-scoped** — with a single multi-account file, the + amy/headless path could publish account B's event under account A's relays. Add an + account-scoped claim. +- Relays commonly **reject `created_at` too far in the future** — pre-signing next + week's post may be refused at publish; handle as FAILED, and bound stale-overdue + auto-publish (don't blast weeks-old content silently). + +> **The phase details below are superseded by the "Research Insights & Revised Plan" +> section wherever they conflict.** The original phases are kept for provenance. + +## Reuse / Extract / New Matrix + +| File/Component | Status | Location | Action | +|----------------|--------|----------|--------| +| `ScheduledPost` model + `ScheduledPostStatus` | 📦 Extract (PURE) | `amethyst/.../service/scheduledposts/` | Move to `commons/commonMain` | +| `ScheduledPostStore` (Jackson+Mutex+atomic, StateFlow) | 📦 Extract | same | Move to **`commons/jvmAndroid`** (Jackson+File gate-forbidden in commonMain); inject `File`, NO expect/actual. Add `CLAIM_TTL` recovery + account-scoped claim. | +| Drain/publish loop (from `ScheduledPostWorker`) | 📦 Extract logic | same | New `ScheduledPostPublisher` in **`commons/jvmAndroid`**; call quartz `publishAndConfirmDetailed` (drop `waitForOk`) | +| `INostrClient.publishAndConfirmDetailed()` | ✅ Reuse | `quartz/.../accessories/NostrClientPublishExt.kt` | Key-free publish+confirm (commonMain) | +| `ScheduleAtButton` / `ScheduleAtPicker` | 📦 Extract UI + presets | `amethyst/.../creators/scheduling/` | Presets/rounding → commons; picker to commons Compose | +| `roundUpToNextQuarterHour`, preset fns | 📦 Extract (PURE) | `ScheduleAtPicker.kt` | Move to `commons` util | +| `DraftWrapEvent` (NIP-37, kind 31234) | ✅ Reuse | `quartz/.../nip37Drafts/` | commonMain; usable from Desktop as-is | +| `ScheduledPostWorker` (WorkManager) | ⚠️ Android-only | `amethyst/...` | Keep Android; Desktop uses OS scheduler | +| `ScheduledPostNotifier` | ⚠️ Android-only | `amethyst/...` | `expect/actual` notifier; Desktop = tray/log | +| `ComposeNoteDialog` (composer) | 🆕 Extend | `desktopApp/.../ui/ComposeNoteDialog.kt` | Add clock icon + picker + schedule path | +| `DeckColumnType` (nav) | 🆕 Extend | `desktopApp/.../ui/deck/DeckColumnType.kt` | Add/rename destination for Drafts & Scheduled | +| `DesktopDraftStore` (local, article-oriented) | ⚠️ Reconcile | `desktopApp/.../service/drafts/` | Reuse pattern; add short-note drafts + opt-in NIP-37 | +| `DesktopHighlightStore` (JSON+Mutex+atomic) | ✅ Reuse pattern | `desktopApp/.../service/highlights/` | Template for `DesktopScheduledPostStore` wiring | +| `iAccount.nip65RelayList.outboxFlow` | ✅ Reuse | `desktopApp/.../model/DesktopIAccount.kt` | Write-relay re-resolution (in-app path) | +| OS scheduler (launchd/schtasks/systemd) | 🆕 New (PR2) | `expect` in `commons/jvmAndroid`, actual `jvmMain` (or `desktopApp`) | NOT commonMain (no iOS stub); Desktop-only registration; macOS-first | +| `amy publish-scheduled` subcommand | 🆕 New (PR2, **optional**) | `cli/.../commands/` + `Main.kt` dispatch | Verb group `scheduled run\|list\|publish-now\|cancel` + `--scheduled-file`; optional CLI parity, not the production path | + +## Research Insights & Revised Plan (deepen-plan) + +### Revised staging (supersedes the single-PR phase list) + +| PR | Scope | Risk | Delivers | +|----|-------|------|----------| +| **PR1** | Phase 0 (extract + fix store bugs) + Phase 1 (composer schedule) + Phase 2 (in-app timer + launch catch-up) + Scheduled tab | ~zero HIGH | "Schedule a note; it publishes at its time" — including the laptop-closed-overnight-reopened-next-morning case, which covers the large majority of real usage. | +| **PR2** | App-closed OS firing (thin trigger → headless publish mode), macOS-first behind the abstraction | isolates all HIGH risks | Fires even if the app is never reopened around the scheduled time. | +| **PR3** (optional) | NIP-37 opt-in draft sync (synced badge, dTag dedup) | MED | Cross-device drafts. Orthogonal to scheduling. | + +Rationale (simplicity review): every HIGH risk in this plan lives in the OS/amy +layer. Shipping PR1 first gets ~90% of the value at ~10% of reviewer/maintainer cost — +which matters doubly for a first upstream FOSS PR. + +### CRITICAL / HIGH findings to fix before/within Phase 0 + +- **Cross-process store race (arch C1 / sec H3).** `ScheduledPostStore.claimDuePosts()` + guards with an **in-process `Mutex` only** — zero cross-process mutual exclusion. + Two JVMs (app timer + OS-fired publisher) can both claim the same PENDING rows → + **duplicate publish** or lost status write. Resolution options, best-first: + 1. **Single-writer discipline (recommended for PR2):** OS trigger launches the + desktop binary's headless publish mode; a lockfile/heartbeat ensures the GUI + timer and the headless drain never run concurrently. Removes the race by + construction (only one drainer alive at a time), same `~/.amethyst/` dir. + 2. **Switch the store to SQLite-WAL (cross-process-store research):** the repo + already runs SQLite cross-process-correctly (`SQLiteEventStore`, WAL + + `busy_timeout`, `BundledSQLiteDriver`, no JNI). `claimDuePosts` becomes one + `BEGIN IMMEDIATE; UPDATE … WHERE status='PENDING' AND publish_at<=?` — true ACID, + lease recovery is a one-line `WHERE`. **Use `PRAGMA synchronous=NORMAL`, NOT the + `OFF` that `SQLiteEventStore` uses** (a lost SENT row = double-publish). This is + the robust option if concurrent drain must be allowed. + 3. File lock (`FileChannel.lock()` on a sibling `.lock`, held across claim+persist, + both paths) — correct but fragile (per-JVM semantics, NFS, stale locks). Least + preferred. +- **Stuck `PUBLISHING` → silent permanent drop (arch C2 / sec H4).** There is **no + timeout auto-recovery** today; `releaseClaim` only fires when the account isn't + loaded. A crash/sleep between claim and ack strands the row in `PUBLISHING` forever + (never re-claimed, never purged, never surfaced) — the worst outcome for a + scheduling feature. **Fix in Phase 0 (benefits Android too):** on load/claim, revert + any `PUBLISHING` row with `now - lastAttemptAtSec > CLAIM_TTL` (e.g. 10 min) to + `PENDING`. Add a test. This makes Open Question #3's assumed recovery real. +- **Account-scoped claim (amy-expert).** Add `claimDuePosts(nowSec, accountPubkey)` (or + filter in the publisher). Otherwise the single multi-account file lets the + headless/amy path publish account B's pre-signed event using account A's outbox. + +### KMP placement — corrected (kotlin-multiplatform skill, verified against `verifyKmpPurity`) + +Jackson and `java.io.File` are **forbidden in `commonMain`** by the live purity gate +(`commons/build.gradle.kts:223-232`). Jackson reaches `commons` only transitively via +quartz's `jvmAndroid` `api`. Corrected layout: + +``` +commons/src/commonMain/.../scheduledposts/ + ScheduledPost.kt (model + enum + file DTO) — PURE, commonMain OK + ScheduleTimePresets.kt (roundUpToNextQuarterHour, presets) — PURE (use TimeUtils.now, NOT System.currentTimeMillis) +commons/src/jvmAndroid/.../scheduledposts/ + ScheduledPostStore.kt (Jackson + File + Mutex + StateFlow) — inject the File, NO expect/actual + ScheduledPostPublisher.kt (drain → INostrClient.publishAndConfirmDetailed) + ScheduledPostNotifier.kt (expect class — declared in jvmAndroid so iOS needs no stub) + OsScheduler.kt (expect class — declared in jvmAndroid) +commons/src/androidMain/.../scheduledposts/ ScheduledPostNotifier.kt (WorkManager), OsScheduler.kt +commons/src/jvmMain/.../scheduledposts/ ScheduledPostNotifier.kt (tray/log), OsScheduler.kt (launchd/schtasks/systemd) +``` + +- Store + publisher live in **`jvmAndroid`** (both JVM Desktop & Android share it + verbatim). Do **not** abstract the file path behind expect/actual — inject the + resolved `File` (over-abstraction). No `iosMain` files needed for any scheduling + artifact. +- **Sequence the Android refactor (arch M4):** 0a pure move + re-point Android (tests + green) → 0b swap `waitForOk`→`publishAndConfirmDetailed` (re-verify "any relay + acked = SENT") → 0c add claim-staleness sweep + test. Keep commits bisectable. +- **Kill the `waitForOk` extraction (arch H3):** both platforms call the quartz + `publishAndConfirmDetailed` primitive; don't port Android's `pendingPublishRelaysFor` + polling into commons. + +### OsScheduler placement (arch H2) + +`OsScheduler` writes plist/unit/task files and shells out — it must NOT sit in +`commons/jvmMain` if that would put OS-orchestration on amy's CLI-safe classpath. Per +the KMP finding, declare the `expect` in **`jvmAndroid`** (actual in `jvmMain` for +Desktop OS-switch, `androidMain` = WorkManager). The Desktop-only registration logic +itself may equally live in `desktopApp/jvmMain` if it consumes only Desktop types — +decide by whether Android reuses the abstraction (it has WorkManager, so a `desktopApp` +home is also defensible). Either way: **not commonMain, no iOS stub.** + +### Relay re-resolution — fix the story (arch H1) + +A fresh `~/.amy/` has no kind:10002 for the account, so the amy path's +`ctx.outboxRelays()` re-resolution **never fires** for the app-closed case — it always +falls back to the stored snapshot. Better: **the app refreshes the stored `relayUrls` +snapshot whenever its outbox changes** (cheap, app-side, always current), so whatever +the headless/amy path reads is fresh. With the thin-trigger design (headless mode of +the desktop binary) this is moot — it reads the app's own live NIP-65 state. + +### Compose picker (research) — no custom picker needed + +Material3 `DatePicker`/`TimePicker` work on Compose Desktop (CMP 1.11.0, material3 +1.9.0). Adapt Android's `ScheduleAtPicker` inside a `Dialog` (or `DialogWindow` for a +roomier modal) — **not `AlertDialog`** (project memory: subscriptions/state issues in +AlertDialog on Desktop). Reuse `rememberDatePickerState`/`rememberTimePickerState`, +keep the timezone + rounding logic verbatim. Split the matrix row: pure time utils = +clean 📦 extract; picker composable = adapt-to-Desktop (verify parity), closer to 🆕. + +### Security hardening (security review) — fold into acceptance criteria + +- Store file + any scheduler files: **`0600`**; refuse to write if the parent dir is + group/world-writable. (`DesktopDraftStore` sets 0600 — copy it; `DesktopHighlightStore` + sets none — do NOT copy that.) +- OS trigger command: **absolute, canonicalized path inside the app bundle**, never a + bare name / `PATH` lookup (else a poisoned `~/.local/bin/amy` = durable user-level + RCE on a 5-min timer). Verify the path is non-writable by others before emitting it. +- Generate plist/unit/task via **strict escaping / argv arrays, never `sh -c` string + concat**; reject control chars / newlines in any interpolated path (injection). + Reuse the repo's existing argv-array subprocess pattern (`security`/`secret-tool`/ + `gsettings`). +- **Key-free path must read zero secrets:** amy's `Context` eagerly builds a signer + from the keychain today — the publish path must use a key-free `Context`/`Identity` + variant that never calls `keyPair().privKey`/`secrets.resolve`. Add a criterion: + "publish path performs zero keychain reads." +- Both drain paths **re-verify the signature** of the deserialized `signedEventJson` + and assert `event.pubKey == accountPubkey` before broadcast (tamper defense). +- NIP-37 sync: keep **default OFF**; document that kind 31234 leaks *that a draft of + kind N exists at time T for pubkey P* (content stays NIP-44 encrypted to self); 90-day + NIP-40 expiry is advisory only. + +## Technical Approach + +### Architecture + +``` +Compose (Desktop, ComposeNoteDialog) commons (shared) OS + ClockButton → ScheduleAtPicker ─┐ + pre-sign via account.signer ├─► ScheduledPostStore ───┐ register/cancel + (block if bunker offline) │ (commonMain: JSON, ├─► OsScheduler (expect/actual) + store.add(signedEventJson) ─┘ Mutex, claimDuePosts)│ launchd / schtasks / systemd + ▲ │ │ + In-app timer (app open) ────────────────────┘ │ ▼ every ~5 min WHEN queue>0 + ScheduledPostPublisher.drain() │ amy publish-scheduled (cli, key-free) + re-resolve outbox relays │ drain due → publishAndConfirmDetailed + "Drafts & Scheduled" screen ────────────────────┘ → markSent / markFailed + Drafts tab (local + NIP-37, synced badge) + Scheduled tab (status + edit/cancel/publish-now) +``` + +Store path (shared by Desktop app AND `amy`): +`~/.amethyst/scheduled/scheduled.json` (per-account keyed by `accountPubkey` field; +one file, filtered by account — mirrors Android's single-file store). + +> **Data-dir mismatch (must reconcile):** `amy` resolves its own data dir as +> `~/.amy//…` with its own `SecretStore`, independent of the desktop +> app's `~/.amethyst/`. So the OS job must point `amy` at the shared scheduled +> file explicitly — either a new `--scheduled-file PATH` flag on +> `publish-scheduled`, or a convention both agree on. `amy` does **not** need the +> desktop's keys (posts are pre-signed), but it does need read/write access to the +> shared scheduled store. Decide the mechanism in Phase 3. + +### Implementation Phases + +#### Phase 0 — Extract shared scheduling core to `commons/` +- Move `ScheduledPost.kt` (model + enum + `ScheduledPostFile`) to + `commons/commonMain/.../scheduledposts/`. PURE — no changes. +- Move `ScheduledPostStore.kt` to commons. Replace `java.io.File` constructor arg + with a KMP-friendly path/IO abstraction: + - `expect` a storage-path/file-writer, `actual` for `jvmAndroid` (both JVM & + Android are JVM → likely one `jvmAndroid` actual using `java.io.File`, plus + iOS stub if needed). Keep injected `nowSec: () -> Long`. +- Extract the drain/publish/`waitForOk` loop from `ScheduledPostWorker` into a pure + `ScheduledPostPublisher` (commons): + ```kotlin + // commons/commonMain/.../scheduledposts/ScheduledPostPublisher.kt + class ScheduledPostPublisher( + private val store: ScheduledPostStore, + private val client: INostrClient, + private val resolveRelays: (post: ScheduledPost) -> Set, + ) { + suspend fun drainDue(nowSec: Long): DrainReport { /* claim → publishAndConfirmDetailed → markSent/markFailed */ } + } + ``` + Use quartz `INostrClient.publishAndConfirmDetailed()` instead of Android's + hand-rolled `pendingPublishRelaysFor` poll. +- Extract `roundUpToNextQuarterHour` + preset generators into a commons util. +- **Refactor Android** `ScheduledPostWorker`/ViewModel to call the commons store + + publisher (no behavior change; Android tests stay green). +- `expect/actual` notifier interface: `ScheduledPostNotifier` (Android = current + impl; Desktop = tray notification or log). + +**Success:** Android build + existing Android scheduling tests green against the +extracted commons code. `:commons:jvmTest` + `:commons:compileKotlinJvm` pass. + +#### Phase 1 — Desktop store + composer scheduling +- `DesktopScheduledPostStore` wiring: instantiate the commons `ScheduledPostStore` + with the Desktop path (`~/.amethyst/scheduled/scheduled.json`), provide as a + CompositionLocal / app-level singleton in `Main.kt` (mirror `DesktopDraftStore`). +- Add clock icon to `ComposeNoteDialog` toolbar row (near `MediaAttachmentRow`); + `MaterialSymbols.Schedule` (already referenced? verify; regenerate subset font if + it's a new codepoint — see CLAUDE.md Icons rule). +- Add `scheduledForSec: Long?` state + `ScheduleAtPicker` (extracted) in the dialog. +- On Publish when `scheduledForSec != null`: + - Build the template, **re-stamp `createdAt = scheduledForSec`** (feed ordering), + `account.signer.sign(template)`. + - **Bunker offline → block**: if signer is remote (NIP-46) and signing fails/times + out, show an inline message ("Signer must be online to schedule") and abort — + do NOT store. (see brainstorm: Resolved / bunker offline) + - `store.add(ScheduledPost(... signedEventJson, relayUrls = outbox snapshot ...))`. +- No draft deletion coupling yet (Desktop composer draft autosave handled in Phase 4). + +**Success:** Scheduling a note writes a `PENDING` row; app compiles +(`:desktopApp:compileKotlin`), spotless clean. + +#### Phase 2 — In-app timer publisher +- App-level coroutine (in `Main.kt` scope) runs `ScheduledPostPublisher.drainDue()` + on a ticker (e.g. every 30–60s) while the app is open. +- `resolveRelays` for the in-app path = **re-resolve** `iAccount.nip65RelayList.outboxFlow.value` + for the post's account, falling back to the stored `relayUrls` snapshot if empty. +- On launch, run one **catch-up** drain → overdue posts auto-publish. (see brainstorm §4) + +**Success:** A post scheduled for ~1 min out publishes while the app is open; +overdue post publishes on relaunch. + +#### Phase 3 — OS-level scheduler + `amy publish-scheduled` +- `amy publish-scheduled` subcommand (`cli/.../commands/`, wired into `Main.kt` + `when(head)` dispatch): resolves the shared store path, `drainDue(now)`, publishes + via `publishAndConfirmDetailed`, marks status, exits 0/1. Honors `--json`. + - Key-free: reads `signedEventJson` — no signer needed. Uses `Context.publish()` + → `publishAndConfirmDetailed` (accepts pre-signed events). + - Relay resolution for the amy path: prefer amy's own `ctx.outboxRelays()` (reads + kind:10002 from its store, or a fresh network fetch) to honor "re-resolve at + publish"; fall back to the stored `relayUrls` snapshot if amy has no synced + kind:10002 for that account. (see Risks — divergence is now bounded, not total.) +- `expect/actual OsScheduler` (commons `jvmMain` or desktopApp): + - `fun ensureRegistered()` / `fun unregister()` — idempotent. + - **macOS**: write a `launchd` `~/Library/LaunchAgents/com.vitorpamplona.amethyst.scheduledposts.plist` + with `StartInterval` ~300s calling the bundled `amy publish-scheduled`; `launchctl load/unload`. + - **Windows**: `schtasks /create /sc minute /mo 5 …` invoking `amy.bat publish-scheduled`; `/delete` on unregister. + - **Linux**: `systemd --user` timer (`.timer` + `.service`) or crontab fallback. +- **Lifecycle management**: observe `store.flow`; when pending count goes 0→>0 call + `ensureRegistered()`, when it drains to 0 call `unregister()`. (see brainstorm §1 + user refinement) +- **Bundle `amy` with the desktop distribution** so the OS job has a stable path. + `amy` is a Gradle `application` (launcher `amy`/`amy.bat`, mainClass `…cli.MainKt`) + with its own jlink+jpackage bundle. Options (decide in impl): (a) add `:cli` to the + desktop jpackage image and resolve the launcher path at runtime, or (b) ship amy's + jlink image inside the desktop app resources. Path must survive app updates. + +**Success:** With the app closed, a due post publishes via the OS job on macOS +(dogfood platform); registration appears/disappears with queue transitions. + +#### Phase 4 — "Drafts & Scheduled" unified screen + draft sync +- Nav: extend `DeckColumnType` — rename `Drafts` destination presentation to + "Drafts & Scheduled" (keep `Drafts` object; add `ScheduledPosts` OR make the + existing Drafts screen a two-tab host). One sidebar entry, two tabs (Drafts | + Scheduled). (see brainstorm §7) +- **Scheduled tab**: list from `store.flow.listFor(account)`; each row = content + preview + scheduled time + status chip (PENDING/PUBLISHING/SENT/FAILED). Actions: + - **Publish now** → `store.publishNow(id)` (+ trigger drain). + - **Cancel** → `store.cancel(id)`. + - **Edit** → cancel + reopen content in `ComposeNoteDialog` as a draft to + re-schedule. (see brainstorm §8) + - FAILED rows show `lastError` + attemptCount and a retry (= publishNow). +- **Drafts tab**: reconcile with existing `DesktopDraftStore`. Add composer + "Save as draft" for short notes; **opt-in NIP-37 sync** toggle publishes the draft + as `DraftWrapEvent` (kind 31234). One list; synced rows get a small cloud badge; + same `dTag` dedups local+synced. (see brainstorm §5 / Resolved) + +**Success:** Screen lists both; all actions work; synced badge shows for NIP-37 +drafts. + +#### Phase 5 — Retry, cleanup, notifications, tests, docs +- Retry/backoff policy (reuse Android `attemptCount`); retention purge (SENT >7d, + CANCELLED >30d, FAILED kept) already in the store — verify on Desktop. +- Desktop notifier `actual`: system tray / OS notification on SENT/FAILED (optional; + can stub+log for v1). +- Tests: commons unit tests for store state machine, `claimDuePosts` atomicity, + publisher drain, preset/rounding utils. Manual testing sheet. +- Docs: update `desktopApp` plans; note the OS-job files created per platform. + +## Alternative Approaches Considered + +- **One OS job per post** (brainstorm Approach B): precise but create/cancel churn + and orphan risk. Rejected for lifecycle-managed single job. +- **In-app-only + catch-up** (brainstorm Approach A-lite): simplest but never fires + when the app is closed. Rejected — user explicitly wants app-closed firing. +- **Sign at publish time**: would force the OS job / amy to hold keys. Rejected — + breaks the key-free guarantee. (see brainstorm §3) + +## System-Wide Impact + +### Interaction Graph +`ComposeNoteDialog.Publish(scheduled)` → `signer.sign` → `store.add` → `store.flow` +emits → lifecycle observer → `OsScheduler.ensureRegistered()`. At fire time: OS job +→ `amy publish-scheduled` → `store.claimDuePosts` (flips PENDING→PUBLISHING) → +`publishAndConfirmDetailed` → `markSent/markFailed` → `store.flow` emits → if queue +now empty → `OsScheduler.unregister()`. Parallel in-app timer path claims the same +rows via the same atomic `claimDuePosts`, so only one path publishes each post. + +### Error & Failure Propagation +- Sign failure at schedule (bunker offline) → surfaced inline, nothing stored. +- Publish failure at fire time → `markFailed(id, error)`, row stays FAILED with + `lastError`; retryable from the Scheduled tab. +- Two-process write race (app + amy) → store must be safe across processes, not just + coroutines (see Risks). + +### State Lifecycle Risks +- A post claimed as PUBLISHING by amy, then amy crashes → `releaseClaim`/timeout must + return it to PENDING so it isn't stuck. Verify the store's claim has a recovery path. +- OS job registered but queue emptied by the in-app path → observer must still + `unregister()` (don't leak launchd/schtasks/systemd entries). + +### API Surface Parity +- Android composer path and Desktop composer path must produce identical + `ScheduledPost` rows (same re-stamp + relay snapshot semantics). +- `amy publish-scheduled` and the in-app timer must share `ScheduledPostPublisher`. + +### Integration Test Scenarios +1. Schedule → close app → OS job fires → note appears at scheduled `created_at`. +2. Schedule → keep app open → in-app timer fires before OS job; amy later finds + nothing due (claim already consumed). +3. Queue 0→1→0 registers then unregisters the OS job (inspect launchd/schtasks/systemd). +4. Overdue on relaunch auto-publishes. +5. Bunker offline at schedule → blocked, no row written. +6. Change write relays after scheduling → in-app path uses new outbox; amy path uses + stored snapshot (documented divergence). + +## Acceptance Criteria + +### Functional +- [ ] Clock icon in Desktop composer opens `ScheduleAtPicker` with presets. +- [ ] Scheduling pre-signs and stores a `PENDING` row; blocks if bunker offline. +- [ ] In-app timer publishes due posts while app is open; catch-up on launch + auto-publishes overdue posts. +- [ ] OS job registered on queue 0→1, cancelled on →0 (macOS/Windows/Linux). +- [ ] `amy publish-scheduled` drains + publishes pre-signed posts key-free (text + + `--json`, exit 0/1/2). +- [ ] "Drafts & Scheduled" sidebar screen with two tabs; Scheduled rows show + status + edit(cancel+recompose)/cancel/publish-now. +- [ ] Drafts tab shows local + opt-in NIP-37 synced drafts with a synced badge. + +### Non-Functional +- [ ] Publisher never accesses signing keys; **publish path performs zero keychain reads**. +- [ ] Store is safe against concurrent drains — no duplicate publish, no lost row + (test: concurrent double-drain → single publish). +- [ ] Stuck `PUBLISHING` rows auto-recover after `CLAIM_TTL` (test: crash-mid-publish → row returns to PENDING). +- [ ] `claimDuePosts` is account-scoped; a row publishes only under its own `accountPubkey`. +- [ ] Both drain paths re-verify signature + `event.pubKey == accountPubkey` before broadcast. +- [ ] Store file + scheduler files are `0600`; write refused if parent dir is group/world-writable. +- [ ] OS trigger uses an absolute, canonical, non-other-writable path (no `PATH` lookup); scheduler files built via argv-array/strict escaping, never `sh -c`. +- [ ] No orphaned OS-scheduler entries after queue drains / logout; registration reconciled idempotently on startup (level-triggered, not only Flow-edge-triggered). +- [ ] Future-`created_at` relay rejection handled as FAILED with a clear error; stale-overdue (> 24h) auto-publish is bounded, not silent. + +### Quality Gates +- [ ] `:commons:jvmTest`, `:desktopApp:compileKotlin`, Android build green. +- [ ] `./gradlew spotlessApply` clean. +- [ ] Manual testing sheet executed (Desktop macOS at minimum). + +## Success Metrics +- A note scheduled with the app closed publishes within one OS-tick (~5 min) of its + time. Zero duplicate publishes across in-app + OS paths. Zero leaked OS jobs. + +## Dependencies & Risks +- **Two-process store safety (HIGH):** Android's `ScheduledPostStore` uses an + in-process `Mutex` + atomic file rename. With amy and the app both writing, need + cross-process safety (file lock, or amy-only-writes-when-app-absent, or a lock + file). Must resolve in Phase 3. +- **amy bundling/path (HIGH):** OS job needs a stable amy executable path surviving + updates. Ties into desktop jpackage config. +- **Data-dir reconciliation (HIGH):** amy's `~/.amy//` ≠ desktop's + `~/.amethyst/`. OS job must point amy at the shared scheduled file (new flag or + convention). See Architecture note. +- **Relay re-resolution divergence (MED):** in-app path re-resolves via + `nip65RelayList.outboxFlow`; amy path re-resolves via `ctx.outboxRelays()` (needs + synced kind:10002) else falls back to snapshot. Bounded divergence, acceptable v1. +- **Compose Multiplatform Material3 pickers (MED):** `DatePicker`/`TimePicker` on + Desktop — verify parity or build a custom picker. +- **OS integration fragility (MED):** launchd/schtasks/systemd differences; macOS + is the dogfood target, others need testing. +- **Icon subset font (LOW):** new `MaterialSymbols.Schedule` codepoint requires + regenerating the subset font (CLAUDE.md rule). + +## Open Questions (resolve during implementation) +1. **PR2 mechanism (decide before PR2):** thin-trigger launching a headless mode of the + desktop binary (recommended — single-writer, same dir) vs separate bundled `amy` vs + SQLite-WAL store enabling safe concurrent drain. Deepen-plan recommends thin-trigger; + confirm before building PR2. +2. **Store engine:** keep JSON (single-writer discipline) or migrate to SQLite-WAL + (true cross-process ACID, repo already uses it)? Ties to Q1. +3. ~~`claimDuePosts` stuck-PUBLISHING recovery~~ — **RESOLVED: no recovery exists today; + Phase 0 adds a `CLAIM_TTL` staleness sweep + test.** +4. **Screen shape (simplicity):** a standalone `Scheduled` destination beside the + existing `Drafts` one is cleaner for PR1 than refactoring the working Drafts screen + into a two-tab host. Revisit the two-tab decision — brainstorm said one screen/two + tabs, but a sibling destination may ship faster. Confirm with user. +5. Desktop notifier: system tray vs log-only for v1 (log-only acceptable). +6. Time zone / DST — keep all storage/firing in epoch seconds (already the case); + confine TZ logic to the picker's presentation layer. +7. Filename: unify on Android's existing `scheduled_posts.json` (not `scheduled.json`). + +## Sources & References + +### Origin +- **Brainstorm:** [docs/brainstorms/2026-07-09-feat-desktop-note-scheduling-brainstorm.md](../brainstorms/2026-07-09-feat-desktop-note-scheduling-brainstorm.md) + — carried forward: OS-level lifecycle-managed publishing, pre-sign/key-free + publisher, local+opt-in-NIP-37 drafts, one-screen-two-tabs, cancel+recompose edit. + +### Internal References +- Desktop composer: `desktopApp/.../ui/ComposeNoteDialog.kt` (`publishNote`, ~636–673) +- Desktop nav: `desktopApp/.../ui/deck/DeckColumnType.kt`, `DeckSidebar.kt` (`NAV_ITEMS`) +- Desktop publish + write relays: `desktopApp/.../network/RelayConnectionManager.kt`, + `desktopApp/.../model/DesktopIAccount.kt` (`nip65RelayList.outboxFlow`) +- Desktop store pattern: `desktopApp/.../service/drafts/DesktopDraftStore.kt`, + `.../highlights/DesktopHighlightStore.kt` +- Android scheduling: `amethyst/.../service/scheduledposts/{ScheduledPost,ScheduledPostStore,ScheduledPostWorker,ScheduledPostNotifier}.kt` +- Android composer scheduling block: `amethyst/.../home/ShortNotePostViewModel.kt` `sendPostSync()` (~812–906) +- Scheduling UI: `amethyst/.../creators/scheduling/{ScheduleAtButton,ScheduleAtPicker}.kt` +- Publish primitive: `quartz/.../nip01Core/relay/client/accessories/NostrClientPublishExt.kt` (`publishAndConfirmDetailed`) +- NIP-37: `quartz/.../nip37Drafts/DraftWrapEvent.kt` (kind 31234) +- amy dispatch: `cli/src/main/kotlin/.../cli/Main.kt` (`dispatch` `when(head)`), `cli/build.gradle.kts` (application + jpackage)