fix(privacylock): provide CompositionLocals inside App() for tests

CI failure: AppStateMachineTest called App() directly, bypassing
the outer Window { CompositionLocalProvider } shell in Main.kt.
DesktopMessagesLockGate read LocalMessagesLockState and hit the
compositionLocalOf { error(...) } trap.

Fix: construct the state holder + provide both LocalMessagesLockState
and LocalPrivacyLockSettings INSIDE App() itself. Extracted the body
of App() into a private AppInner() so the provider can wrap it
cleanly. The outer providers are removed from Main.kt — no longer
needed.

Trade-off: MessagesLockState is now scoped to App() (via
rememberCoroutineScope) instead of windowScope. That means it
rebuilds on appRestartKey change, which is intentional — an app
restart should reset the coroutines too. The seeded initial value
is still read synchronously from prefs so the first composition
sees the correct LockState (deep-link race fix preserved).

Also fixes an unrelated `!!` warning on existingHash in
SetPasswordDialog by using a safe smart-cast check.
This commit is contained in:
nrobi144
2026-07-01 12:53:32 +03:00
parent 72c3dff870
commit 9d8856efea
2 changed files with 83 additions and 17 deletions
@@ -291,20 +291,9 @@ fun main() {
// Callback set by App() for single pane navigation from MenuBar
var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) }
// Messages privacy lock: app-global settings + state holder. Initial
// LockState is seeded synchronously inside MessagesLockState from
// the java.util.prefs value so the first composition sees the correct
// state (deep-link race fix, plan §Security Hardening H1).
val privacyLockSettings =
remember {
com.vitorpamplona.amethyst.commons.privacylock
.PreferencesPrivacyLockSettings()
}
val messagesLockState =
remember {
com.vitorpamplona.amethyst.commons.privacylock
.MessagesLockState(privacyLockSettings, windowScope)
}
// Messages privacy lock CompositionLocals are provided inside App()
// itself (see App() around line ~700) so tests that call App()
// directly — bypassing this Main.kt Window shell — still get them.
// Window title-bar / taskbar thumbnail icon. On macOS the source logo
// is wrapped in a squircle so it matches every other dock icon; on
@@ -617,8 +606,6 @@ fun main() {
LocalWindowState provides windowState,
LocalAwtWindow provides window,
LocalIsImmersiveFullscreen provides immersiveFullscreenState,
com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState provides messagesLockState,
com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings provides privacyLockSettings,
) {
key(appRestartKey) {
CompositionLocalProvider(
@@ -696,6 +683,85 @@ fun App(
val singlePaneState = remember { SinglePaneState() }
val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } }
// Messages privacy lock — app-global settings + state holder, scoped to
// App() so they survive appRestartKey rebuilds but rebuild on genuine app
// restart. Provided as CompositionLocals right here so both production
// (called from application { Window { App() } }) and tests (which call
// App() directly, bypassing outer providers) see them.
val appScope = rememberCoroutineScope()
val privacyLockSettings =
remember {
com.vitorpamplona.amethyst.commons.privacylock
.PreferencesPrivacyLockSettings()
}
val messagesLockState =
remember(privacyLockSettings) {
com.vitorpamplona.amethyst.commons.privacylock
.MessagesLockState(privacyLockSettings, appScope)
}
CompositionLocalProvider(
com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState provides messagesLockState,
com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings provides privacyLockSettings,
) {
AppInner(
layoutMode = layoutMode,
onLayoutModeChange = onLayoutModeChange,
deckState = deckState,
workspaceManager = workspaceManager,
accountManager = accountManager,
showComposeDialog = showComposeDialog,
showAppDrawer = showAppDrawer,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onDismissComposeDialog = onDismissComposeDialog,
onDismissAppDrawer = onDismissAppDrawer,
onShowAppDrawer = onShowAppDrawer,
replyToNote = replyToNote,
showImportFollowListDialog = showImportFollowListDialog,
onShowImportFollowListDialog = onShowImportFollowListDialog,
onDismissImportFollowListDialog = onDismissImportFollowListDialog,
onRestartApp = onRestartApp,
torManager = torManager,
torTypeFlow = torTypeFlow,
externalPortFlow = externalPortFlow,
initialTorSettings = initialTorSettings,
onNavigateToScreen = onNavigateToScreen,
testOverrides = testOverrides,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
)
}
}
@Composable
private fun AppInner(
layoutMode: LayoutMode,
onLayoutModeChange: (LayoutMode) -> Unit,
deckState: DeckState,
workspaceManager: WorkspaceManager,
accountManager: AccountManager,
showComposeDialog: Boolean,
showAppDrawer: Boolean,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onDismissComposeDialog: () -> Unit,
onDismissAppDrawer: () -> Unit,
onShowAppDrawer: () -> Unit,
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
showImportFollowListDialog: Boolean,
onShowImportFollowListDialog: () -> Unit,
onDismissImportFollowListDialog: () -> Unit,
onRestartApp: () -> Unit,
torManager: com.vitorpamplona.amethyst.commons.tor.ITorManager,
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings,
onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit,
testOverrides: LaunchTestOverrides?,
singlePaneState: SinglePaneState,
pinnedNavBarState: PinnedNavBarState,
) {
// Register single pane navigation callback for MenuBar shortcuts
LaunchedEffect(singlePaneState) {
onNavigateToScreen { screen -> singlePaneState.navigate(screen) }
@@ -103,7 +103,7 @@ fun SetPasswordDialog(
val submit: () -> Unit = {
val currentOk =
!isChange ||
PasswordHasher.verify(current.toCharArray(), existingHash!!)
(existingHash != null && PasswordHasher.verify(current.toCharArray(), existingHash))
when {
!currentOk -> {
currentError = "Wrong password"