diff --git a/README.md b/README.md index ba45de46..e7e66fa2 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,8 @@ Requires a recent browser (Chrome 119+, Firefox 120+, Safari 18.2+). > locally with `./gradlew :server:run` and point **Settings → Proxy server** at > `http://localhost:8088`. For hosting, **self-host your own** instance (a shared proxy can see your > credentials in transit) — see [`server/README.md`](server/README.md) for Docker/Fly.io setup and -> the trust caveats. +> the trust caveats. Users who can't do that can opt into the instance we host, but only after +> confirming a dialog that explains what its operator can see. ### 🍎 iOS diff --git a/server/README.md b/server/README.md index f7e6c58c..953c9b87 100644 --- a/server/README.md +++ b/server/README.md @@ -21,8 +21,15 @@ apps don't use this proxy at all. > The proxy terminates TLS, so it sees the `Authorization` header (your CalDAV credentials) in > transit. **Whoever runs the proxy could read those credentials.** For that reason: > - **Self-host your own instance** whenever you can — then you are the only one in the path. -> - Any shared/public instance (including a project demo) should be treated as **evaluation only — -> do not use real credentials** against a proxy you don't control. +> - Any other shared/public instance should be treated as **evaluation only — do not use real +> credentials** against a proxy you don't control. +> +> For users who can't run a container, the web app also offers `https://spectacled-proxy.fly.dev`, +> an instance of exactly this code operated by Techbee. It is off by default and can only be +> selected after confirming a dialog that spells out what the operator can see; while it is +> active, the settings page keeps that disclosure on screen. It logs the request method and the +> target hostname only — never credentials, headers, or entry content — but you cannot verify that +> from the outside, which is precisely why self-hosting is still the recommended path. ## How it works diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 3ffae097..adcac0a9 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -382,6 +382,34 @@ Proxy server Web only. Browsers block cross-origin CalDAV (WebDAV) requests, so the web app routes them through this proxy, which adds the required CORS headers. The proxy can see your credentials in transit - prefer one you host yourself. + My own proxy server + You run the proxy, so nobody else is in the path. Recommended. + Spectacled proxy (hosted by us) + No setup needed, but your CalDAV user name and password pass through a server we operate. + Sees your credentials + Recommended + Proxy presets + Local development server + Proxy setup info + Your credentials pass through our server + The web app sends every CalDAV request - including your user name and password - through %1$s, which is operated by Techbee. + What this means + Switch to my own server + + First: choose a proxy server + The web version reaches CalDAV servers through a proxy. Pick one here before connecting an account - without it, the app cannot reach any server. + Proxy: %1$s + Change + + Use the proxy we host? + %1$s is operated by Techbee, the makers of Spectacled, on Fly.io. Using it means you do not have to set anything up - but it also means trusting us with your CalDAV login. + The proxy terminates the encrypted connection, so your CalDAV user name and password - and everything you sync - are readable on that machine while a request passes through. + We do not log or store your credentials or your entries, and the proxy is open source so you can read exactly what it does. You cannot verify from here what we actually run, though - that part is trust. + Safer options: host the same proxy yourself (one container, takes a few minutes), or create an app-specific password on your CalDAV server that you can revoke at any time. + I understand that my CalDAV credentials pass through a server operated by Techbee. + Use hosted proxy + How to host it myself + Widget configuration diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListScreenRoot.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListScreenRoot.kt index c4587077..0917ccba 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListScreenRoot.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/AccountListScreenRoot.kt @@ -200,6 +200,7 @@ fun AccountListScreenRoot( sheetState = rememberExpandedSheetState(), processingState = state.processingState, isFirstAccount = state.principals.isEmpty(), + userAppPreferencesStore = viewModel.userAppPreferencesStore, onAction = { viewModel.onAction(it) }, onDismiss = { viewModel.onAction(AccountListAction.OnShowAddPrincipalBottomSheet(false)) } ) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt index aad95e09..e2f3f8ce 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/AddPrincipalBottomSheet.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.ChevronLeft import androidx.compose.material.icons.outlined.ChevronRight import androidx.compose.material.icons.outlined.MoreVert @@ -49,6 +50,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -58,6 +60,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization @@ -71,7 +74,11 @@ import at.techbee.spectacled.screens.account.presentation.AccountListAction import at.techbee.spectacled.screens.account.presentation.ProcessingState import at.techbee.spectacled.screens.account.presentation.components.datastructures.CalDavProvider import at.techbee.spectacled.screens.account.presentation.components.datastructures.CalDavProviderCategory +import at.techbee.spectacled.screens.account.presentation.components.settings.ProxyServerSetup +import at.techbee.spectacled.screens.core.Platforms import at.techbee.spectacled.screens.core.data.Credentials +import at.techbee.spectacled.screens.core.data.UserAppPreferencesStore +import at.techbee.spectacled.screens.core.getPlatform import at.techbee.spectacled.screens.core.presentation.components.BottomSheetWithMenu import at.techbee.spectacled.screens.core.presentation.components.SplashScreen import at.techbee.spectacled.theme.AppTheme @@ -89,6 +96,10 @@ import spectacled.shared.generated.resources.add_account_option2_recommendation_ import spectacled.shared.generated.resources.add_account_option2_recommended_providers import spectacled.shared.generated.resources.add_account_option2_text import spectacled.shared.generated.resources.add_account_option_x +import spectacled.shared.generated.resources.add_account_proxy_change +import spectacled.shared.generated.resources.add_account_proxy_ready +import spectacled.shared.generated.resources.add_account_proxy_required_info +import spectacled.shared.generated.resources.add_account_proxy_required_title import spectacled.shared.generated.resources.add_account_provider_tasks_only_warning import spectacled.shared.generated.resources.add_account_spectacled_is_provider_independent import spectacled.shared.generated.resources.back @@ -111,6 +122,7 @@ fun AddPrincipalBottomSheet( sheetState: SheetState, processingState: ProcessingState, isFirstAccount: Boolean, + userAppPreferencesStore: UserAppPreferencesStore, onAction: (AccountListAction.OnAddPrincipal) -> Unit, onDismiss: () -> Unit, spectacledVariant: SpectacledVariant = koinInject() @@ -207,6 +219,7 @@ fun AddPrincipalBottomSheet( if (page == 0) { SelectAccountOptionScreen( isFirstAccount = isFirstAccount, + userAppPreferencesStore = userAppPreferencesStore, onPageChanged = { selectedPage = it }, spectacledVariant = spectacledVariant, modifier = Modifier.padding(8.dp).fillMaxSize().verticalScroll(rememberScrollState()) @@ -234,11 +247,19 @@ fun AddPrincipalBottomSheet( @Composable fun SelectAccountOptionScreen( isFirstAccount: Boolean, + userAppPreferencesStore: UserAppPreferencesStore, onPageChanged: (AddPrincipalBottomSheetPage) -> Unit, modifier: Modifier = Modifier.padding(8.dp).fillMaxSize().verticalScroll(rememberScrollState()), spectacledVariant: SpectacledVariant = koinInject() ) { + // Only the web build talks to CalDAV through a proxy, and until one is picked it can reach no + // server at all - so on the web the options stay closed until that choice is made. + val proxyRequired = getPlatform().platform == Platforms.WASM || LocalInspectionMode.current + val userProxyServer by userAppPreferencesStore.getUserProxyServerAsFlow().collectAsState(userAppPreferencesStore.userProxyServer) + val proxyConfigured = !userProxyServer.isNullOrBlank() + var proxySetupExpanded by rememberSaveable { mutableStateOf(false) } + Column( verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), horizontalAlignment = Alignment.CenterHorizontally, @@ -284,7 +305,52 @@ fun SelectAccountOptionScreen( ) } + if (proxyRequired) { + // Configured already: a one-line confirmation, expandable if they want to change it. + // Not configured: the full picker, since nothing below it can work until it is answered. + if (proxyConfigured && !proxySetupExpanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Outlined.Check, null, tint = MaterialTheme.colorScheme.primary) + Text( + text = stringResource(Res.string.add_account_proxy_ready, userProxyServer.orEmpty()), + style = MaterialTheme.typography.bodySmall, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.weight(1f, fill = false) + ) + TextButton(onClick = { proxySetupExpanded = true }) { + Text(stringResource(Res.string.add_account_proxy_change)) + } + } + } else { + ElevatedCard { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(Res.string.add_account_proxy_required_title), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(Res.string.add_account_proxy_required_info), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + ProxyServerSetup(userAppPreferencesStore) + } + } + } + } + ElevatedCard( + enabled = !proxyRequired || proxyConfigured, onClick = { onPageChanged(AddPrincipalBottomSheetPage.USE_EXISTING) } ) { @@ -323,6 +389,7 @@ fun SelectAccountOptionScreen( ElevatedCard( + enabled = !proxyRequired || proxyConfigured, onClick = { onPageChanged(AddPrincipalBottomSheetPage.SELECT_FROM_LIST) } ) { @@ -737,6 +804,7 @@ private fun AddAccountScreen_Preview_Idle() { sheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded, enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded)), processingState = ProcessingState.Idle, isFirstAccount = true, + userAppPreferencesStore = UserAppPreferencesStore.getEmptyPreferenceStoreForPreview(SpectacledVariant.JOURNALS), onAction = {}, onDismiss = {}, spectacledVariant = SpectacledVariant.JOURNALS @@ -755,6 +823,7 @@ private fun AddAccountScreen_Preview_Processing() { sheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded, enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded)), processingState = ProcessingState.Processing, isFirstAccount = false, + userAppPreferencesStore = UserAppPreferencesStore.getEmptyPreferenceStoreForPreview(SpectacledVariant.NOTES), onAction = {}, onDismiss = {}, spectacledVariant = SpectacledVariant.NOTES diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/ProxyServerSetup.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/ProxyServerSetup.kt new file mode 100644 index 00000000..4f58ee1e --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/ProxyServerSetup.kt @@ -0,0 +1,349 @@ +package at.techbee.spectacled.screens.account.presentation.components.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Cloud +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.techbee.spectacled.SpectacledVariant +import at.techbee.spectacled.screens.core.data.HttpClientFactory +import at.techbee.spectacled.screens.core.data.UserAppPreferencesStore +import at.techbee.spectacled.theme.AppTheme +import org.jetbrains.compose.resources.stringResource +import spectacled.shared.generated.resources.Res +import spectacled.shared.generated.resources.insecure_connection_warning +import spectacled.shared.generated.resources.settings_proxy_hosted_active_message +import spectacled.shared.generated.resources.settings_proxy_hosted_active_title +import spectacled.shared.generated.resources.settings_proxy_hosted_review +import spectacled.shared.generated.resources.settings_proxy_hosted_switch_to_own +import spectacled.shared.generated.resources.settings_proxy_option_hosted +import spectacled.shared.generated.resources.settings_proxy_option_hosted_badge +import spectacled.shared.generated.resources.settings_proxy_option_hosted_info +import spectacled.shared.generated.resources.settings_proxy_option_own +import spectacled.shared.generated.resources.settings_proxy_option_own_info +import spectacled.shared.generated.resources.settings_proxy_option_own_recommended +import spectacled.shared.generated.resources.settings_proxy_preset_local_development +import spectacled.shared.generated.resources.settings_proxy_presets +import spectacled.shared.generated.resources.settings_proxy_server +import spectacled.shared.generated.resources.settings_proxy_server_info +import spectacled.shared.generated.resources.settings_proxy_setup_instructions + + +/** + * Picks the CORS proxy the web build routes CalDAV traffic through: the user's own instance, or the + * one Techbee hosts - the latter only after [ProxyTrustDialog] has been confirmed. + * + * Shared by the settings sheet and the add-account flow so the consent gate has exactly one + * implementation; a second copy of this UI would be a second chance to get the hosted proxy + * selected without the disclosure. Callers decide whether to show it at all (it is meaningless off + * the web build) and supply their own heading. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProxyServerSetup( + userAppPreferencesStore: UserAppPreferencesStore, + modifier: Modifier = Modifier +) { + + val userProxyServer by userAppPreferencesStore.getUserProxyServerAsFlow().collectAsState(userAppPreferencesStore.userProxyServer) + val hostedProxyConsentUrl by userAppPreferencesStore.getHostedProxyConsentUrlAsFlow().collectAsState(userAppPreferencesStore.hostedProxyConsentUrl) + + val hostedProxyUrl = HttpClientFactory.HOSTED_FLYIO_PROXY_URL + val hostedProxySelected = userProxyServer?.trim() == hostedProxyUrl + + // Kept around while the hosted proxy is selected so switching back restores what the user had typed. + var ownProxyServerDraft by remember { mutableStateOf(userProxyServer?.takeIf { it.trim() != hostedProxyUrl } ?: "") } + var trustDialogVisible by remember { mutableStateOf(false) } + var proxyPresetsExpanded by remember { mutableStateOf(false) } + + val uriHandler = LocalUriHandler.current + + + fun selectOwnProxy() { + userAppPreferencesStore.userProxyServer = ownProxyServerDraft.ifBlank { null } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + + Text( + text = stringResource(Res.string.settings_proxy_server_info), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.widthIn(min = 350.dp).fillMaxWidth() + ) + + TextButton(onClick = { uriHandler.openUri(HttpClientFactory.PROXY_SETUP_INFO_URL) }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(stringResource(Res.string.settings_proxy_setup_instructions)) + Icon(Icons.AutoMirrored.Outlined.OpenInNew, null) + } + } + + ProxyOptionCard( + selected = !hostedProxySelected, + icon = Icons.Outlined.Dns, + title = stringResource(Res.string.settings_proxy_option_own), + info = stringResource(Res.string.settings_proxy_option_own_info), + badge = stringResource(Res.string.settings_proxy_option_own_recommended), + onClick = { selectOwnProxy() } + ) + + AnimatedVisibility(!hostedProxySelected) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.widthIn(min = 350.dp).fillMaxWidth().padding(start = 16.dp) + ) { + + OutlinedTextField( + value = ownProxyServerDraft, + onValueChange = { + ownProxyServerDraft = it + userAppPreferencesStore.userProxyServer = it.ifBlank { null } + }, + placeholder = { Text("https://") }, + supportingText = { + AnimatedVisibility(ownProxyServerDraft.trim().startsWith("http://")) { + Text( + text = stringResource(Res.string.insecure_connection_warning), + color = MaterialTheme.colorScheme.error + ) + } + }, + label = { Text(stringResource(Res.string.settings_proxy_server)) }, + // Presets for this field only - the hosted proxy is deliberately not among them, + // since selecting it has to go through the consent dialog. + trailingIcon = { + IconButton(onClick = { proxyPresetsExpanded = !proxyPresetsExpanded }) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = stringResource(Res.string.settings_proxy_presets) + ) + + DropdownMenu( + expanded = proxyPresetsExpanded, + onDismissRequest = { proxyPresetsExpanded = false } + ) { + DropdownMenuItem( + text = { + Column { + Text(stringResource(Res.string.settings_proxy_preset_local_development)) + Text( + text = HttpClientFactory.DEFAULT_WEB_PROXY_URL, + style = MaterialTheme.typography.bodySmall + ) + } + }, + onClick = { + ownProxyServerDraft = HttpClientFactory.DEFAULT_WEB_PROXY_URL + userAppPreferencesStore.userProxyServer = HttpClientFactory.DEFAULT_WEB_PROXY_URL + proxyPresetsExpanded = false + } + ) + } + } + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + ProxyOptionCard( + selected = hostedProxySelected, + icon = Icons.Outlined.Cloud, + title = stringResource(Res.string.settings_proxy_option_hosted), + info = stringResource(Res.string.settings_proxy_option_hosted_info), + badge = stringResource(Res.string.settings_proxy_option_hosted_badge), + badgeColor = MaterialTheme.colorScheme.error, + supportingText = hostedProxyUrl, + onClick = { + // Consent is per URL: an instance the user never agreed to always asks first. + if (hostedProxyConsentUrl == hostedProxyUrl) + userAppPreferencesStore.userProxyServer = hostedProxyUrl + else + trustDialogVisible = true + } + ) + + // While the hosted proxy is in use the disclosure stays on screen - consent is given once, + // but the user should never have to remember what they agreed to. + AnimatedVisibility(hostedProxySelected) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier.widthIn(min = 350.dp).fillMaxWidth().padding(start = 16.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Outlined.Warning, null) + Text( + text = stringResource(Res.string.settings_proxy_hosted_active_title), + style = MaterialTheme.typography.titleSmall + ) + } + Text( + text = stringResource(Res.string.settings_proxy_hosted_active_message, hostedProxyUrl), + style = MaterialTheme.typography.bodySmall + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val buttonColors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + TextButton( + onClick = { trustDialogVisible = true }, + colors = buttonColors + ) { + Text(stringResource(Res.string.settings_proxy_hosted_review)) + } + TextButton( + onClick = { selectOwnProxy() }, + colors = buttonColors + ) { + Text(stringResource(Res.string.settings_proxy_hosted_switch_to_own)) + } + } + } + } + } + } + + if (trustDialogVisible) { + ProxyTrustDialog( + proxyUrl = hostedProxyUrl, + initiallyAccepted = hostedProxySelected, + onConfirm = { + userAppPreferencesStore.hostedProxyConsentUrl = hostedProxyUrl + userAppPreferencesStore.userProxyServer = hostedProxyUrl + trustDialogVisible = false + }, + onDismiss = { trustDialogVisible = false }, + onOpenSelfHostingInfo = { uriHandler.openUri(HttpClientFactory.PROXY_SETUP_INFO_URL) } + ) + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProxyOptionCard( + selected: Boolean, + icon: ImageVector, + title: String, + info: String, + badge: String, + onClick: () -> Unit, + badgeColor: Color = MaterialTheme.colorScheme.primary, + supportingText: String? = null, + modifier: Modifier = Modifier +) { + OutlinedCard( + onClick = onClick, + colors = CardDefaults.outlinedCardColors( + containerColor = + if (selected) MaterialTheme.colorScheme.surfaceVariant + else MaterialTheme.colorScheme.surface + ), + modifier = modifier.widthIn(min = 350.dp).fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp) + ) { + RadioButton( + selected = selected, + onClick = onClick + ) + Icon(icon, null, tint = MaterialTheme.colorScheme.primary) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = badge, + style = MaterialTheme.typography.labelSmall, + color = badgeColor + ) + } + Text( + text = info, + style = MaterialTheme.typography.bodySmall + ) + supportingText?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Preview +@Composable +private fun ProxyServerSetup_Preview() { + AppTheme(spectacledVariant = SpectacledVariant.JOURNALS) { + Scaffold { + ProxyServerSetup( + userAppPreferencesStore = UserAppPreferencesStore.getEmptyPreferenceStoreForPreview(SpectacledVariant.JOURNALS) + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/ProxyTrustDialog.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/ProxyTrustDialog.kt new file mode 100644 index 00000000..938ea03f --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/ProxyTrustDialog.kt @@ -0,0 +1,147 @@ +package at.techbee.spectacled.screens.account.presentation.components.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.techbee.spectacled.SpectacledVariant +import at.techbee.spectacled.screens.core.data.HttpClientFactory +import at.techbee.spectacled.theme.AppTheme +import org.jetbrains.compose.resources.stringResource +import spectacled.shared.generated.resources.Res +import spectacled.shared.generated.resources.cancel +import spectacled.shared.generated.resources.proxy_trust_confirm_button +import spectacled.shared.generated.resources.proxy_trust_confirmation +import spectacled.shared.generated.resources.proxy_trust_intro +import spectacled.shared.generated.resources.proxy_trust_point_alternatives +import spectacled.shared.generated.resources.proxy_trust_point_credentials +import spectacled.shared.generated.resources.proxy_trust_point_promise +import spectacled.shared.generated.resources.proxy_trust_self_host_button +import spectacled.shared.generated.resources.proxy_trust_title + +/** + * Informed-consent dialog shown before the hosted CORS proxy is selected in the settings. + * + * The hosted proxy sees the CalDAV credentials of everyone who uses it, so switching to it must be a + * deliberate act: the confirm button stays disabled until the user ticks the acknowledgement, and the + * self-hosting alternative is offered right next to it. + * + * @param proxyUrl the hosted instance the user is about to trust + * @param initiallyAccepted pre-ticks the acknowledgement when the dialog is re-opened to review a + * consent that was already given + * @param onOpenSelfHostingInfo opens the self-hosting instructions (external link) + */ +@Composable +fun ProxyTrustDialog( + proxyUrl: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + onOpenSelfHostingInfo: () -> Unit, + initiallyAccepted: Boolean = false +) { + + var acknowledged by remember { mutableStateOf(initiallyAccepted) } + + AlertDialog( + icon = { Icon(Icons.Outlined.Key, null) }, + title = { Text(stringResource(Res.string.proxy_trust_title)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Text(stringResource(Res.string.proxy_trust_intro, proxyUrl)) + Text( + text = stringResource(Res.string.proxy_trust_point_credentials), + color = MaterialTheme.colorScheme.error + ) + Text(stringResource(Res.string.proxy_trust_point_promise)) + Text(stringResource(Res.string.proxy_trust_point_alternatives)) + + TextButton(onClick = { onOpenSelfHostingInfo() }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(Res.string.proxy_trust_self_host_button), + textAlign = TextAlign.Start + ) + Icon(Icons.AutoMirrored.Outlined.OpenInNew, null) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { acknowledged = !acknowledged } + ) { + Checkbox( + checked = acknowledged, + onCheckedChange = { acknowledged = it } + ) + Text( + text = stringResource(Res.string.proxy_trust_confirmation), + style = MaterialTheme.typography.bodyMedium + ) + } + } + }, + onDismissRequest = { onDismiss() }, + confirmButton = { + TextButton( + onClick = { onConfirm() }, + enabled = acknowledged + ) { + Text(stringResource(Res.string.proxy_trust_confirm_button)) + } + }, + dismissButton = { + TextButton(onClick = { onDismiss() }) { + Text(stringResource(Res.string.cancel)) + } + } + ) +} + + +@Preview +@Composable +private fun ProxyTrustDialog_Preview() { + AppTheme(spectacledVariant = SpectacledVariant.JOURNALS) { + Scaffold { + ProxyTrustDialog( + proxyUrl = HttpClientFactory.HOSTED_FLYIO_PROXY_URL, + onConfirm = {}, + onDismiss = {}, + onOpenSelfHostingInfo = {} + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/SettingsMorePage.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/SettingsMorePage.kt index 6131c0a6..11d77543 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/SettingsMorePage.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/SettingsMorePage.kt @@ -1,34 +1,18 @@ package at.techbee.spectacled.screens.account.presentation.components.settings -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.OpenInNew -import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import at.techbee.spectacled.SpectacledVariant @@ -38,10 +22,8 @@ import at.techbee.spectacled.screens.core.getPlatform import at.techbee.spectacled.theme.AppTheme import org.jetbrains.compose.resources.stringResource import spectacled.shared.generated.resources.Res -import spectacled.shared.generated.resources.insecure_connection_warning import spectacled.shared.generated.resources.more import spectacled.shared.generated.resources.settings_proxy_server -import spectacled.shared.generated.resources.settings_proxy_server_info @OptIn(ExperimentalMaterial3Api::class) @@ -51,11 +33,6 @@ fun SettingsMorePage( modifier: Modifier = Modifier ) { - var userProxyServerDropdownExpanded by remember { mutableStateOf(false) } - val userProxyServer by userAppPreferencesStore.getUserProxyServerAsFlow().collectAsState(userAppPreferencesStore.userProxyServer) - - val uriHandler = LocalUriHandler.current - Column( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -69,80 +46,14 @@ fun SettingsMorePage( ) if (getPlatform().platform == Platforms.WASM || LocalInspectionMode.current) { - OutlinedTextField( - value = userProxyServer ?: "", - onValueChange = { userAppPreferencesStore.userProxyServer = it.ifBlank { null } }, - placeholder = { Text("https://") }, - supportingText = { - val trimmedServer = userProxyServer?.trim() ?: "" - val isInsecure = trimmedServer.startsWith("http://") - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - AnimatedVisibility(isInsecure) { - Text( - text = stringResource(Res.string.insecure_connection_warning), - color = MaterialTheme.colorScheme.error - ) - } - Text(stringResource(Res.string.settings_proxy_server_info)) - - TextButton( - onClick = { - uriHandler.openUri("https://github.com/TechbeeAT/spectacled/tree/main/server") - } - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text("Proxy setup info") - Icon(Icons.AutoMirrored.Outlined.OpenInNew, null) - } - } - } - }, - label = { Text(stringResource(Res.string.settings_proxy_server)) }, - trailingIcon = { - TextButton( - onClick = { userProxyServerDropdownExpanded = !userProxyServerDropdownExpanded }, - ) { - Icon(Icons.Outlined.MoreVert, null) - - DropdownMenu( - expanded = userProxyServerDropdownExpanded, - onDismissRequest = { userProxyServerDropdownExpanded = false } - ) { - DropdownMenuItem( - text = { - Column { - Text("Development test") - Text("http://localhost:8088") - } - }, - onClick = { - userAppPreferencesStore.userProxyServer = "http://localhost:8088" - userProxyServerDropdownExpanded = false - } - ) - - DropdownMenuItem( - text = { - Column { - Text("spectacled Proxy on Fly.io") - Text("https://spectacled-proxy.fly.dev") - } - }, - onClick = { - userAppPreferencesStore.userProxyServer = "https://spectacled-proxy.fly.dev" - userProxyServerDropdownExpanded = false - } - ) - } - } - }, + Text( + text = stringResource(Res.string.settings_proxy_server), + style = MaterialTheme.typography.titleMedium, modifier = Modifier.widthIn(min = 350.dp).fillMaxWidth() ) + + ProxyServerSetup(userAppPreferencesStore) } } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/HttpClientFactory.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/HttpClientFactory.kt index 1144bcbd..70711e59 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/HttpClientFactory.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/HttpClientFactory.kt @@ -1,8 +1,6 @@ package at.techbee.spectacled.screens.core.data -import at.techbee.spectacled.screens.core.Platforms -import at.techbee.spectacled.screens.core.getPlatform import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine @@ -11,26 +9,33 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging -import io.ktor.http.HttpHeaders import io.ktor.client.request.HttpRequestPipeline +import io.ktor.http.HttpHeaders import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json object HttpClientFactory { - /** Proxy the web (WASM) build falls back to when the user hasn't configured one. */ + /** Proxy for the web (WASM) build if the server is started locally. */ const val DEFAULT_WEB_PROXY_URL = "http://localhost:8088" - /** The proxy URL to use on the current platform when no user setting is present. */ - fun defaultProxyUrl(): String? = - if (getPlatform().platform == Platforms.WASM) DEFAULT_WEB_PROXY_URL else null + /** + * Instance of [the CORS proxy](https://github.com/TechbeeAT/spectacled/tree/main/server) that Techbee + * hosts for users who can't run their own. It terminates TLS and therefore sees the CalDAV credentials + * of everyone using it, so it must only ever be set after the user confirmed the trust dialog + * (see `ProxyTrustDialog` / `UserAppPreferencesStore.hostedProxyConsentUrl`). + */ + const val HOSTED_FLYIO_PROXY_URL = "https://spectacled-proxy.fly.dev" + + /** Where the self-hosting instructions for the proxy live. */ + const val PROXY_SETUP_INFO_URL = "https://github.com/TechbeeAT/spectacled/tree/main/server" fun create( engine: HttpClientEngine, jsonContentNegotiation: Boolean = true, // Resolved per request so the in-app "Proxy server" setting takes effect without an app restart. - proxyUrlProvider: () -> String? = { defaultProxyUrl() } + proxyUrlProvider: () -> String? = { null } ): HttpClient { return HttpClient(engine) { followRedirects = false diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/UserAppPreferencesStore.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/UserAppPreferencesStore.kt index 3b274fcb..6f9d615c 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/UserAppPreferencesStore.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/data/UserAppPreferencesStore.kt @@ -31,6 +31,7 @@ const val THEME_FONT = "theme_font" const val CLAUDE_USER_API_KEY = "claude_user_api_key" const val USER_PROXY_SERVER = "user_proxy_server" +const val HOSTED_PROXY_CONSENT_URL = "hosted_proxy_consent_url" const val AI_PROVIDER = "ai_provider" const val CLAUDE_MODEL = "claude_model" @@ -122,6 +123,16 @@ interface UserAppPreferencesStore { set(value) = if(value == null) this.remove(USER_PROXY_SERVER) else this.save(USER_PROXY_SERVER, value) fun getUserProxyServerAsFlow(): Flow = this.loadAsFlow(USER_PROXY_SERVER) + /** + * The hosted proxy URL the user explicitly agreed to send their credentials through, or `null` if they + * never did. Stored as the URL (not a boolean) so that consent given for one instance doesn't silently + * carry over to another one if [HttpClientFactory.HOSTED_FLYIO_PROXY_URL] ever changes. + */ + var hostedProxyConsentUrl: String? + get() = this.load(HOSTED_PROXY_CONSENT_URL)?.ifEmpty { null } + set(value) = if(value.isNullOrBlank()) this.remove(HOSTED_PROXY_CONSENT_URL) else this.save(HOSTED_PROXY_CONSENT_URL, value) + fun getHostedProxyConsentUrlAsFlow(): Flow = this.loadAsFlow(HOSTED_PROXY_CONSENT_URL) + /** Which backend fulfils the "derive entries from text" AI feature. Defaults to [AiProvider.CLAUDE]. */ var aiProvider: AiProvider get() = AiProvider.fromString(this.load(AI_PROVIDER)) diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt index 32697a42..36a16e5d 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt @@ -27,8 +27,9 @@ val sharedModule = module { val preferences = get() HttpClientFactory.create( engine = getPlatformEngine(), - // Prefer the user-configured proxy, falling back to the platform default (web only). - proxyUrlProvider = { preferences.userProxyServer ?: HttpClientFactory.defaultProxyUrl() } + // No proxy until the user picks one: on the web that makes an unconfigured app fail + // visibly at the browser instead of quietly aiming at a localhost that is not running. + proxyUrlProvider = { preferences.userProxyServer } ) } singleOf(::CalendarRepositoryImpl) { bind() }