Skip to content
Open
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
4 changes: 2 additions & 2 deletions PLATFORMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This document defines the platform boundary for Nextcloud Native. It separates
portable product behavior from operating-system integration so shared code does
not erase native security, lifecycle, accessibility, or filesystem semantics.

**Last reviewed: 2026-08-20.** Implementation and release availability may
**Last reviewed: 2026-09-01.** Implementation and release availability may
have changed. The [GitHub Releases page](https://github.com/Obiente/nc-native/releases)
is the source of truth for published artifacts and their limitations.

Expand All @@ -28,7 +28,7 @@ its platform acceptance criteria pass and its limitations are documented.
| Android | Compose Multiplatform | Active launcher and signed alpha APK/AAB | Keystore, WorkManager, DocumentsProvider, permissions, notifications, shares, media sessions, camera backup, and calls |
| Linux | Compose Desktop | Primary interactive desktop target; alpha RPM/DEB | Secret Service, desktop file integration, notifications, media keys, portals, and conventional sync roots |
| Windows | Compose Desktop | Unsigned x86-64 MSI with Credential Manager, attested builds, and Cloud Files integration under prerelease qualification | Explorer validation, free trusted signing when available, notifications, media controls, and updates |
| macOS | Compose Desktop | Early DMG packaging artifact; no supported authenticated login yet | Keychain, File Provider/Finder integration, notifications, media controls, and updates |
| macOS | Compose Desktop | Early DMG packaging artifact; Keychain storage is source-tested, but authenticated use has not been live-validated or qualified | Keychain, File Provider/Finder integration, notifications, media controls, and updates |
| iOS / iPadOS | Planned Compose target | No supported launcher is shipped | Keychain, File Provider, background transfer, share extension, notifications, media, and CallKit |

Packaging is not feature parity. A platform becomes supported for a workflow
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,16 +159,16 @@ boundaries.

## Implemented alpha surfaces

**Last reviewed: 2026-08-20.** Repository implementation may have changed. The
**Last reviewed: 2026-09-01.** Repository implementation may have changed. The
[default branch](https://github.com/Obiente/nc-native/tree/main) is the source of
truth for current code. A listed surface can still have platform, version,
action, or lifecycle limitations and is not a shipped-support guarantee.

The repository already contains runnable Android and Linux desktop
applications with:

- Nextcloud Login Flow v2 with Android Keystore and Linux Secret Service
credential storage;
- Nextcloud Login Flow v2 with Android Keystore, Linux Secret Service, Windows
Credential Manager, and source-tested macOS Keychain credential storage;
- authenticated native Files browsing, list/grid layouts, previews, sharing
foundations, text editing, and media viewing;
- Photos and Memories collections, albums, tags, people, favorites, RAW/JPEG
Expand Down Expand Up @@ -220,7 +220,7 @@ The dependency gates and data-safety criteria are in

## Platform status

**Last reviewed: 2026-08-20.** Platform availability may have changed. The
**Last reviewed: 2026-09-01.** Platform availability may have changed. The
[GitHub Releases page](https://github.com/Obiente/nc-native/releases) is the
source of truth for published artifacts and limitations. This table is not a
stable-support guarantee.
Expand All @@ -230,7 +230,7 @@ stable-support guarantee.
| Android | Active application target with signed APK/AAB prereleases; hosted CI covers unit tests and packaging, while connected-device instrumentation remains separate |
| Linux | Primary interactive desktop development target, distributable plus RPM/DEB prereleases |
| Windows | x86-64 MSI, native Credential Manager login storage, and Cloud Files sync under active prerelease qualification |
| macOS | Early DMG packaging artifact; native Keychain login storage and supported authenticated use are not implemented yet |
| macOS | Early DMG packaging artifact; native Keychain storage is covered by deterministic source tests, but authenticated use has not been live-validated or qualified |
| iOS / iPadOS | Planned platform target; no supported launcher is shipped yet |

Android and desktop already share domain models, semantic components, and
Expand Down Expand Up @@ -278,8 +278,9 @@ Android release artifacts are signed with the project's protected release key.
Desktop packages are provided per successful platform build. Windows MSI
packages use native Credential Manager storage, include keyless GitHub build
provenance, and are currently unsigned, so SmartScreen may require choosing
`More info > Run anyway`. macOS packages still prove packaging only and do not
yet have native Keychain login integration.
`More info > Run anyway`. The source includes deterministically tested macOS
Keychain integration, but the macOS package remains a packaging artifact until
authenticated use passes a live macOS acceptance run.
Read each release's known limitations before installing over an existing test
build.

Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/335-macos-keychain-secret-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: 335
pull: 430
platforms: macos
user-facing: yes

Store desktop login credentials and Deck draft keys in macOS Keychain, migrate Secret Service values, and keep locked access and sign-out cleanup retryable. Preserve encrypted Deck drafts instead of replacing a missing key.
2 changes: 1 addition & 1 deletion tools/kotlin-file-size-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface.
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1937
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12436
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12435
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1755
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,15 +534,42 @@ fun NextcloudNativeApp(

NextcloudNativeTheme(darkTheme = darkTheme) {
NextcloudAppBackground {
var session by remember { mutableStateOf(services.loadSession()) }
if (session == null) {
var sessionLoadAttempt by remember { mutableStateOf(0) }
val sessionLoad = remember(services, sessionLoadAttempt) {
loadNextcloudSessionSafely(services::loadSession)
Comment thread
veryCrunchy marked this conversation as resolved.
}
var session by remember(services, sessionLoadAttempt) {
mutableStateOf((sessionLoad as? NextcloudSessionLoadState.Loaded)?.session)
}
val signInAgain = {
scope.launch {
try {
services.clearSession()
sessionLoadAttempt += 1
} catch (failure: CancellationException) {
throw failure
} catch (_: NextcloudSessionStorageUnavailableException) {
// Keep the recoverable storage screen visible when cleanup could not be queued safely.
}
}
Unit
}
if (sessionLoad == NextcloudSessionLoadState.SecureStorageUnavailable) {
SecureSessionStorageUnavailable(
onRetry = { sessionLoadAttempt += 1 },
onSignInAgain = signInAgain,
)
} else if (sessionLoad == NextcloudSessionLoadState.LegacyMigrationUnavailable) {
LegacySessionMigrationUnavailable(
onRetry = { sessionLoadAttempt += 1 },
onSignInAgain = signInAgain,
)
} else if (session == null) {
if (pendingAppUpdateReviewRequest != null) {
LoggedOutAppUpdateReviewScreen(
services = services,
platformCapabilityRefreshRequest = platformCapabilityRefreshRequest,
onContinueToSignIn = {
handledAppUpdateReviewRequest = pendingAppUpdateReviewRequest
},
onContinueToSignIn = { handledAppUpdateReviewRequest = pendingAppUpdateReviewRequest },
)
} else {
LoginScreen(
Expand Down Expand Up @@ -12367,34 +12394,6 @@ internal fun SectionTitle(text: String, modifier: Modifier = Modifier) {
Text(text, modifier = modifier, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary)
}

@Composable
internal fun LoadingMessage(message: String) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
Text(message, modifier = Modifier.padding(top = NextcloudSpacing.Large))
}
}

@Composable
internal fun EmptyMessage(message: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(message, modifier = Modifier.padding(NextcloudSpacing.XLarge), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}

@Composable
internal fun ErrorMessage(message: String, onRetry: (() -> Unit)? = null) {
Column(modifier = Modifier.padding(NextcloudSpacing.XLarge), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(NextcloudIcons.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Text(message, color = MaterialTheme.colorScheme.error)
onRetry?.let { retry -> OutlinedButton(onClick = retry) { Text("Try again") } }
}
}

@Composable
private fun RetainedRefreshError(message: String, onRetry: () -> Unit, modifier: Modifier = Modifier) {
RetainedContentNotice(message, onRetry, modifier.padding(horizontal = NextcloudSpacing.Large, vertical = NextcloudSpacing.Small))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package dev.obiente.nextcloudnative.app

import kotlinx.coroutines.CancellationException

internal open class NextcloudSessionStorageUnavailableException(
message: String,
cause: Throwable? = null,
) : IllegalStateException(message, cause)

internal class NextcloudSessionLegacyMigrationUnavailableException(
cause: Throwable,
) : NextcloudSessionStorageUnavailableException(
"The legacy secure-storage provider required for session migration is unavailable.",
cause,
)

internal sealed interface NextcloudSessionLoadState {
data class Loaded(val session: NextcloudSession?) : NextcloudSessionLoadState

data object SecureStorageUnavailable : NextcloudSessionLoadState

data object LegacyMigrationUnavailable : NextcloudSessionLoadState
}

internal fun loadNextcloudSessionSafely(
loadSession: () -> NextcloudSession?,
): NextcloudSessionLoadState = try {
NextcloudSessionLoadState.Loaded(loadSession())
} catch (failure: CancellationException) {
throw failure
} catch (_: NextcloudSessionLegacyMigrationUnavailableException) {
NextcloudSessionLoadState.LegacyMigrationUnavailable
} catch (_: NextcloudSessionStorageUnavailableException) {
NextcloudSessionLoadState.SecureStorageUnavailable
Comment thread
veryCrunchy marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package dev.obiente.nextcloudnative.app

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.obiente.nextcloudnative.app.design.NextcloudIcons
import dev.obiente.nextcloudnative.app.design.NextcloudSpacing

@Composable
internal fun LoadingMessage(message: String) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
Text(message, modifier = Modifier.padding(top = NextcloudSpacing.Large))
}
}

@Composable
internal fun EmptyMessage(message: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(message, modifier = Modifier.padding(NextcloudSpacing.XLarge), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}

@Composable
internal fun ErrorMessage(message: String, onRetry: (() -> Unit)? = null) {
Column(modifier = Modifier.padding(NextcloudSpacing.XLarge), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(NextcloudIcons.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Text(message, color = MaterialTheme.colorScheme.error)
onRetry?.let { retry -> OutlinedButton(onClick = retry) { Text("Try again") } }
}
}

@Composable
internal fun SecureSessionStorageUnavailable(
onRetry: () -> Unit,
onSignInAgain: () -> Unit,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.padding(NextcloudSpacing.XLarge),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(NextcloudIcons.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Text(
"Secure session storage is locked or unavailable. Unlock it or allow " +
"Nextcloud Native access, then try again, or discard the stored session and sign in again.",
color = MaterialTheme.colorScheme.error,
)
OutlinedButton(onClick = onRetry) { Text("Try again") }
OutlinedButton(onClick = onSignInAgain) { Text("Sign in again") }
}
}
}

@Composable
internal fun LegacySessionMigrationUnavailable(
onRetry: () -> Unit,
onSignInAgain: () -> Unit,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.padding(NextcloudSpacing.XLarge),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(NextcloudIcons.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Text(
"The previous session needs the legacy secure-storage provider. Install the provider " +
"and try again, or discard the stored session and sign in again.",
color = MaterialTheme.colorScheme.error,
)
OutlinedButton(onClick = onRetry) { Text("Try again") }
OutlinedButton(onClick = onSignInAgain) { Text("Sign in again") }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package dev.obiente.nextcloudnative.app

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlinx.coroutines.CancellationException

class NextcloudSessionLoadingTest {
@Test
fun secureStorageFailureBecomesRetryableWithoutExposingItsMessage() {
var attempts = 0
val expected = NextcloudSession("https://cloud.invalid", "alice", "synthetic-secret")
val load = {
attempts += 1
if (attempts == 1) throw NextcloudSessionStorageUnavailableException("private provider failure")
expected
}

assertEquals(
NextcloudSessionLoadState.SecureStorageUnavailable,
loadNextcloudSessionSafely(load),
)
val recovered = assertIs<NextcloudSessionLoadState.Loaded>(loadNextcloudSessionSafely(load))
assertEquals(expected, recovered.session)
}

@Test
fun cancellationRemainsControlFlow() {
assertFailsWith<CancellationException> {
loadNextcloudSessionSafely { throw CancellationException("cancelled") }
}
}

@Test
fun missingLegacyMigrationProviderKeepsItsRecoveryCategory() {
assertEquals(
NextcloudSessionLoadState.LegacyMigrationUnavailable,
loadNextcloudSessionSafely {
throw NextcloudSessionLegacyMigrationUnavailableException(
NextcloudSessionStorageUnavailableException("private provider failure"),
)
},
)
}

@Test
fun unrelatedProgrammingFailureIsNotPresentedAsUnavailableStorage() {
assertFailsWith<IllegalStateException> {
loadNextcloudSessionSafely { error("synthetic invariant failure") }
}
}
}
Loading
Loading