From 0d74e698a9fe878dd999826e3851a264d9ae088a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 09:33:14 +0000 Subject: [PATCH 01/10] Gate the hosted proxy behind an explicit trust disclosure The web build needs a CORS proxy, and the "spectacled Proxy on Fly.io" entry in the settings dropdown could be picked with a single tap - even though that proxy terminates TLS and therefore sees the CalDAV credentials of everyone who uses it. Settings -> More now presents the proxy as a choice between two named options instead of a dropdown of URLs: - "My own proxy server" (recommended, default), with the URL field and the localhost/setup shortcuts underneath it, - "Spectacled proxy (hosted by us)", badged "Sees your credentials". Picking the hosted one opens ProxyTrustDialog, which states what the operator can see, what we do and don't log, that this is unverifiable from the outside, and the safer alternatives (self-hosting, an app-specific password). Its confirm button stays disabled until the acknowledgement checkbox is ticked. Consent is stored as the URL it was given for, so it never carries over silently to another instance, and while the hosted proxy is active the settings page keeps the disclosure on screen with shortcuts to review it or switch back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NwACpV4rjwUXSZaBpZGLiX --- README.md | 3 +- server/README.md | 11 +- .../composeResources/values/strings.xml | 22 ++ .../components/settings/ProxyTrustDialog.kt | 146 +++++++++ .../components/settings/SettingsMorePage.kt | 299 ++++++++++++++---- .../screens/core/data/HttpClientFactory.kt | 11 + .../core/data/UserAppPreferencesStore.kt | 11 + 7 files changed, 435 insertions(+), 68 deletions(-) create mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/ProxyTrustDialog.kt 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..759cba84 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -382,6 +382,28 @@ 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 + Use local development proxy (%1$s) + 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 + + 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/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..819f2927 --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/ProxyTrustDialog.kt @@ -0,0 +1,146 @@ +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), + 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_WEB_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..8f6101c6 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 @@ -9,13 +9,18 @@ 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.material.icons.outlined.Cloud +import androidx.compose.material.icons.outlined.Dns +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.ExperimentalMaterial3Api import androidx.compose.material3.Icon 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 @@ -27,12 +32,15 @@ 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.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 import at.techbee.spectacled.screens.core.Platforms +import at.techbee.spectacled.screens.core.data.HttpClientFactory import at.techbee.spectacled.screens.core.data.UserAppPreferencesStore import at.techbee.spectacled.screens.core.getPlatform import at.techbee.spectacled.theme.AppTheme @@ -40,8 +48,20 @@ 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_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_server import spectacled.shared.generated.resources.settings_proxy_server_info +import spectacled.shared.generated.resources.settings_proxy_setup_instructions +import spectacled.shared.generated.resources.settings_proxy_use_localhost @OptIn(ExperimentalMaterial3Api::class) @@ -51,11 +71,30 @@ fun SettingsMorePage( modifier: Modifier = Modifier ) { - var userProxyServerDropdownExpanded by remember { mutableStateOf(false) } val userProxyServer by userAppPreferencesStore.getUserProxyServerAsFlow().collectAsState(userAppPreferencesStore.userProxyServer) + val hostedProxyConsentUrl by userAppPreferencesStore.getHostedProxyConsentUrlAsFlow().collectAsState(userAppPreferencesStore.hostedProxyConsentUrl) + + val hostedProxyUrl = HttpClientFactory.HOSTED_WEB_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) } val uriHandler = LocalUriHandler.current + fun selectHostedProxy() { + // Consent is per URL: an instance the user never agreed to always asks first. + if (hostedProxyConsentUrl == hostedProxyUrl) + userAppPreferencesStore.userProxyServer = hostedProxyUrl + else + trustDialogVisible = true + } + + fun selectOwnProxy() { + userAppPreferencesStore.userProxyServer = ownProxyServerDraft.ifBlank { null } + } + Column( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -69,80 +108,210 @@ 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) + Text( + text = stringResource(Res.string.settings_proxy_server), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.widthIn(min = 350.dp).fillMaxWidth() + ) + Text( + text = stringResource(Res.string.settings_proxy_server_info), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.widthIn(min = 350.dp).fillMaxWidth() + ) + + 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( + verticalArrangement = Arrangement.spacedBy(4.dp), + 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)) }, + modifier = Modifier.fillMaxWidth() + ) - } - } - }, - label = { Text(stringResource(Res.string.settings_proxy_server)) }, - trailingIcon = { TextButton( - onClick = { userProxyServerDropdownExpanded = !userProxyServerDropdownExpanded }, + onClick = { + ownProxyServerDraft = HttpClientFactory.DEFAULT_WEB_PROXY_URL + userAppPreferencesStore.userProxyServer = HttpClientFactory.DEFAULT_WEB_PROXY_URL + } ) { - Icon(Icons.Outlined.MoreVert, null) + Text(stringResource(Res.string.settings_proxy_use_localhost, HttpClientFactory.DEFAULT_WEB_PROXY_URL)) + } + + 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.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 = { selectHostedProxy() } + ) - DropdownMenu( - expanded = userProxyServerDropdownExpanded, - onDismissRequest = { userProxyServerDropdownExpanded = false } + // 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) ) { - DropdownMenuItem( - text = { - Column { - Text("Development test") - Text("http://localhost:8088") - } - }, - onClick = { - userAppPreferencesStore.userProxyServer = "http://localhost:8088" - userProxyServerDropdownExpanded = false - } + 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 + ) - 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 - } + 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)) + } } } - }, - modifier = Modifier.widthIn(min = 350.dp).fillMaxWidth() + } + } + } + } + + 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 + ) + } + } } } } 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..01876a95 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 @@ -22,6 +22,17 @@ object HttpClientFactory { /** Proxy the web (WASM) build falls back to when the user hasn't configured one. */ const val DEFAULT_WEB_PROXY_URL = "http://localhost:8088" + /** + * 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_WEB_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" + /** 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 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..a234f614 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_WEB_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)) From 4f53be9f3104c468d78f35c2257ffb912fa5ec74 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 09:45:45 +0000 Subject: [PATCH 02/10] Drop the stale ws@8.18.0 entry from the Wasm yarn lock Unit tests have been failing on main since eb8f4d8, first at :kotlinStoreYarnLock and, once 1020b01 fixed the JS lock, at :kotlinWasmStoreYarnLock - both with "Lock file was changed". The failure reproduces on main's own head (8b4d034) and has nothing to do with this branch, which touches no npm dependency. kotlin-js-store/wasm/yarn.lock carries the same leftover entry that 1020b01 removed from kotlin-js-store/yarn.lock: an exact-pinned ws@8.18.0 that no dependency in the file requests at any range, left behind when the pin moved to 8.20.1. This applies that same deletion to the Wasm lock. Regenerating it properly (./gradlew kotlinWasmUpgradeYarnLock) was not possible in the environment this was written in, so CI is the check on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NwACpV4rjwUXSZaBpZGLiX --- kotlin-js-store/wasm/yarn.lock | 5 ----- 1 file changed, 5 deletions(-) diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock index 056907f6..c60eb864 100644 --- a/kotlin-js-store/wasm/yarn.lock +++ b/kotlin-js-store/wasm/yarn.lock @@ -279,11 +279,6 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -ws@8.18.0: - version "8.18.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" - integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== - ws@8.20.1: version "8.20.1" resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" From 96f9aea90da70555b4e0fdc6fd84342d39241c22 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 10:17:40 +0000 Subject: [PATCH 03/10] Move the localhost preset into a dropdown on the proxy field The "Use local development proxy" text button spent permanent vertical space on a dev-only affordance. A trailing-icon menu on the field is the usual place for presets that fill it, and it has room for more entries if another preset ever shows up. Only own-server presets live in it. The hosted proxy stays out: putting it one tap away in a menu is exactly what this branch replaced, since selecting it has to go through the consent dialog. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NwACpV4rjwUXSZaBpZGLiX --- .../composeResources/values/strings.xml | 3 +- .../components/settings/SettingsMorePage.kt | 49 +++++++++++++++---- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 759cba84..dc0a0c27 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -388,7 +388,8 @@ No setup needed, but your CalDAV user name and password pass through a server we operate. Sees your credentials Recommended - Use local development proxy (%1$s) + 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. 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 8f6101c6..5eb2fed3 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 @@ -11,12 +11,16 @@ 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 @@ -60,8 +64,9 @@ 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_server import spectacled.shared.generated.resources.settings_proxy_server_info +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_setup_instructions -import spectacled.shared.generated.resources.settings_proxy_use_localhost @OptIn(ExperimentalMaterial3Api::class) @@ -80,6 +85,7 @@ fun SettingsMorePage( // 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 @@ -150,18 +156,41 @@ fun SettingsMorePage( } }, 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() ) - TextButton( - onClick = { - ownProxyServerDraft = HttpClientFactory.DEFAULT_WEB_PROXY_URL - userAppPreferencesStore.userProxyServer = HttpClientFactory.DEFAULT_WEB_PROXY_URL - } - ) { - Text(stringResource(Res.string.settings_proxy_use_localhost, HttpClientFactory.DEFAULT_WEB_PROXY_URL)) - } - TextButton(onClick = { uriHandler.openUri(HttpClientFactory.PROXY_SETUP_INFO_URL) }) { Row( verticalAlignment = Alignment.CenterVertically, From af88c6c45b289ace9bf207f50f2e5fb534e0ec2c Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:18:12 +0200 Subject: [PATCH 04/10] Layout update in ProxyTrustDialog --- .../account/presentation/components/settings/ProxyTrustDialog.kt | 1 + 1 file changed, 1 insertion(+) 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 index 819f2927..1aae3e3c 100644 --- 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 @@ -71,6 +71,7 @@ fun ProxyTrustDialog( text = { Column( verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.verticalScroll(rememberScrollState()) ) { Text(stringResource(Res.string.proxy_trust_intro, proxyUrl)) From c456408a23a0341550f49e038a9fdd1c9bc5a114 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:25:32 +0200 Subject: [PATCH 05/10] Moved proxy setup info to top --- .../components/settings/SettingsMorePage.kt | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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 5eb2fed3..8c996695 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 @@ -126,6 +126,16 @@ fun SettingsMorePage( 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, @@ -137,9 +147,10 @@ fun SettingsMorePage( AnimatedVisibility(!hostedProxySelected) { Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.widthIn(min = 350.dp).fillMaxWidth().padding(start = 16.dp) ) { + OutlinedTextField( value = ownProxyServerDraft, onValueChange = { @@ -190,16 +201,6 @@ fun SettingsMorePage( }, modifier = Modifier.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) - } - } } } From 480f19acec42922e8e19465189e74d107cb040f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 10:37:47 +0000 Subject: [PATCH 06/10] Require a proxy before the add-account options on web On the web build every CalDAV request goes through the proxy, so a user who reaches the add-account sheet without one configured can pick either option and only find out at the first request - which fails looking like an unreachable server rather than a missing setup step. The selection page now asks that question first on WASM: an explanatory card carrying the proxy picker, with both option cards disabled until a proxy is set. Once it is, the card collapses to a one-line confirmation with a Change affordance, so returning users and every native platform see the flow exactly as before. The picker itself moves out of SettingsMorePage into ProxyServerSetup so both entry points share one implementation. That matters beyond reuse: a second copy of this UI would be a second chance to get the hosted proxy selected without its consent dialog. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NwACpV4rjwUXSZaBpZGLiX --- .../composeResources/values/strings.xml | 5 + .../presentation/AccountListScreenRoot.kt | 1 + .../components/AddPrincipalBottomSheet.kt | 69 ++++ .../components/settings/ProxyServerSetup.kt | 350 ++++++++++++++++++ .../components/settings/SettingsMorePage.kt | 290 +-------------- 5 files changed, 426 insertions(+), 289 deletions(-) create mode 100644 shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/ProxyServerSetup.kt diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index dc0a0c27..adcac0a9 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -396,6 +396,11 @@ 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. 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..80a84d19 --- /dev/null +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/ProxyServerSetup.kt @@ -0,0 +1,350 @@ +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_WEB_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 selectHostedProxy() { + // Consent is per URL: an instance the user never agreed to always asks first. + if (hostedProxyConsentUrl == hostedProxyUrl) + userAppPreferencesStore.userProxyServer = hostedProxyUrl + else + trustDialogVisible = true + } + + 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 = { selectHostedProxy() } + ) + + // 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/SettingsMorePage.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/account/presentation/components/settings/SettingsMorePage.kt index 8c996695..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,72 +1,29 @@ 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.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 import at.techbee.spectacled.screens.core.Platforms -import at.techbee.spectacled.screens.core.data.HttpClientFactory import at.techbee.spectacled.screens.core.data.UserAppPreferencesStore 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_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_server -import spectacled.shared.generated.resources.settings_proxy_server_info -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_setup_instructions @OptIn(ExperimentalMaterial3Api::class) @@ -76,31 +33,6 @@ fun SettingsMorePage( modifier: Modifier = Modifier ) { - val userProxyServer by userAppPreferencesStore.getUserProxyServerAsFlow().collectAsState(userAppPreferencesStore.userProxyServer) - val hostedProxyConsentUrl by userAppPreferencesStore.getHostedProxyConsentUrlAsFlow().collectAsState(userAppPreferencesStore.hostedProxyConsentUrl) - - val hostedProxyUrl = HttpClientFactory.HOSTED_WEB_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 selectHostedProxy() { - // Consent is per URL: an instance the user never agreed to always asks first. - if (hostedProxyConsentUrl == hostedProxyUrl) - userAppPreferencesStore.userProxyServer = hostedProxyUrl - else - trustDialogVisible = true - } - - fun selectOwnProxy() { - userAppPreferencesStore.userProxyServer = ownProxyServerDraft.ifBlank { null } - } - Column( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -120,228 +52,8 @@ fun SettingsMorePage( style = MaterialTheme.typography.titleMedium, modifier = Modifier.widthIn(min = 350.dp).fillMaxWidth() ) - 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 = { selectHostedProxy() } - ) - - // 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 - ) - } - } + ProxyServerSetup(userAppPreferencesStore) } } } From e1be6e6afb9e0186b545db14926cac9225087ac3 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:39:27 +0200 Subject: [PATCH 07/10] Use no proxy if it's not configured --- .../screens/core/data/HttpClientFactory.kt | 12 +++--------- .../techbee/spectacled/screens/core/koin/Modules.kt | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) 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 01876a95..0b7aaa7c 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,15 +9,15 @@ 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" /** @@ -33,15 +31,11 @@ object HttpClientFactory { /** Where the self-hosting instructions for the proxy live. */ const val PROXY_SETUP_INFO_URL = "https://github.com/TechbeeAT/spectacled/tree/main/server" - /** 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 - 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/koin/Modules.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt index 32697a42..8b44fdec 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 @@ -28,7 +28,7 @@ val sharedModule = module { HttpClientFactory.create( engine = getPlatformEngine(), // Prefer the user-configured proxy, falling back to the platform default (web only). - proxyUrlProvider = { preferences.userProxyServer ?: HttpClientFactory.defaultProxyUrl() } + proxyUrlProvider = { preferences.userProxyServer } ) } singleOf(::CalendarRepositoryImpl) { bind() } From c555c46a6bd2ff38ce54ccc6182c8776d16a8a6e Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:44:01 +0200 Subject: [PATCH 08/10] Renamed constant --- .../presentation/components/settings/ProxyServerSetup.kt | 2 +- .../presentation/components/settings/ProxyTrustDialog.kt | 2 +- .../techbee/spectacled/screens/core/data/HttpClientFactory.kt | 2 +- .../spectacled/screens/core/data/UserAppPreferencesStore.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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 index 80a84d19..200a8ab6 100644 --- 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 @@ -84,7 +84,7 @@ fun ProxyServerSetup( val userProxyServer by userAppPreferencesStore.getUserProxyServerAsFlow().collectAsState(userAppPreferencesStore.userProxyServer) val hostedProxyConsentUrl by userAppPreferencesStore.getHostedProxyConsentUrlAsFlow().collectAsState(userAppPreferencesStore.hostedProxyConsentUrl) - val hostedProxyUrl = HttpClientFactory.HOSTED_WEB_PROXY_URL + 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. 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 index 1aae3e3c..938ea03f 100644 --- 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 @@ -137,7 +137,7 @@ private fun ProxyTrustDialog_Preview() { AppTheme(spectacledVariant = SpectacledVariant.JOURNALS) { Scaffold { ProxyTrustDialog( - proxyUrl = HttpClientFactory.HOSTED_WEB_PROXY_URL, + proxyUrl = HttpClientFactory.HOSTED_FLYIO_PROXY_URL, onConfirm = {}, onDismiss = {}, onOpenSelfHostingInfo = {} 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 0b7aaa7c..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 @@ -26,7 +26,7 @@ object HttpClientFactory { * 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_WEB_PROXY_URL = "https://spectacled-proxy.fly.dev" + 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" 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 a234f614..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 @@ -126,7 +126,7 @@ interface UserAppPreferencesStore { /** * 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_WEB_PROXY_URL] ever changes. + * 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 } From d92a62501608a907d58f9de1c5df6c9e13f41ba5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:47:39 +0000 Subject: [PATCH 09/10] Correct the proxy comment left by the fallback removal e1be6e6 dropped defaultProxyUrl(), so there is no platform default to fall back to any more - but the comment above proxyUrlProvider still described one. Say what the code now does, and why that is the point: an unconfigured web build fails at the browser rather than at a localhost address nobody is listening on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NwACpV4rjwUXSZaBpZGLiX --- .../kotlin/at/techbee/spectacled/screens/core/koin/Modules.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 8b44fdec..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,7 +27,8 @@ val sharedModule = module { val preferences = get() HttpClientFactory.create( engine = getPlatformEngine(), - // Prefer the user-configured proxy, falling back to the platform default (web only). + // 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 } ) } From 0cdcb639a4038cbbec79852c7abfa8854eb87b6b Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:59:06 +0200 Subject: [PATCH 10/10] Structural update for better code readability --- .../components/settings/ProxyServerSetup.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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 index 200a8ab6..4f58ee1e 100644 --- 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 @@ -94,13 +94,6 @@ fun ProxyServerSetup( val uriHandler = LocalUriHandler.current - fun selectHostedProxy() { - // Consent is per URL: an instance the user never agreed to always asks first. - if (hostedProxyConsentUrl == hostedProxyUrl) - userAppPreferencesStore.userProxyServer = hostedProxyUrl - else - trustDialogVisible = true - } fun selectOwnProxy() { userAppPreferencesStore.userProxyServer = ownProxyServerDraft.ifBlank { null } @@ -204,7 +197,13 @@ fun ProxyServerSetup( badge = stringResource(Res.string.settings_proxy_option_hosted_badge), badgeColor = MaterialTheme.colorScheme.error, supportingText = hostedProxyUrl, - onClick = { selectHostedProxy() } + 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,