mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
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:
co-authored by
Claude Opus 4.8
parent
51607ff4cf
commit
3f56d177d8
@@ -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
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+34
-32
@@ -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!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+22
-37
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+16
-4
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
+39
@@ -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?,
|
||||
)
|
||||
}
|
||||
+178
@@ -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
|
||||
}
|
||||
}
|
||||
+219
-57
@@ -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
|
||||
}
|
||||
}
|
||||
+45
@@ -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"
|
||||
}
|
||||
}
|
||||
+208
-5
@@ -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 A–C, E–F only; **D will not register a job by design**):
|
||||
`./gradlew :desktopApp:run` — look for the log line
|
||||
`scheduled-post OS job not registered: no packaged app binary in dev mode`.
|
||||
|
||||
**Preconditions for all sections:** logged in with an account that has working
|
||||
**write relays** (NIP-65 outbox, or at least connected relays). For sync tests
|
||||
(F) you need the account's signer available (local nsec, or bunker online).
|
||||
|
||||
Data locations used by the feature:
|
||||
`~/.amethyst/scheduled/scheduled_posts.json` (store),
|
||||
`~/.amethyst/scheduled/scheduled.lock` (single-writer lock),
|
||||
`~/.amethyst/scheduled/scheduler.log` (headless run log).
|
||||
|
||||
---
|
||||
|
||||
## Section A — Scheduling from the composer
|
||||
|
||||
- [ ] **A1** Open the composer (New Note). A **clock/schedule icon** is present in
|
||||
the action row.
|
||||
- [ ] **A2** Click it → a **date/time picker** opens in a dialog (not a broken
|
||||
modal). Preset chips show: *in 1 hour*, *tomorrow 9am*, *next Monday 9am*.
|
||||
- [ ] **A3** Pick a preset → the composer shows the chosen time; the Publish
|
||||
action becomes **Schedule**.
|
||||
- [ ] **A4** Pick a custom date **then** time → confirm. Time is **rounded up to
|
||||
the next 15-minute** boundary (expected — matches the ~5-min tick coarseness).
|
||||
- [ ] **A5** Type note text, hit **Schedule** → dialog closes; a row appears in
|
||||
**Drafts & Scheduled → Scheduled** with status **PENDING** and the chosen time.
|
||||
- [ ] **A6** Verify the note did **not** post immediately (check your feed / a relay).
|
||||
- [ ] **A7** (Picture posts) With an image attached, the schedule button is hidden
|
||||
(pictures are not schedulable in v1) — expected, note if otherwise.
|
||||
- [ ] **A8 — bunker offline block** (only if you use a NIP-46 bunker signer):
|
||||
take the bunker offline, try to Schedule → it is **refused with a message**
|
||||
("Signer must be online to schedule…") and **no** row is stored. (Local-nsec
|
||||
users can skip A8.)
|
||||
|
||||
## Section B — In-app publishing (app open + catch-up)
|
||||
|
||||
- [ ] **B1** Schedule a note for **~2 min** out. Keep the app **open**. Within one
|
||||
45-second tick after the time passes, the row flips **PENDING → SENT** and the
|
||||
note appears on a relay / in your feed.
|
||||
- [ ] **B2 — created_at** The published note's timestamp is the **scheduled** time
|
||||
(it sorts into the timeline at the intended moment), not the compose time.
|
||||
- [ ] **B3 — launch catch-up** Schedule a note ~2 min out, then **quit the app
|
||||
before** the time. Reopen the app **after** the time → it publishes on launch
|
||||
(overdue catch-up).
|
||||
- [ ] **B4 — far-future guard** Schedule a note for e.g. next week. When it
|
||||
eventually fires (or via Publish-now), if a relay rejects a too-far-future
|
||||
`created_at`, the row shows **FAILED** with a clear error (not a silent drop).
|
||||
|
||||
## Section C — Drafts & Scheduled management screen
|
||||
|
||||
- [ ] **C1** Sidebar → **Drafts & Scheduled** opens a tabbed screen
|
||||
(**Drafts / Articles / Scheduled**).
|
||||
- [ ] **C2** Scheduled tab lists your account's rows: content preview, absolute +
|
||||
relative time ("… (in 3h)"), and a **status chip** (PENDING/PUBLISHING/SENT/
|
||||
FAILED/CANCELLED).
|
||||
- [ ] **C3 — Cancel** On a PENDING row, overflow menu → **Cancel** → status becomes
|
||||
CANCELLED and it stops firing.
|
||||
- [ ] **C4 — Publish now** On a PENDING/FAILED row → **Publish now** → it publishes
|
||||
within a tick (or immediately) and flips to SENT.
|
||||
- [ ] **C5 — live updates** Actions update the list **without** re-navigating
|
||||
(reactive off the store).
|
||||
- [ ] **C6 — Edit** *(known limitation)* Edit currently **cancels** the row; there
|
||||
is no composer-prefill reopen yet. Confirm it cancels; log if it does more.
|
||||
- [ ] **C7** Empty state ("No scheduled posts") shows when the list is empty.
|
||||
|
||||
## Section D — App-closed firing (OS scheduler) — **packaged build required**
|
||||
|
||||
> If you ran a dev build, D is expected to be skipped (no OS job registered).
|
||||
|
||||
**Common flow:**
|
||||
|
||||
1. **D1 — register on schedule:** with a packaged app, schedule a note ~10 min out.
|
||||
The PENDING row triggers `osScheduler.ensureRegistered()`.
|
||||
2. **D2 — job exists:** verify per-OS (below).
|
||||
3. **D3 — close the app entirely** (Quit, not just close the window).
|
||||
4. **D4 — wait** past the publish time **and** the next ~5-min OS tick.
|
||||
5. **D5 — confirm publish:** `~/.amethyst/scheduled/scheduler.log` shows a drain
|
||||
with `published=1`; the note is on a relay; the row is SENT.
|
||||
6. **D6 — unregister on drain:** reopen the app (or let the headless run finish)
|
||||
with no PENDING/PUBLISHING rows left → `osScheduler.unregister()`; verify the
|
||||
OS job is **gone**.
|
||||
|
||||
### macOS (launchd)
|
||||
- [ ] Registered: `launchctl print gui/$UID/com.vitorpamplona.amethyst.scheduler` exits 0.
|
||||
- [ ] Plist at `~/Library/LaunchAgents/com.vitorpamplona.amethyst.scheduler.plist`,
|
||||
perms `-rw-------`; `ProgramArguments[0]` = absolute app path,
|
||||
`StartInterval` 300, `RunAtLoad` false.
|
||||
- [ ] Force a run: `launchctl kickstart -k gui/$UID/com.vitorpamplona.amethyst.scheduler` → publishes.
|
||||
- [ ] Unregistered: `launchctl print …` non-zero **and** plist deleted.
|
||||
|
||||
### Windows (schtasks + hidden VBS)
|
||||
- [ ] Registered: `schtasks /query /tn "Amethyst\Scheduler"` exits 0.
|
||||
- [ ] VBS at `%LOCALAPPDATA%\Amethyst\amethyst-scheduler-launch.vbs`.
|
||||
- [ ] **No console flash:** when the task fires, **no black window** pops up.
|
||||
- [ ] Force a run: `schtasks /run /tn "Amethyst\Scheduler"` → publishes.
|
||||
- [ ] Unregistered: query says "task does not exist"; VBS deleted.
|
||||
|
||||
### Linux (systemd --user; cron fallback)
|
||||
- [ ] systemd present: `systemctl --user is-enabled amethyst-scheduler.timer` → `enabled`;
|
||||
`systemctl --user list-timers amethyst-scheduler.timer` lists it.
|
||||
- [ ] Units at `~/.config/systemd/user/amethyst-scheduler.{service,timer}`, perms `-rw-------`.
|
||||
- [ ] **Lingering (required for app-closed firing while logged out):**
|
||||
`loginctl show-user $USER -p Linger` → `Linger=yes`. If registration warned it
|
||||
couldn't enable it, run `sudo loginctl enable-linger $USER` and retest.
|
||||
- [ ] Force a run: `systemctl --user start amethyst-scheduler.service` → publishes.
|
||||
- [ ] Unregistered: `is-enabled` non-zero; unit files deleted.
|
||||
- [ ] **cron fallback** (no systemd): `crontab -l` shows a
|
||||
`# BEGIN amethyst-scheduler … # END amethyst-scheduler` block with a
|
||||
`*/5 * * * *` line invoking the **absolute** app path + `--publish-scheduled`;
|
||||
after drain the block is removed and other cron entries are untouched.
|
||||
|
||||
## Section E — Single-writer lock (no double-publish)
|
||||
|
||||
- [ ] **E1** With the app **open** (45s timer running), manually trigger the OS job
|
||||
(the per-OS "force a run" command). The headless process should log
|
||||
`Drain skipped: another process holds the lock …` and exit 0 — the note is
|
||||
published **exactly once**, never twice. (Exercises
|
||||
`~/.amethyst/scheduled/scheduled.lock`.)
|
||||
- [ ] **E2 — crash recovery** *(optional/advanced)* If a headless run is killed
|
||||
mid-publish (leaving a row PUBLISHING), the row auto-reverts to PENDING after
|
||||
the claim TTL (~10 min) and publishes on a later tick — it is never stranded.
|
||||
|
||||
## Section F — NIP-37 draft sync (opt-in)
|
||||
|
||||
- [ ] **F1** Composer → **Save as draft** (without the sync toggle) → the draft
|
||||
appears in **Drafts & Scheduled → Drafts** (local, **no** cloud badge).
|
||||
- [ ] **F2** The **"Sync across devices (encrypted)"** toggle is **OFF by default**.
|
||||
- [ ] **F3** Enable the toggle → Save as draft → the draft shows a **cloud/synced
|
||||
badge**. A kind **31234** event was published (verify on a relay if you can).
|
||||
- [ ] **F4 — cross-device** On a **second** device/profile logged into the **same**
|
||||
account, the synced draft appears in the Drafts tab (subscribed + decrypted).
|
||||
- [ ] **F5 — dedup** The local copy and the synced copy of the same draft show as
|
||||
**one** row (deduped by dTag), not two.
|
||||
- [ ] **F6 — sync failure is soft** With relays unreachable, enabling sync + Save
|
||||
still **saves locally** and shows "Draft saved locally, but sync failed: …".
|
||||
- [ ] **F7 — privacy** Draft **content** is never sent in plaintext (NIP-44
|
||||
encrypted to your own pubkey). Note: kind 31234 *does* publicly reveal that a
|
||||
draft of some kind exists at a time for your pubkey — expected, documented.
|
||||
|
||||
## Section G — Security spot-checks (packaged, Section D context)
|
||||
|
||||
- [ ] **G1** `plist` / `.vbs` / systemd unit / `scheduled_posts.json` are **`0600`**
|
||||
(owner-only).
|
||||
- [ ] **G2** The registered OS command uses an **absolute** app path (no bare
|
||||
`java`/`gradle`/`PATH` lookup).
|
||||
- [ ] **G3** A headless drain **never** prompts for a signer/keychain unlock (it only
|
||||
pushes pre-signed bytes). Watch for any macOS keychain dialog during D5 — there
|
||||
should be none.
|
||||
|
||||
---
|
||||
|
||||
## Multi-account note
|
||||
|
||||
The store keys rows by account pubkey and claims are account-scoped. If you test
|
||||
with multiple accounts, confirm each account only publishes **its own** scheduled
|
||||
rows, and switching accounts restarts the in-app timer for the new account.
|
||||
|
||||
## Known limitations (v1 — not test failures)
|
||||
|
||||
- Opening a saved/synced **draft back into the composer** is not wired yet (no
|
||||
prefill entry point) — Drafts list shows them but clicking doesn't reopen.
|
||||
- **Edit scheduled post** = Cancel (same prefill gap).
|
||||
- OS integration is **compile-verified**; this sheet is its first real-world run.
|
||||
- Windows/Linux are **untested by the authors** — results from Section D there are
|
||||
especially valuable.
|
||||
|
||||
## Cleanup after testing
|
||||
|
||||
Remove any leftover OS jobs and test data:
|
||||
|
||||
- macOS: `launchctl bootout gui/$UID/com.vitorpamplona.amethyst.scheduler; rm -f ~/Library/LaunchAgents/com.vitorpamplona.amethyst.scheduler.plist`
|
||||
- Windows: `schtasks /delete /tn "Amethyst\Scheduler" /f` and delete the VBS.
|
||||
- Linux: `systemctl --user disable --now amethyst-scheduler.timer; rm -f ~/.config/systemd/user/amethyst-scheduler.*; systemctl --user daemon-reload` (or remove the crontab marker block).
|
||||
- `rm -rf ~/.amethyst/scheduled/` to clear the store/lock/log.
|
||||
@@ -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)) {
|
||||
|
||||
+212
@@ -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")
|
||||
}
|
||||
+125
@@ -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
|
||||
}
|
||||
}
|
||||
+87
@@ -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")
|
||||
}
|
||||
+179
@@ -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())
|
||||
+538
@@ -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("&")
|
||||
'<' -> append("<")
|
||||
'>' -> append(">")
|
||||
'"' -> append(""")
|
||||
else -> append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Escapes a token for a systemd ExecStart line (spaces/backslashes). */
|
||||
private fun systemdEscape(s: String): String =
|
||||
if (s.any { it == ' ' || it == '\\' || it == '"' }) {
|
||||
"\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||
} else {
|
||||
s
|
||||
}
|
||||
|
||||
/** Single-quotes a token for a POSIX shell (cron reads /bin/sh). */
|
||||
private fun shellQuote(s: String): String = "'" + s.replace("'", "'\\''") + "'"
|
||||
|
||||
private enum class HostOs { MAC, WINDOWS, LINUX, UNKNOWN }
|
||||
|
||||
private fun detectOs(): HostOs {
|
||||
val name = System.getProperty("os.name")?.lowercase() ?: ""
|
||||
return when {
|
||||
name.contains("mac") || name.contains("darwin") -> HostOs.MAC
|
||||
name.contains("win") -> HostOs.WINDOWS
|
||||
name.contains("nux") || name.contains("nix") -> HostOs.LINUX
|
||||
else -> HostOs.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OsScheduler"
|
||||
|
||||
/** Canonical label (macOS launchd / Windows schtasks folder+name). */
|
||||
const val LABEL = "com.vitorpamplona.amethyst.scheduler"
|
||||
private const val TASK_NAME = "Amethyst\\Scheduler"
|
||||
private const val LINUX_UNIT = "amethyst-scheduler"
|
||||
private const val INTERVAL_SECONDS = 300
|
||||
|
||||
private const val CRON_MARKER_BEGIN = "# BEGIN amethyst-scheduler"
|
||||
private const val CRON_MARKER_END = "# END amethyst-scheduler"
|
||||
|
||||
/**
|
||||
* The absolute argv that invokes this app in headless publish mode, or
|
||||
* `null` when running in dev (`./gradlew run`) where no stable app binary
|
||||
* exists. Callers pass `null` through as an empty command so the scheduler
|
||||
* no-ops rather than registering a job that points at gradle/java.
|
||||
*
|
||||
* `jpackage.app-path` is set by the JVM launcher inside a jpackage bundle
|
||||
* to the absolute path of the app executable.
|
||||
*/
|
||||
fun resolveAppLaunchCommand(): List<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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+332
-19
@@ -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,
|
||||
|
||||
+9
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
@@ -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)) },
|
||||
|
||||
+103
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+331
@@ -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)
|
||||
}
|
||||
}
|
||||
+386
@@ -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"
|
||||
}
|
||||
}
|
||||
+49
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+281
@@ -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,
|
||||
}
|
||||
+67
@@ -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
|
||||
}
|
||||
+101
@@ -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 0–2 + Scheduled tab) delivers the core value
|
||||
with ~zero HIGH risks. PR2 adds app-closed OS firing. PR3 adds NIP-37 draft sync.
|
||||
2. **Publish mechanism reconsidered.** The recommended app-closed mechanism is now a
|
||||
**thin OS trigger that launches a headless publish mode of the desktop binary**
|
||||
(single-writer, same `~/.amethyst/` dir, real relay re-resolution, no second
|
||||
bundled binary, no keychain reads on tick) — with `amy publish-scheduled` kept as
|
||||
*optional* CLI/interop parity, not the production path. This collapses four HIGH
|
||||
risks at once (cross-process race, data-dir mismatch, amy bundling, keychain
|
||||
exposure).
|
||||
3. **Two latent correctness bugs found in the existing store** (inherited by Android
|
||||
too): (a) no cross-process safety in `claimDuePosts` (in-process `Mutex` only), (b)
|
||||
**no timeout recovery for stuck `PUBLISHING` rows** → silent permanent drop. Both
|
||||
must be fixed in Phase 0.
|
||||
4. **KMP placement corrected** — Jackson + `java.io.File` are **gate-forbidden in
|
||||
`commonMain`** (`verifyKmpPurity`). Store/publisher go in `jvmAndroid`, not
|
||||
commonMain. `OsScheduler` `expect` in `jvmAndroid`, not commons/jvmMain.
|
||||
5. **Security hardening added** to acceptance criteria (0600 perms, absolute/canonical
|
||||
trigger path, argv-arrays not `sh -c`, strict plist/unit escaping, key-free path).
|
||||
|
||||
### New considerations discovered
|
||||
- Material3 `DatePicker`/`TimePicker` **do work on Compose Desktop** (CMP 1.11.0) —
|
||||
adapt Android's picker inside a `Dialog` (not `AlertDialog`); no custom picker needed.
|
||||
- `claimDuePosts` is **not account-scoped** — with a single multi-account file, the
|
||||
amy/headless path could publish account B's event under account A's relays. Add an
|
||||
account-scoped claim.
|
||||
- Relays commonly **reject `created_at` too far in the future** — pre-signing next
|
||||
week's post may be refused at publish; handle as FAILED, and bound stale-overdue
|
||||
auto-publish (don't blast weeks-old content silently).
|
||||
|
||||
> **The phase details below are superseded by the "Research Insights & Revised Plan"
|
||||
> section wherever they conflict.** The original phases are kept for provenance.
|
||||
|
||||
## Reuse / Extract / New Matrix
|
||||
|
||||
| File/Component | Status | Location | Action |
|
||||
|----------------|--------|----------|--------|
|
||||
| `ScheduledPost` model + `ScheduledPostStatus` | 📦 Extract (PURE) | `amethyst/.../service/scheduledposts/` | Move to `commons/commonMain` |
|
||||
| `ScheduledPostStore` (Jackson+Mutex+atomic, StateFlow) | 📦 Extract | same | Move to **`commons/jvmAndroid`** (Jackson+File gate-forbidden in commonMain); inject `File`, NO expect/actual. Add `CLAIM_TTL` recovery + account-scoped claim. |
|
||||
| Drain/publish loop (from `ScheduledPostWorker`) | 📦 Extract logic | same | New `ScheduledPostPublisher` in **`commons/jvmAndroid`**; call quartz `publishAndConfirmDetailed` (drop `waitForOk`) |
|
||||
| `INostrClient.publishAndConfirmDetailed()` | ✅ Reuse | `quartz/.../accessories/NostrClientPublishExt.kt` | Key-free publish+confirm (commonMain) |
|
||||
| `ScheduleAtButton` / `ScheduleAtPicker` | 📦 Extract UI + presets | `amethyst/.../creators/scheduling/` | Presets/rounding → commons; picker to commons Compose |
|
||||
| `roundUpToNextQuarterHour`, preset fns | 📦 Extract (PURE) | `ScheduleAtPicker.kt` | Move to `commons` util |
|
||||
| `DraftWrapEvent` (NIP-37, kind 31234) | ✅ Reuse | `quartz/.../nip37Drafts/` | commonMain; usable from Desktop as-is |
|
||||
| `ScheduledPostWorker` (WorkManager) | ⚠️ Android-only | `amethyst/...` | Keep Android; Desktop uses OS scheduler |
|
||||
| `ScheduledPostNotifier` | ⚠️ Android-only | `amethyst/...` | `expect/actual` notifier; Desktop = tray/log |
|
||||
| `ComposeNoteDialog` (composer) | 🆕 Extend | `desktopApp/.../ui/ComposeNoteDialog.kt` | Add clock icon + picker + schedule path |
|
||||
| `DeckColumnType` (nav) | 🆕 Extend | `desktopApp/.../ui/deck/DeckColumnType.kt` | Add/rename destination for Drafts & Scheduled |
|
||||
| `DesktopDraftStore` (local, article-oriented) | ⚠️ Reconcile | `desktopApp/.../service/drafts/` | Reuse pattern; add short-note drafts + opt-in NIP-37 |
|
||||
| `DesktopHighlightStore` (JSON+Mutex+atomic) | ✅ Reuse pattern | `desktopApp/.../service/highlights/` | Template for `DesktopScheduledPostStore` wiring |
|
||||
| `iAccount.nip65RelayList.outboxFlow` | ✅ Reuse | `desktopApp/.../model/DesktopIAccount.kt` | Write-relay re-resolution (in-app path) |
|
||||
| OS scheduler (launchd/schtasks/systemd) | 🆕 New (PR2) | `expect` in `commons/jvmAndroid`, actual `jvmMain` (or `desktopApp`) | NOT commonMain (no iOS stub); Desktop-only registration; macOS-first |
|
||||
| `amy publish-scheduled` subcommand | 🆕 New (PR2, **optional**) | `cli/.../commands/` + `Main.kt` dispatch | Verb group `scheduled run\|list\|publish-now\|cancel` + `--scheduled-file`; optional CLI parity, not the production path |
|
||||
|
||||
## Research Insights & Revised Plan (deepen-plan)
|
||||
|
||||
### Revised staging (supersedes the single-PR phase list)
|
||||
|
||||
| PR | Scope | Risk | Delivers |
|
||||
|----|-------|------|----------|
|
||||
| **PR1** | Phase 0 (extract + fix store bugs) + Phase 1 (composer schedule) + Phase 2 (in-app timer + launch catch-up) + Scheduled tab | ~zero HIGH | "Schedule a note; it publishes at its time" — including the laptop-closed-overnight-reopened-next-morning case, which covers the large majority of real usage. |
|
||||
| **PR2** | App-closed OS firing (thin trigger → headless publish mode), macOS-first behind the abstraction | isolates all HIGH risks | Fires even if the app is never reopened around the scheduled time. |
|
||||
| **PR3** (optional) | NIP-37 opt-in draft sync (synced badge, dTag dedup) | MED | Cross-device drafts. Orthogonal to scheduling. |
|
||||
|
||||
Rationale (simplicity review): every HIGH risk in this plan lives in the OS/amy
|
||||
layer. Shipping PR1 first gets ~90% of the value at ~10% of reviewer/maintainer cost —
|
||||
which matters doubly for a first upstream FOSS PR.
|
||||
|
||||
### CRITICAL / HIGH findings to fix before/within Phase 0
|
||||
|
||||
- **Cross-process store race (arch C1 / sec H3).** `ScheduledPostStore.claimDuePosts()`
|
||||
guards with an **in-process `Mutex` only** — zero cross-process mutual exclusion.
|
||||
Two JVMs (app timer + OS-fired publisher) can both claim the same PENDING rows →
|
||||
**duplicate publish** or lost status write. Resolution options, best-first:
|
||||
1. **Single-writer discipline (recommended for PR2):** OS trigger launches the
|
||||
desktop binary's headless publish mode; a lockfile/heartbeat ensures the GUI
|
||||
timer and the headless drain never run concurrently. Removes the race by
|
||||
construction (only one drainer alive at a time), same `~/.amethyst/` dir.
|
||||
2. **Switch the store to SQLite-WAL (cross-process-store research):** the repo
|
||||
already runs SQLite cross-process-correctly (`SQLiteEventStore`, WAL +
|
||||
`busy_timeout`, `BundledSQLiteDriver`, no JNI). `claimDuePosts` becomes one
|
||||
`BEGIN IMMEDIATE; UPDATE … WHERE status='PENDING' AND publish_at<=?` — true ACID,
|
||||
lease recovery is a one-line `WHERE`. **Use `PRAGMA synchronous=NORMAL`, NOT the
|
||||
`OFF` that `SQLiteEventStore` uses** (a lost SENT row = double-publish). This is
|
||||
the robust option if concurrent drain must be allowed.
|
||||
3. File lock (`FileChannel.lock()` on a sibling `.lock`, held across claim+persist,
|
||||
both paths) — correct but fragile (per-JVM semantics, NFS, stale locks). Least
|
||||
preferred.
|
||||
- **Stuck `PUBLISHING` → silent permanent drop (arch C2 / sec H4).** There is **no
|
||||
timeout auto-recovery** today; `releaseClaim` only fires when the account isn't
|
||||
loaded. A crash/sleep between claim and ack strands the row in `PUBLISHING` forever
|
||||
(never re-claimed, never purged, never surfaced) — the worst outcome for a
|
||||
scheduling feature. **Fix in Phase 0 (benefits Android too):** on load/claim, revert
|
||||
any `PUBLISHING` row with `now - lastAttemptAtSec > CLAIM_TTL` (e.g. 10 min) to
|
||||
`PENDING`. Add a test. This makes Open Question #3's assumed recovery real.
|
||||
- **Account-scoped claim (amy-expert).** Add `claimDuePosts(nowSec, accountPubkey)` (or
|
||||
filter in the publisher). Otherwise the single multi-account file lets the
|
||||
headless/amy path publish account B's pre-signed event using account A's outbox.
|
||||
|
||||
### KMP placement — corrected (kotlin-multiplatform skill, verified against `verifyKmpPurity`)
|
||||
|
||||
Jackson and `java.io.File` are **forbidden in `commonMain`** by the live purity gate
|
||||
(`commons/build.gradle.kts:223-232`). Jackson reaches `commons` only transitively via
|
||||
quartz's `jvmAndroid` `api`. Corrected layout:
|
||||
|
||||
```
|
||||
commons/src/commonMain/.../scheduledposts/
|
||||
ScheduledPost.kt (model + enum + file DTO) — PURE, commonMain OK
|
||||
ScheduleTimePresets.kt (roundUpToNextQuarterHour, presets) — PURE (use TimeUtils.now, NOT System.currentTimeMillis)
|
||||
commons/src/jvmAndroid/.../scheduledposts/
|
||||
ScheduledPostStore.kt (Jackson + File + Mutex + StateFlow) — inject the File, NO expect/actual
|
||||
ScheduledPostPublisher.kt (drain → INostrClient.publishAndConfirmDetailed)
|
||||
ScheduledPostNotifier.kt (expect class — declared in jvmAndroid so iOS needs no stub)
|
||||
OsScheduler.kt (expect class — declared in jvmAndroid)
|
||||
commons/src/androidMain/.../scheduledposts/ ScheduledPostNotifier.kt (WorkManager), OsScheduler.kt
|
||||
commons/src/jvmMain/.../scheduledposts/ ScheduledPostNotifier.kt (tray/log), OsScheduler.kt (launchd/schtasks/systemd)
|
||||
```
|
||||
|
||||
- Store + publisher live in **`jvmAndroid`** (both JVM Desktop & Android share it
|
||||
verbatim). Do **not** abstract the file path behind expect/actual — inject the
|
||||
resolved `File` (over-abstraction). No `iosMain` files needed for any scheduling
|
||||
artifact.
|
||||
- **Sequence the Android refactor (arch M4):** 0a pure move + re-point Android (tests
|
||||
green) → 0b swap `waitForOk`→`publishAndConfirmDetailed` (re-verify "any relay
|
||||
acked = SENT") → 0c add claim-staleness sweep + test. Keep commits bisectable.
|
||||
- **Kill the `waitForOk` extraction (arch H3):** both platforms call the quartz
|
||||
`publishAndConfirmDetailed` primitive; don't port Android's `pendingPublishRelaysFor`
|
||||
polling into commons.
|
||||
|
||||
### OsScheduler placement (arch H2)
|
||||
|
||||
`OsScheduler` writes plist/unit/task files and shells out — it must NOT sit in
|
||||
`commons/jvmMain` if that would put OS-orchestration on amy's CLI-safe classpath. Per
|
||||
the KMP finding, declare the `expect` in **`jvmAndroid`** (actual in `jvmMain` for
|
||||
Desktop OS-switch, `androidMain` = WorkManager). The Desktop-only registration logic
|
||||
itself may equally live in `desktopApp/jvmMain` if it consumes only Desktop types —
|
||||
decide by whether Android reuses the abstraction (it has WorkManager, so a `desktopApp`
|
||||
home is also defensible). Either way: **not commonMain, no iOS stub.**
|
||||
|
||||
### Relay re-resolution — fix the story (arch H1)
|
||||
|
||||
A fresh `~/.amy/` has no kind:10002 for the account, so the amy path's
|
||||
`ctx.outboxRelays()` re-resolution **never fires** for the app-closed case — it always
|
||||
falls back to the stored snapshot. Better: **the app refreshes the stored `relayUrls`
|
||||
snapshot whenever its outbox changes** (cheap, app-side, always current), so whatever
|
||||
the headless/amy path reads is fresh. With the thin-trigger design (headless mode of
|
||||
the desktop binary) this is moot — it reads the app's own live NIP-65 state.
|
||||
|
||||
### Compose picker (research) — no custom picker needed
|
||||
|
||||
Material3 `DatePicker`/`TimePicker` work on Compose Desktop (CMP 1.11.0, material3
|
||||
1.9.0). Adapt Android's `ScheduleAtPicker` inside a `Dialog` (or `DialogWindow` for a
|
||||
roomier modal) — **not `AlertDialog`** (project memory: subscriptions/state issues in
|
||||
AlertDialog on Desktop). Reuse `rememberDatePickerState`/`rememberTimePickerState`,
|
||||
keep the timezone + rounding logic verbatim. Split the matrix row: pure time utils =
|
||||
clean 📦 extract; picker composable = adapt-to-Desktop (verify parity), closer to 🆕.
|
||||
|
||||
### Security hardening (security review) — fold into acceptance criteria
|
||||
|
||||
- Store file + any scheduler files: **`0600`**; refuse to write if the parent dir is
|
||||
group/world-writable. (`DesktopDraftStore` sets 0600 — copy it; `DesktopHighlightStore`
|
||||
sets none — do NOT copy that.)
|
||||
- OS trigger command: **absolute, canonicalized path inside the app bundle**, never a
|
||||
bare name / `PATH` lookup (else a poisoned `~/.local/bin/amy` = durable user-level
|
||||
RCE on a 5-min timer). Verify the path is non-writable by others before emitting it.
|
||||
- Generate plist/unit/task via **strict escaping / argv arrays, never `sh -c` string
|
||||
concat**; reject control chars / newlines in any interpolated path (injection).
|
||||
Reuse the repo's existing argv-array subprocess pattern (`security`/`secret-tool`/
|
||||
`gsettings`).
|
||||
- **Key-free path must read zero secrets:** amy's `Context` eagerly builds a signer
|
||||
from the keychain today — the publish path must use a key-free `Context`/`Identity`
|
||||
variant that never calls `keyPair().privKey`/`secrets.resolve`. Add a criterion:
|
||||
"publish path performs zero keychain reads."
|
||||
- Both drain paths **re-verify the signature** of the deserialized `signedEventJson`
|
||||
and assert `event.pubKey == accountPubkey` before broadcast (tamper defense).
|
||||
- NIP-37 sync: keep **default OFF**; document that kind 31234 leaks *that a draft of
|
||||
kind N exists at time T for pubkey P* (content stays NIP-44 encrypted to self); 90-day
|
||||
NIP-40 expiry is advisory only.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Compose (Desktop, ComposeNoteDialog) commons (shared) OS
|
||||
ClockButton → ScheduleAtPicker ─┐
|
||||
pre-sign via account.signer ├─► ScheduledPostStore ───┐ register/cancel
|
||||
(block if bunker offline) │ (commonMain: JSON, ├─► OsScheduler (expect/actual)
|
||||
store.add(signedEventJson) ─┘ Mutex, claimDuePosts)│ launchd / schtasks / systemd
|
||||
▲ │ │
|
||||
In-app timer (app open) ────────────────────┘ │ ▼ every ~5 min WHEN queue>0
|
||||
ScheduledPostPublisher.drain() │ amy publish-scheduled (cli, key-free)
|
||||
re-resolve outbox relays │ drain due → publishAndConfirmDetailed
|
||||
"Drafts & Scheduled" screen ────────────────────┘ → markSent / markFailed
|
||||
Drafts tab (local + NIP-37, synced badge)
|
||||
Scheduled tab (status + edit/cancel/publish-now)
|
||||
```
|
||||
|
||||
Store path (shared by Desktop app AND `amy`):
|
||||
`~/.amethyst/scheduled/scheduled.json` (per-account keyed by `accountPubkey` field;
|
||||
one file, filtered by account — mirrors Android's single-file store).
|
||||
|
||||
> **Data-dir mismatch (must reconcile):** `amy` resolves its own data dir as
|
||||
> `~/.amy/<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 30–60s) while the app is open.
|
||||
- `resolveRelays` for the in-app path = **re-resolve** `iAccount.nip65RelayList.outboxFlow.value`
|
||||
for the post's account, falling back to the stored `relayUrls` snapshot if empty.
|
||||
- On launch, run one **catch-up** drain → overdue posts auto-publish. (see brainstorm §4)
|
||||
|
||||
**Success:** A post scheduled for ~1 min out publishes while the app is open;
|
||||
overdue post publishes on relaunch.
|
||||
|
||||
#### Phase 3 — OS-level scheduler + `amy publish-scheduled`
|
||||
- `amy publish-scheduled` subcommand (`cli/.../commands/`, wired into `Main.kt`
|
||||
`when(head)` dispatch): resolves the shared store path, `drainDue(now)`, publishes
|
||||
via `publishAndConfirmDetailed`, marks status, exits 0/1. Honors `--json`.
|
||||
- Key-free: reads `signedEventJson` — no signer needed. Uses `Context.publish()`
|
||||
→ `publishAndConfirmDetailed` (accepts pre-signed events).
|
||||
- Relay resolution for the amy path: prefer amy's own `ctx.outboxRelays()` (reads
|
||||
kind:10002 from its store, or a fresh network fetch) to honor "re-resolve at
|
||||
publish"; fall back to the stored `relayUrls` snapshot if amy has no synced
|
||||
kind:10002 for that account. (see Risks — divergence is now bounded, not total.)
|
||||
- `expect/actual OsScheduler` (commons `jvmMain` or desktopApp):
|
||||
- `fun ensureRegistered()` / `fun unregister()` — idempotent.
|
||||
- **macOS**: write a `launchd` `~/Library/LaunchAgents/com.vitorpamplona.amethyst.scheduledposts.plist`
|
||||
with `StartInterval` ~300s calling the bundled `amy publish-scheduled`; `launchctl load/unload`.
|
||||
- **Windows**: `schtasks /create /sc minute /mo 5 …` invoking `amy.bat publish-scheduled`; `/delete` on unregister.
|
||||
- **Linux**: `systemd --user` timer (`.timer` + `.service`) or crontab fallback.
|
||||
- **Lifecycle management**: observe `store.flow`; when pending count goes 0→>0 call
|
||||
`ensureRegistered()`, when it drains to 0 call `unregister()`. (see brainstorm §1
|
||||
user refinement)
|
||||
- **Bundle `amy` with the desktop distribution** so the OS job has a stable path.
|
||||
`amy` is a Gradle `application` (launcher `amy`/`amy.bat`, mainClass `…cli.MainKt`)
|
||||
with its own jlink+jpackage bundle. Options (decide in impl): (a) add `:cli` to the
|
||||
desktop jpackage image and resolve the launcher path at runtime, or (b) ship amy's
|
||||
jlink image inside the desktop app resources. Path must survive app updates.
|
||||
|
||||
**Success:** With the app closed, a due post publishes via the OS job on macOS
|
||||
(dogfood platform); registration appears/disappears with queue transitions.
|
||||
|
||||
#### Phase 4 — "Drafts & Scheduled" unified screen + draft sync
|
||||
- Nav: extend `DeckColumnType` — rename `Drafts` destination presentation to
|
||||
"Drafts & Scheduled" (keep `Drafts` object; add `ScheduledPosts` OR make the
|
||||
existing Drafts screen a two-tab host). One sidebar entry, two tabs (Drafts |
|
||||
Scheduled). (see brainstorm §7)
|
||||
- **Scheduled tab**: list from `store.flow.listFor(account)`; each row = content
|
||||
preview + scheduled time + status chip (PENDING/PUBLISHING/SENT/FAILED). Actions:
|
||||
- **Publish now** → `store.publishNow(id)` (+ trigger drain).
|
||||
- **Cancel** → `store.cancel(id)`.
|
||||
- **Edit** → cancel + reopen content in `ComposeNoteDialog` as a draft to
|
||||
re-schedule. (see brainstorm §8)
|
||||
- FAILED rows show `lastError` + attemptCount and a retry (= publishNow).
|
||||
- **Drafts tab**: reconcile with existing `DesktopDraftStore`. Add composer
|
||||
"Save as draft" for short notes; **opt-in NIP-37 sync** toggle publishes the draft
|
||||
as `DraftWrapEvent` (kind 31234). One list; synced rows get a small cloud badge;
|
||||
same `dTag` dedups local+synced. (see brainstorm §5 / Resolved)
|
||||
|
||||
**Success:** Screen lists both; all actions work; synced badge shows for NIP-37
|
||||
drafts.
|
||||
|
||||
#### Phase 5 — Retry, cleanup, notifications, tests, docs
|
||||
- Retry/backoff policy (reuse Android `attemptCount`); retention purge (SENT >7d,
|
||||
CANCELLED >30d, FAILED kept) already in the store — verify on Desktop.
|
||||
- Desktop notifier `actual`: system tray / OS notification on SENT/FAILED (optional;
|
||||
can stub+log for v1).
|
||||
- Tests: commons unit tests for store state machine, `claimDuePosts` atomicity,
|
||||
publisher drain, preset/rounding utils. Manual testing sheet.
|
||||
- Docs: update `desktopApp` plans; note the OS-job files created per platform.
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
- **One OS job per post** (brainstorm Approach B): precise but create/cancel churn
|
||||
and orphan risk. Rejected for lifecycle-managed single job.
|
||||
- **In-app-only + catch-up** (brainstorm Approach A-lite): simplest but never fires
|
||||
when the app is closed. Rejected — user explicitly wants app-closed firing.
|
||||
- **Sign at publish time**: would force the OS job / amy to hold keys. Rejected —
|
||||
breaks the key-free guarantee. (see brainstorm §3)
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
`ComposeNoteDialog.Publish(scheduled)` → `signer.sign` → `store.add` → `store.flow`
|
||||
emits → lifecycle observer → `OsScheduler.ensureRegistered()`. At fire time: OS job
|
||||
→ `amy publish-scheduled` → `store.claimDuePosts` (flips PENDING→PUBLISHING) →
|
||||
`publishAndConfirmDetailed` → `markSent/markFailed` → `store.flow` emits → if queue
|
||||
now empty → `OsScheduler.unregister()`. Parallel in-app timer path claims the same
|
||||
rows via the same atomic `claimDuePosts`, so only one path publishes each post.
|
||||
|
||||
### Error & Failure Propagation
|
||||
- Sign failure at schedule (bunker offline) → surfaced inline, nothing stored.
|
||||
- Publish failure at fire time → `markFailed(id, error)`, row stays FAILED with
|
||||
`lastError`; retryable from the Scheduled tab.
|
||||
- Two-process write race (app + amy) → store must be safe across processes, not just
|
||||
coroutines (see Risks).
|
||||
|
||||
### State Lifecycle Risks
|
||||
- A post claimed as PUBLISHING by amy, then amy crashes → `releaseClaim`/timeout must
|
||||
return it to PENDING so it isn't stuck. Verify the store's claim has a recovery path.
|
||||
- OS job registered but queue emptied by the in-app path → observer must still
|
||||
`unregister()` (don't leak launchd/schtasks/systemd entries).
|
||||
|
||||
### API Surface Parity
|
||||
- Android composer path and Desktop composer path must produce identical
|
||||
`ScheduledPost` rows (same re-stamp + relay snapshot semantics).
|
||||
- `amy publish-scheduled` and the in-app timer must share `ScheduledPostPublisher`.
|
||||
|
||||
### Integration Test Scenarios
|
||||
1. Schedule → close app → OS job fires → note appears at scheduled `created_at`.
|
||||
2. Schedule → keep app open → in-app timer fires before OS job; amy later finds
|
||||
nothing due (claim already consumed).
|
||||
3. Queue 0→1→0 registers then unregisters the OS job (inspect launchd/schtasks/systemd).
|
||||
4. Overdue on relaunch auto-publishes.
|
||||
5. Bunker offline at schedule → blocked, no row written.
|
||||
6. Change write relays after scheduling → in-app path uses new outbox; amy path uses
|
||||
stored snapshot (documented divergence).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
- [ ] Clock icon in Desktop composer opens `ScheduleAtPicker` with presets.
|
||||
- [ ] Scheduling pre-signs and stores a `PENDING` row; blocks if bunker offline.
|
||||
- [ ] In-app timer publishes due posts while app is open; catch-up on launch
|
||||
auto-publishes overdue posts.
|
||||
- [ ] OS job registered on queue 0→1, cancelled on →0 (macOS/Windows/Linux).
|
||||
- [ ] `amy publish-scheduled` drains + publishes pre-signed posts key-free (text +
|
||||
`--json`, exit 0/1/2).
|
||||
- [ ] "Drafts & Scheduled" sidebar screen with two tabs; Scheduled rows show
|
||||
status + edit(cancel+recompose)/cancel/publish-now.
|
||||
- [ ] Drafts tab shows local + opt-in NIP-37 synced drafts with a synced badge.
|
||||
|
||||
### Non-Functional
|
||||
- [ ] Publisher never accesses signing keys; **publish path performs zero keychain reads**.
|
||||
- [ ] Store is safe against concurrent drains — no duplicate publish, no lost row
|
||||
(test: concurrent double-drain → single publish).
|
||||
- [ ] Stuck `PUBLISHING` rows auto-recover after `CLAIM_TTL` (test: crash-mid-publish → row returns to PENDING).
|
||||
- [ ] `claimDuePosts` is account-scoped; a row publishes only under its own `accountPubkey`.
|
||||
- [ ] Both drain paths re-verify signature + `event.pubKey == accountPubkey` before broadcast.
|
||||
- [ ] Store file + scheduler files are `0600`; write refused if parent dir is group/world-writable.
|
||||
- [ ] OS trigger uses an absolute, canonical, non-other-writable path (no `PATH` lookup); scheduler files built via argv-array/strict escaping, never `sh -c`.
|
||||
- [ ] No orphaned OS-scheduler entries after queue drains / logout; registration reconciled idempotently on startup (level-triggered, not only Flow-edge-triggered).
|
||||
- [ ] Future-`created_at` relay rejection handled as FAILED with a clear error; stale-overdue (> 24h) auto-publish is bounded, not silent.
|
||||
|
||||
### Quality Gates
|
||||
- [ ] `:commons:jvmTest`, `:desktopApp:compileKotlin`, Android build green.
|
||||
- [ ] `./gradlew spotlessApply` clean.
|
||||
- [ ] Manual testing sheet executed (Desktop macOS at minimum).
|
||||
|
||||
## Success Metrics
|
||||
- A note scheduled with the app closed publishes within one OS-tick (~5 min) of its
|
||||
time. Zero duplicate publishes across in-app + OS paths. Zero leaked OS jobs.
|
||||
|
||||
## Dependencies & Risks
|
||||
- **Two-process store safety (HIGH):** Android's `ScheduledPostStore` uses an
|
||||
in-process `Mutex` + atomic file rename. With amy and the app both writing, need
|
||||
cross-process safety (file lock, or amy-only-writes-when-app-absent, or a lock
|
||||
file). Must resolve in Phase 3.
|
||||
- **amy bundling/path (HIGH):** OS job needs a stable amy executable path surviving
|
||||
updates. Ties into desktop jpackage config.
|
||||
- **Data-dir reconciliation (HIGH):** amy's `~/.amy/<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`, ~636–673)
|
||||
- Desktop nav: `desktopApp/.../ui/deck/DeckColumnType.kt`, `DeckSidebar.kt` (`NAV_ITEMS`)
|
||||
- Desktop publish + write relays: `desktopApp/.../network/RelayConnectionManager.kt`,
|
||||
`desktopApp/.../model/DesktopIAccount.kt` (`nip65RelayList.outboxFlow`)
|
||||
- Desktop store pattern: `desktopApp/.../service/drafts/DesktopDraftStore.kt`,
|
||||
`.../highlights/DesktopHighlightStore.kt`
|
||||
- Android scheduling: `amethyst/.../service/scheduledposts/{ScheduledPost,ScheduledPostStore,ScheduledPostWorker,ScheduledPostNotifier}.kt`
|
||||
- Android composer scheduling block: `amethyst/.../home/ShortNotePostViewModel.kt` `sendPostSync()` (~812–906)
|
||||
- Scheduling UI: `amethyst/.../creators/scheduling/{ScheduleAtButton,ScheduleAtPicker}.kt`
|
||||
- Publish primitive: `quartz/.../nip01Core/relay/client/accessories/NostrClientPublishExt.kt` (`publishAndConfirmDetailed`)
|
||||
- NIP-37: `quartz/.../nip37Drafts/DraftWrapEvent.kt` (kind 31234)
|
||||
- amy dispatch: `cli/src/main/kotlin/.../cli/Main.kt` (`dispatch` `when(head)`), `cli/build.gradle.kts` (application + jpackage)
|
||||
Reference in New Issue
Block a user