Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3b03339
core: add listening-time tracking as a grow-only counter
JacobStephens2 Jun 5, 2026
79c8154
web: rewrite privacy policy for optional account-based sync
JacobStephens2 Jun 5, 2026
8f1ceda
web: track and display lifetime listening time
JacobStephens2 Jun 5, 2026
7817504
server: add the listening-time sync service (deploy-later)
JacobStephens2 Jun 5, 2026
94907ab
web: add account sign-in and listening-time sync
JacobStephens2 Jun 5, 2026
28ef83b
android: track and display lifetime listening time
JacobStephens2 Jun 5, 2026
41fd601
android: account sign-in and listening-time sync
JacobStephens2 Jun 5, 2026
d0a6358
windows: track and display lifetime listening time
JacobStephens2 Jun 5, 2026
54ded4f
apple: track and display lifetime listening time (macOS + iOS)
JacobStephens2 Jun 5, 2026
6a28655
windows: silence MVVMTK0045 (field [ObservableProperty] deprecation)
JacobStephens2 Jun 5, 2026
4111d65
server: add a Prometheus /metrics endpoint
JacobStephens2 Jun 5, 2026
1bf7c00
server: codify the sync-service deploy as Terraform + Ansible
JacobStephens2 Jun 5, 2026
06785c8
server+web: STARTTLS for port 587, enable sync in prod web build
JacobStephens2 Jun 5, 2026
89d5755
server: target the cascade_sync Ansible role at Ubuntu/apache2
JacobStephens2 Jun 5, 2026
dc2e4d4
server: observability-as-code for the sync service (Prometheus + Graf…
JacobStephens2 Jun 5, 2026
21560c3
ci: approval-gated deploy pipeline for the sync server
JacobStephens2 Jun 5, 2026
3eacafd
server: threat model for cascade-sync-server
JacobStephens2 Jun 5, 2026
b3f7a74
docs: ADR for the listening-time CRDT architecture
JacobStephens2 Jun 5, 2026
65b958f
docs: HN-grade blog post draft on the listening-time design
JacobStephens2 Jun 5, 2026
c6411f6
windows: account sign-in and listening-time sync
JacobStephens2 Jun 5, 2026
afce8a5
apple: account sign-in and listening-time sync (macOS + iOS)
JacobStephens2 Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/deploy-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Deploy sync server

# Build the cascade-sync-server release binary, then deploy it to the droplet
# via the Ansible role behind a protected `production` environment (manual
# approval). This is the GitOps-flavored counterpart to the hand-rolled web
# deploy script — same destination, but reviewed, gated, and reproducible.
#
# Manual-only (workflow_dispatch) so a deploy is always a deliberate act.
#
# Requires (configure once):
# - Environment `production` with a required reviewer (the approval gate).
# - Secrets (on the environment):
# SSH_PRIVATE_KEY deploy key for the droplet
# SSH_KNOWN_HOSTS `ssh-keyscan <droplet>` output
# DEPLOY_HOST droplet IP / hostname
# ANSIBLE_VAULT_PASSWORD password for group_vars/all/vault.yml

on:
workflow_dispatch:

concurrency:
group: deploy-sync
cancel-in-progress: false

permissions:
contents: read

jobs:
build:
name: Build release binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Test
working-directory: server
run: cargo test --release
- name: Build
working-directory: server
run: cargo build --release
- uses: actions/upload-artifact@v4
with:
name: cascade-sync-server
path: server/target/release/cascade-sync-server
if-no-files-found: error
retention-days: 7

deploy:
name: Deploy to production
needs: build
runs-on: ubuntu-latest
environment: production # approval gate lives here
steps:
- uses: actions/checkout@v4

- name: Fetch the built binary
uses: actions/download-artifact@v4
with:
name: cascade-sync-server
path: server/deploy/ansible/roles/cascade_sync/files/

- name: Make the binary executable
run: chmod +x server/deploy/ansible/roles/cascade_sync/files/cascade-sync-server

- name: Install Ansible + collections
run: |
pipx install --include-deps ansible
ansible-galaxy collection install -r server/deploy/ansible/requirements.yml

- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts

- name: Run the playbook
working-directory: server/deploy/ansible
env:
ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
run: |
printf '%s' "$ANSIBLE_VAULT_PASSWORD" > .vault-pass
trap 'rm -f .vault-pass' EXIT
cat > inventory.ini <<EOF
[cascade_sync]
droplet ansible_host=${{ secrets.DEPLOY_HOST }} ansible_user=jacob
EOF
ansible-playbook -i inventory.ini playbook.yml \
--vault-password-file .vault-pass \
--become
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ dist-ssr/
.vscode/
*.swp

# Env
# Env — keep real secrets out, but commit non-secret build-time config.
.env
.env.*
!.env.example
# Vite production config holds only the public sync API URL (no secret).
!apps/web/.env.production
# Local overrides always stay out.
apps/web/.env.production.local

# WASM build output (regenerated by wasm-pack)
crates/cascade-wasm/pkg/
Expand Down
12 changes: 12 additions & 0 deletions apps/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Magic-link sign-in: open the app for https://cascade.stephens.page/auth.
autoVerify needs an /.well-known/assetlinks.json on the host
(a deploy step) for chooser-free opening. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="cascade.stephens.page"
android:path="/auth" />
</intent-filter>
</activity>

<service
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@ package page.stephens.cascade
import android.app.Application
import page.stephens.cascade.core.CascadeBridgeHolder
import page.stephens.cascade.settings.SettingsStore
import page.stephens.cascade.sync.AccountStore
import page.stephens.cascade.sync.SyncManager

class CascadeApp : Application() {
lateinit var settingsStore: SettingsStore
private set
lateinit var bridgeHolder: CascadeBridgeHolder
private set
lateinit var syncManager: SyncManager
private set

override fun onCreate() {
super.onCreate()
settingsStore = SettingsStore(this)
// Settings are read async; the bridge handles a missing/empty JSON
// by booting with defaults.
bridgeHolder = CascadeBridgeHolder(settingsStore)
syncManager = SyncManager(bridgeHolder, AccountStore(this))
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package page.stephens.cascade

import android.os.Build
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
Expand All @@ -17,7 +17,7 @@ class MainActivity : ComponentActivity() {

private val viewModel: CascadeViewModel by viewModels {
val app = application as CascadeApp
CascadeViewModel.factory(app.bridgeHolder)
CascadeViewModel.factory(app.bridgeHolder, app.syncManager)
}

override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -28,6 +28,7 @@ class MainActivity : ComponentActivity() {
val app = application as CascadeApp
playback = PlaybackController(this, app.bridgeHolder)
playback.start()
handleAuthDeepLink(intent)

setContent {
CascadeTheme {
Expand All @@ -36,6 +37,24 @@ class MainActivity : ComponentActivity() {
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleAuthDeepLink(intent)
}

/** Complete a magic-link sign-in if we were opened via .../auth?token=... */
private fun handleAuthDeepLink(intent: Intent?) {
val token = intent?.data?.getQueryParameter("token") ?: return
(application as CascadeApp).syncManager.completeSignIn(token)
}

override fun onStop() {
// Flush recent listening before we risk being suspended.
(application as CascadeApp).syncManager.flush()
super.onStop()
}

override fun onDestroy() {
playback.stop()
super.onDestroy()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package page.stephens.cascade.audio

import android.content.ComponentName
import android.content.Context
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
Expand All @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import page.stephens.cascade.core.CascadeBridgeHolder
import page.stephens.cascade.core.Command
import page.stephens.cascade.core.Effect

/**
Expand All @@ -36,14 +38,29 @@ class PlaybackController(
private var controller: MediaController? = null
private var collectJob: Job? = null

// Report *confirmed* playback back to the core. Listening time accrues on
// these signals, not on intent, so an audio-focus loss or a failed start
// never counts as listening.
private val playerListener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
bridge.dispatch(
if (isPlaying) Command.PlatformPlaybackStarted else Command.PlatformPlaybackPaused,
)
}

override fun onPlayerError(error: PlaybackException) {
bridge.dispatch(Command.PlatformPlaybackError(error.message ?: "playback error"))
}
}

fun start() {
val sessionToken = SessionToken(
appContext,
ComponentName(appContext, CascadePlaybackService::class.java),
)
val future = MediaController.Builder(appContext, sessionToken).buildAsync()
future.addListener({
controller = future.get()
controller = future.get().also { it.addListener(playerListener) }
// Drain any effects we missed before the controller existed.
applyCurrentState()
collectJob = scope.launch {
Expand All @@ -55,6 +72,7 @@ class PlaybackController(
fun stop() {
collectJob?.cancel()
collectJob = null
controller?.removeListener(playerListener)
controller?.release()
controller = null
}
Expand All @@ -78,6 +96,7 @@ class PlaybackController(
Effect.PausePlayback -> c.pause()
is Effect.SetPlatformVolume -> c.volume = perceptualVolume(effect.volumePercent)
is Effect.PersistSettings -> { /* handled by CascadeBridgeHolder */ }
is Effect.PersistListening -> { /* handled by CascadeBridgeHolder */ }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ class CascadeBridgeHolder(private val settingsStore: SettingsStore) {
private val _snapshot = MutableStateFlow(cascadeJson.decodeFromString<Snapshot>(bridge.snapshot()))
val snapshot: StateFlow<Snapshot> = _snapshot.asStateFlow()

init {
// Restore the listening ledger once at startup. The core ignores a
// missing/incompatible blob and never lets a restore lower the counter.
val listeningJson = runBlocking { settingsStore.readListening() }
if (!listeningJson.isNullOrEmpty()) {
dispatch(Command.RestoreListening(listeningJson))
}
}

/** Latest effects emitted by the most recent dispatch — consumers
* (PlaybackController, settings persister) collect this. */
private val _effects = MutableStateFlow<List<Effect>>(emptyList())
Expand All @@ -49,8 +58,10 @@ class CascadeBridgeHolder(private val settingsStore: SettingsStore) {
// Persist any settings effect immediately — DataStore handles its
// own coalescing, so flooding it on every slider tick is fine.
for (effect in update.effects) {
if (effect is Effect.PersistSettings) {
scope.launch { settingsStore.write(effect.json) }
when (effect) {
is Effect.PersistSettings -> scope.launch { settingsStore.write(effect.json) }
is Effect.PersistListening -> scope.launch { settingsStore.writeListening(effect.json) }
else -> {}
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions apps/android/app/src/main/java/page/stephens/cascade/core/Dto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ sealed class Command {
@Serializable @SerialName("platformPlaybackStarted") data object PlatformPlaybackStarted : Command()
@Serializable @SerialName("platformPlaybackPaused") data object PlatformPlaybackPaused : Command()
@Serializable @SerialName("platformPlaybackError") data class PlatformPlaybackError(val message: String) : Command()
@Serializable @SerialName("setListeningTracking") data class SetListeningTracking(val enabled: Boolean) : Command()
@Serializable @SerialName("restoreListening") data class RestoreListening(val json: String) : Command()
@Serializable @SerialName("applySyncedTotal") data class ApplySyncedTotal(val syncedThroughMs: Long, val serverTotalMs: Long) : Command()
@Serializable @SerialName("resetListeningData") data object ResetListeningData : Command()
}

@Serializable
Expand All @@ -43,6 +47,7 @@ sealed class Effect {
@Serializable @SerialName("pausePlayback") data object PausePlayback : Effect()
@Serializable @SerialName("setPlatformVolume") data class SetPlatformVolume(val volumePercent: Int) : Effect()
@Serializable @SerialName("persistSettings") data class PersistSettings(val json: String) : Effect()
@Serializable @SerialName("persistListening") data class PersistListening(val json: String) : Effect()
}

@Serializable
Expand All @@ -63,6 +68,15 @@ data class TimerSnapshot(
val progress: Float,
)

@Serializable
data class ListeningSnapshot(
val trackingEnabled: Boolean,
val deviceTotalMs: Long,
val displayedTotalMs: Long,
val unsyncedMs: Long,
val totalLabel: String,
)

@Serializable
data class Snapshot(
val title: String,
Expand All @@ -73,6 +87,7 @@ data class Snapshot(
val primaryButtonLabel: String,
val timer: TimerSnapshot,
val errorMessage: String? = null,
val listening: ListeningSnapshot,
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,21 @@ private val Context.dataStore by preferencesDataStore("cascade-settings")
*/
class SettingsStore(private val context: Context) {
private val key = stringPreferencesKey("settings_v1")
// Lifetime listening ledger — a separate blob from settings, so the two
// evolve and fail independently (mirrors `cascade.listening.v1` on web).
private val listeningKey = stringPreferencesKey("listening_v1")

suspend fun read(): String? =
context.dataStore.data.map { it[key] }.first()

suspend fun write(json: String) {
context.dataStore.edit { it[key] = json }
}

suspend fun readListening(): String? =
context.dataStore.data.map { it[listeningKey] }.first()

suspend fun writeListening(json: String) {
context.dataStore.edit { it[listeningKey] = json }
}
}
Loading
Loading