feat(desktop): note scheduling + NIP-37 opt-in draft sync

Adds note scheduling and NIP-37 opt-in encrypted draft sync to Amethyst
Desktop, and extracts the existing Android scheduled-post code into
`commons` so both platforms (and PowJobRestorer) share one implementation.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-15 11:26:12 +03:00
co-authored by Claude Opus 4.8
parent 51607ff4cf
commit 3f56d177d8
42 changed files with 4605 additions and 177 deletions
@@ -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
@@ -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 {
@@ -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,
@@ -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
@@ -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!!)
}
}
}
@@ -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
@@ -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
}
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -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,
@@ -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?,
)
}
@@ -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<String>,
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<DrainDetail>,
)
/**
* 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<NormalizedRelayUrl>,
private val onSent: suspend (ScheduledPost) -> Unit = {},
private val onFailed: suspend (ScheduledPost, String?) -> Unit = { _, _ -> },
private val consume: suspend (event: Event, relays: Set<NormalizedRelayUrl>, extras: List<Event>) -> 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<DrainDetail>()
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
}
}
@@ -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<ScheduledPost> =
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<ScheduledPost> =
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<ScheduledPost> {
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<ScheduledPostFile>(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
}
}
@@ -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"
}
}
@@ -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)
}
}
@@ -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 AC, EF 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.
@@ -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<String>) {
// 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<com.vitorpamplona.quartz.nip01Core.core.Event?>(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<String?>(null) }
var composeEditDraftTag by remember { mutableStateOf<String?>(null) }
var composeEditScheduledForSec by remember { mutableStateOf<Long?>(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)) {
@@ -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<String, NoteDraft>? = null
private val _drafts = MutableStateFlow<List<NoteDraft>>(emptyList())
val drafts: StateFlow<List<NoteDraft>> = _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<String, NoteDraft>) {
_drafts.value = map.values.sortedByDescending { it.updatedAt }
}
private suspend fun load() {
mutex.withLock { publish(loadMap()) }
}
private fun loadMap(): MutableMap<String, NoteDraft> {
cached?.let { return it }
val loaded: MutableMap<String, NoteDraft> =
if (!indexFile.exists()) {
mutableMapOf()
} else {
try {
mapper.readValue<MutableMap<String, NoteDraft>>(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<String, NoteDraft>) {
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<DesktopNoteDraftStore> {
error("LocalNoteDraftStore not provided")
}
@@ -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<NormalizedRelayUrl>,
) {
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
}
}
@@ -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<ScheduledPostStore> {
error("LocalScheduledPostStore not provided")
}
@@ -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<NormalizedRelayUrl> =
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<NormalizedRelayUrl>,
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())
@@ -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<String>,
) {
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") { " <string>${xmlEscape(it)}</string>" }
return """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${xmlEscape(LABEL)}</string>
<key>ProgramArguments</key>
<array>
$argsXml
</array>
<key>StartInterval</key>
<integer>$INTERVAL_SECONDS</integer>
<key>RunAtLoad</key>
<false/>
<key>ProcessType</key>
<string>Background</string>
<key>EnvironmentVariables</key>
<dict>
<key>LANG</key>
<string>en_US.UTF-8</string>
<key>LC_ALL</key>
<string>en_US.UTF-8</string>
</dict>
</dict>
</plist>
"""
}
// ---------------------------------------------------------------------------
// 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<String>): ProcResult = runWithStdin(argv, null)
private fun runWithStdin(
argv: List<String>,
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("&amp;")
'<' -> append("&lt;")
'>' -> append("&gt;")
'"' -> append("&quot;")
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<String>? {
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")
}
}
}
@@ -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 <T> 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)
}
}
}
@@ -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<IMetaTag>,
relays: Set<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
): 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,
@@ -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,
)
}
@@ -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"
@@ -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,
@@ -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)) },
@@ -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,
)
}
}
}
}
@@ -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<String, MergedDraft>() }
// 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<MergedDraft> =
remember(localDrafts, syncedByDTag.toMap(), account.pubKeyHex) {
val byTag = LinkedHashMap<String, MergedDraft>()
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)
}
}
@@ -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<ScheduledPost> { 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"
}
}
@@ -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,
)
}
}
@@ -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,
}
@@ -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
}
@@ -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
}
@@ -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 02 + 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/<account>/…` 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<NormalizedRelayUrl>,
) {
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 3060s) 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/<account>/` ≠ 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`, ~636673)
- 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()` (~812906)
- 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)