Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions shared/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,34 @@
<string name="settings_proxy_server">Proxy server</string>
<string name="settings_proxy_server_info">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.</string>

<string name="settings_proxy_option_own">My own proxy server</string>
<string name="settings_proxy_option_own_info">You run the proxy, so nobody else is in the path. Recommended.</string>
<string name="settings_proxy_option_hosted">Spectacled proxy (hosted by us)</string>
<string name="settings_proxy_option_hosted_info">No setup needed, but your CalDAV user name and password pass through a server we operate.</string>
<string name="settings_proxy_option_hosted_badge">Sees your credentials</string>
<string name="settings_proxy_option_own_recommended">Recommended</string>
<string name="settings_proxy_presets">Proxy presets</string>
<string name="settings_proxy_preset_local_development">Local development server</string>
<string name="settings_proxy_setup_instructions">Proxy setup info</string>
<string name="settings_proxy_hosted_active_title">Your credentials pass through our server</string>
<string name="settings_proxy_hosted_active_message">The web app sends every CalDAV request - including your user name and password - through %1$s, which is operated by Techbee.</string>
<string name="settings_proxy_hosted_review">What this means</string>
<string name="settings_proxy_hosted_switch_to_own">Switch to my own server</string>

<string name="add_account_proxy_required_title">First: choose a proxy server</string>
<string name="add_account_proxy_required_info">The web version reaches CalDAV servers through a proxy. Pick one here before connecting an account - without it, the app cannot reach any server.</string>
<string name="add_account_proxy_ready">Proxy: %1$s</string>
<string name="add_account_proxy_change">Change</string>

<string name="proxy_trust_title">Use the proxy we host?</string>
<string name="proxy_trust_intro">%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.</string>
<string name="proxy_trust_point_credentials">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.</string>
<string name="proxy_trust_point_promise">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.</string>
<string name="proxy_trust_point_alternatives">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.</string>
<string name="proxy_trust_confirmation">I understand that my CalDAV credentials pass through a server operated by Techbee.</string>
<string name="proxy_trust_confirm_button">Use hosted proxy</string>
<string name="proxy_trust_self_host_button">How to host it myself</string>

<string name="widget_configuration">Widget configuration</string>

<!-- Label/value pair, e.g. "Created: 5 Jan 2026". Some locales space the colon differently. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -111,6 +122,7 @@ fun AddPrincipalBottomSheet(
sheetState: SheetState,
processingState: ProcessingState,
isFirstAccount: Boolean,
userAppPreferencesStore: UserAppPreferencesStore,
onAction: (AccountListAction.OnAddPrincipal) -> Unit,
onDismiss: () -> Unit,
spectacledVariant: SpectacledVariant = koinInject()
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) }
) {

Expand Down Expand Up @@ -323,6 +389,7 @@ fun SelectAccountOptionScreen(


ElevatedCard(
enabled = !proxyRequired || proxyConfigured,
onClick = { onPageChanged(AddPrincipalBottomSheetPage.SELECT_FROM_LIST) }
) {

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading