diff --git a/.env.sample b/.env.sample index aab8aa552..2e805c5a8 100644 --- a/.env.sample +++ b/.env.sample @@ -5,4 +5,11 @@ DITTO_APP_ID="" DITTO_PLAYGROUND_TOKEN="" DITTO_AUTH_URL="" -DITTO_WEBSOCKET_URL="" \ No newline at end of file +DITTO_WEBSOCKET_URL="" + +# Optional: offline-only license token from Ditto support. +# When set (non-empty after trimming whitespace), the quickstart app initializes +# in offline-only mode (peer-to-peer only, no Big Peer / cloud sync) and ignores +# the playground/auth/websocket variables above. Leave empty for the default +# Online Playground mode. Request a token by contacting support@ditto.com. +DITTO_OFFLINE_LICENSE_TOKEN="" \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index c2bf476d4..6631518fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,20 @@ Before working with any app: - `DITTO_PLAYGROUND_TOKEN` - `DITTO_AUTH_URL` - `DITTO_WEBSOCKET_URL` + - Optional: `DITTO_OFFLINE_LICENSE_TOKEN` — when non-empty (after trim), the + app switches to offline-only mode and the playground/auth/websocket vars + above are not used. + +## Identity-mode selection + +Every quickstart reads `DITTO_OFFLINE_LICENSE_TOKEN` from `.env`. If the value +is non-empty after trimming whitespace, the app initializes Ditto in offline-only +mode using the SDK's offline identity (v4: `DittoIdentity.OfflinePlayground`; +v5: `DittoConfig.Connect.SmallPeersOnly`) and calls +`setOfflineOnlyLicenseToken` with the trimmed value. Otherwise it falls back to +the Online Playground identity. In v4 apps, `disableSyncWithV3()` and the DQL +strict-mode `ALTER SYSTEM` call are kept in both branches — they are +store-level and apply regardless of identity. ## Common Development Commands diff --git a/README.md b/README.md index dc5ab2c41..192c13d76 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,18 @@ To obtain your Ditto identity and configure the quickstart apps with it, follow - in a macOS Finder window, press `⇧⌘.` (SHIFT+CMD+period) to show hidden files. 1. Save your App ID, Online Playground Token, Auth URL, and WebSocket URL in the `.env` file. +## Offline-only mode (optional) + +The quickstart apps can also run in offline-only mode, where peers sync directly +with each other over Bluetooth/LAN/etc. and do not connect to Ditto's cloud. +This requires an offline-only license token, which you can request by contacting +. + +To run in offline mode, set `DITTO_OFFLINE_LICENSE_TOKEN` in your `.env` file. +When this variable is non-empty, the app initializes in offline-only mode and +ignores the playground/auth/websocket variables. When it is empty or unset, +the app uses Online Playground as before. + Please see the app-specific README files for details on the tools necessary to build and run them. diff --git a/android-java/README.md b/android-java/README.md index ad34bf0d7..0bebc48cd 100644 --- a/android-java/README.md +++ b/android-java/README.md @@ -59,3 +59,11 @@ ditto = "5.0.0" ``` To use a newer version of the SDK, change the version number on this line. + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/android-java/app/build.gradle.kts b/android-java/app/build.gradle.kts index 11840d22c..f95102810 100644 --- a/android-java/app/build.gradle.kts +++ b/android-java/app/build.gradle.kts @@ -15,16 +15,33 @@ fun loadEnvProperties(): Properties { if (envFile.exists()) { FileInputStream(envFile).use { properties.load(it) } } else { - val requiredEnvVars = listOf( + // Read every var we know about from the process env. DITTO_APP_ID is + // always required; the playground/auth pair is only required when + // DITTO_OFFLINE_LICENSE_TOKEN is unset. + val knownEnvVars = listOf( "DITTO_APP_ID", "DITTO_PLAYGROUND_TOKEN", - "DITTO_AUTH_URL" + "DITTO_AUTH_URL", + "DITTO_OFFLINE_LICENSE_TOKEN" ) - - for (envVar in requiredEnvVars) { - val value = System.getenv(envVar) - ?: throw RuntimeException("Required environment variable $envVar not found") - properties[envVar] = value + for (envVar in knownEnvVars) { + System.getenv(envVar)?.let { properties[envVar] = it } + } + val appId = properties["DITTO_APP_ID"] as String? + if (appId.isNullOrBlank()) { + throw RuntimeException("Required environment variable DITTO_APP_ID not found") + } + val offlineToken = (properties["DITTO_OFFLINE_LICENSE_TOKEN"] as String? ?: "").trim() + if (offlineToken.isEmpty()) { + for (envVar in listOf("DITTO_PLAYGROUND_TOKEN", "DITTO_AUTH_URL")) { + val value = properties[envVar] as String? + if (value.isNullOrBlank()) { + throw RuntimeException( + "Required environment variable $envVar not found " + + "(set DITTO_OFFLINE_LICENSE_TOKEN to use offline mode instead)" + ) + } + } } } return properties @@ -67,6 +84,15 @@ androidComponents { "Ditto Auth URL" ) ) + + it.buildConfigFields.put( + "DITTO_OFFLINE_LICENSE_TOKEN", + BuildConfigField( + "String", + "\"${envValue(prop, "DITTO_OFFLINE_LICENSE_TOKEN")}\"", + "Optional offline-only license token; when non-empty, app runs in offline mode" + ) + ) } } diff --git a/android-java/app/src/main/java/com/example/dittotasks/DittoHelper.kt b/android-java/app/src/main/java/com/example/dittotasks/DittoHelper.kt index bf3a6e3c2..5ac8a08e2 100644 --- a/android-java/app/src/main/java/com/example/dittotasks/DittoHelper.kt +++ b/android-java/app/src/main/java/com/example/dittotasks/DittoHelper.kt @@ -25,6 +25,17 @@ object DittoHelper { return DittoFactory.create(config) } + @JvmStatic + fun createOfflineDitto(appId: String, offlineLicenseToken: String): Ditto { + val config = DittoConfig( + databaseId = appId, + connect = DittoConfig.Connect.SmallPeersOnly(privateKey = null) + ) + val ditto = DittoFactory.create(config) + ditto.setOfflineOnlyLicenseToken(offlineLicenseToken) + return ditto + } + @JvmStatic fun setupAuth(ditto: Ditto, token: String) { ditto.auth?.let { auth -> diff --git a/android-java/app/src/main/java/com/example/dittotasks/DittoMode.java b/android-java/app/src/main/java/com/example/dittotasks/DittoMode.java new file mode 100644 index 000000000..d72b36f6a --- /dev/null +++ b/android-java/app/src/main/java/com/example/dittotasks/DittoMode.java @@ -0,0 +1,19 @@ +package com.example.dittotasks; + +/** + * Identity-mode selection based on env vars. + * + * Non-empty {@code DITTO_OFFLINE_LICENSE_TOKEN} (after trim) selects {@link #OFFLINE}; + * otherwise the app uses {@link #ONLINE_PLAYGROUND}. + */ +public enum DittoMode { + ONLINE_PLAYGROUND, + OFFLINE; + + public static DittoMode select(String offlineLicenseToken) { + if (offlineLicenseToken == null) { + return ONLINE_PLAYGROUND; + } + return offlineLicenseToken.trim().isEmpty() ? ONLINE_PLAYGROUND : OFFLINE; + } +} diff --git a/android-java/app/src/main/java/com/example/dittotasks/MainActivity.java b/android-java/app/src/main/java/com/example/dittotasks/MainActivity.java index 5df56efc9..ccd2005a1 100644 --- a/android-java/app/src/main/java/com/example/dittotasks/MainActivity.java +++ b/android-java/app/src/main/java/com/example/dittotasks/MainActivity.java @@ -42,6 +42,8 @@ public class MainActivity extends ComponentActivity { private final String dittoAppId = BuildConfig.DITTO_APP_ID; private final String dittoPlaygroundToken = BuildConfig.DITTO_PLAYGROUND_TOKEN; private final String dittoAuthUrl = BuildConfig.DITTO_AUTH_URL; + private final String dittoOfflineLicenseToken = BuildConfig.DITTO_OFFLINE_LICENSE_TOKEN.trim(); + private final DittoMode dittoMode = DittoMode.select(dittoOfflineLicenseToken); @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -110,18 +112,24 @@ void initDitto() { Log.d("DittoInit", "Skipping permissions during instrumentation test"); } - Log.d("DittoInit", "Starting Ditto SDK initialization..."); + Log.d("DittoInit", "Starting Ditto SDK initialization in mode: " + dittoMode); try { - // Create Ditto with server connection + // Create Ditto in the selected mode. // https://docs.ditto.live/sdk/latest/install-guides/java#integrating-and-initializing Log.d("DittoInit", "Creating Ditto instance..."); - ditto = DittoHelper.createDitto(dittoAppId, dittoAuthUrl); + if (dittoMode == DittoMode.OFFLINE) { + ditto = DittoHelper.createOfflineDitto(dittoAppId, dittoOfflineLicenseToken); + } else { + ditto = DittoHelper.createDitto(dittoAppId, dittoAuthUrl); + } Log.d("DittoInit", "Ditto instance created successfully"); - // Set up authentication handler (must be set before sync.start()) - Log.d("DittoInit", "Setting up authentication..."); - DittoHelper.setupAuth(ditto, dittoPlaygroundToken); - Log.d("DittoInit", "Authentication configured"); + if (dittoMode == DittoMode.ONLINE_PLAYGROUND) { + // Set up authentication handler (must be set before sync.start()) + Log.d("DittoInit", "Setting up authentication..."); + DittoHelper.setupAuth(ditto, dittoPlaygroundToken); + Log.d("DittoInit", "Authentication configured"); + } // register subscription // https://docs.ditto.live/sdk/latest/sync/syncing-data#creating-subscriptions diff --git a/android-java/app/src/test/java/com/example/dittotasks/DittoModeTest.java b/android-java/app/src/test/java/com/example/dittotasks/DittoModeTest.java new file mode 100644 index 000000000..b20f1a084 --- /dev/null +++ b/android-java/app/src/test/java/com/example/dittotasks/DittoModeTest.java @@ -0,0 +1,27 @@ +package com.example.dittotasks; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class DittoModeTest { + @Test + public void nullTokenSelectsOnline() { + assertEquals(DittoMode.ONLINE_PLAYGROUND, DittoMode.select(null)); + } + + @Test + public void emptyTokenSelectsOnline() { + assertEquals(DittoMode.ONLINE_PLAYGROUND, DittoMode.select("")); + } + + @Test + public void whitespaceOnlyTokenSelectsOnline() { + assertEquals(DittoMode.ONLINE_PLAYGROUND, DittoMode.select(" \t\n ")); + } + + @Test + public void nonEmptyTokenSelectsOffline() { + assertEquals(DittoMode.OFFLINE, DittoMode.select("any-real-license-token")); + } +} diff --git a/android-kotlin/QuickStartTasks/app/build.gradle.kts b/android-kotlin/QuickStartTasks/app/build.gradle.kts index c10dea861..0be8a50df 100644 --- a/android-kotlin/QuickStartTasks/app/build.gradle.kts +++ b/android-kotlin/QuickStartTasks/app/build.gradle.kts @@ -11,22 +11,44 @@ plugins { fun loadEnvProperties(): Properties { val properties = Properties() val envFile = rootProject.file("../../.env") - + if (envFile.exists()) { FileInputStream(envFile).use { properties.load(it) } } else { - val requiredEnvVars = listOf( - "DITTO_APP_ID", - "DITTO_PLAYGROUND_TOKEN", - "DITTO_AUTH_URL", - "DITTO_WEBSOCKET_URL" + // Read every var we know about from the process env. DITTO_APP_ID is + // always required; the playground/auth/websocket trio is only required + // when DITTO_OFFLINE_LICENSE_TOKEN is unset. + val knownEnvVars = listOf( + "DITTO_APP_ID", + "DITTO_PLAYGROUND_TOKEN", + "DITTO_AUTH_URL", + "DITTO_WEBSOCKET_URL", + "DITTO_OFFLINE_LICENSE_TOKEN" ) - - for (envVar in requiredEnvVars) { - val value = System.getenv(envVar) - ?: throw RuntimeException("Required environment variable $envVar not found") - properties[envVar] = value + for (envVar in knownEnvVars) { + System.getenv(envVar)?.let { properties[envVar] = it } + } + val appId = properties["DITTO_APP_ID"] as String? + if (appId.isNullOrBlank()) { + throw RuntimeException("Required environment variable DITTO_APP_ID not found") } + val offlineToken = (properties["DITTO_OFFLINE_LICENSE_TOKEN"] as String? ?: "").trim() + if (offlineToken.isEmpty()) { + for (envVar in listOf("DITTO_PLAYGROUND_TOKEN", "DITTO_AUTH_URL", "DITTO_WEBSOCKET_URL")) { + val value = properties[envVar] as String? + if (value.isNullOrBlank()) { + throw RuntimeException( + "Required environment variable $envVar not found " + + "(set DITTO_OFFLINE_LICENSE_TOKEN to use offline mode instead)" + ) + } + } + } + } + // Offline mode is opt-in; treat missing var as empty so the + // BuildConfig field always exists. + if (!properties.containsKey("DITTO_OFFLINE_LICENSE_TOKEN")) { + properties["DITTO_OFFLINE_LICENSE_TOKEN"] = System.getenv("DITTO_OFFLINE_LICENSE_TOKEN") ?: "" } return properties } @@ -39,13 +61,14 @@ androidComponents { "DITTO_PLAYGROUND_TOKEN" to "Ditto playground token", "DITTO_AUTH_URL" to "Ditto authentication URL", "DITTO_WEBSOCKET_URL" to "Ditto websocket URL", + "DITTO_OFFLINE_LICENSE_TOKEN" to "Optional offline-only license token; when non-empty, app runs in offline mode", "TEST_DOCUMENT_TITLE" to "Test document title for BrowserStack verification" ) - + buildConfigFields.forEach { (key, description) -> it.buildConfigFields.put( key, - BuildConfigField("String", "\"${prop[key]}\"", description) + BuildConfigField("String", "\"${prop[key] ?: ""}\"", description) ) } } diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/DittoMode.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/DittoMode.kt new file mode 100644 index 000000000..9c43d161c --- /dev/null +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/DittoMode.kt @@ -0,0 +1,17 @@ +package live.ditto.quickstart.tasks + +/** + * Identity-mode selection based on env vars. + * + * Non-empty `DITTO_OFFLINE_LICENSE_TOKEN` (after trim) selects [OFFLINE]; + * otherwise the app uses [ONLINE_PLAYGROUND]. + */ +enum class DittoMode { + ONLINE_PLAYGROUND, + OFFLINE; + + companion object { + fun select(offlineLicenseToken: String?): DittoMode = + if (offlineLicenseToken.isNullOrBlank()) ONLINE_PLAYGROUND else OFFLINE + } +} diff --git a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt index a05cae2f5..f9e961093 100644 --- a/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt +++ b/android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt @@ -10,6 +10,7 @@ import live.ditto.Ditto import live.ditto.DittoIdentity import live.ditto.android.DefaultAndroidDittoDependencies import live.ditto.quickstart.tasks.DittoHandler.Companion.ditto +import live.ditto.quickstart.tasks.DittoMode class TasksApplication : Application() { @@ -48,6 +49,7 @@ class TasksApplication : Application() { val token = BuildConfig.DITTO_PLAYGROUND_TOKEN val authUrl = BuildConfig.DITTO_AUTH_URL val webSocketURL = BuildConfig.DITTO_WEBSOCKET_URL + val offlineLicenseToken = BuildConfig.DITTO_OFFLINE_LICENSE_TOKEN.trim() val enableDittoCloudSync = false @@ -55,18 +57,29 @@ class TasksApplication : Application() { * Setup Ditto Identity * https://docs.ditto.live/sdk/latest/install-guides/kotlin#integrating-and-initializing */ - val identity = DittoIdentity.OnlinePlayground( - dependencies = androidDependencies, - appId = appId, - token = token, - customAuthUrl = authUrl, - enableDittoCloudSync = enableDittoCloudSync // This is required to be set to false to use the correct URLs - ) + val mode = DittoMode.select(offlineLicenseToken) + val identity = when (mode) { + DittoMode.OFFLINE -> DittoIdentity.OfflinePlayground( + dependencies = androidDependencies, + appId = appId + ) + DittoMode.ONLINE_PLAYGROUND -> DittoIdentity.OnlinePlayground( + dependencies = androidDependencies, + appId = appId, + token = token, + customAuthUrl = authUrl, + enableDittoCloudSync = enableDittoCloudSync // This is required to be set to false to use the correct URLs + ) + } ditto = Ditto(androidDependencies, identity) - ditto.updateTransportConfig { config -> - // Set the Ditto Websocket URL - config.connect.websocketUrls.add(webSocketURL) + if (mode == DittoMode.OFFLINE) { + ditto.setOfflineOnlyLicenseToken(offlineLicenseToken) + } else { + ditto.updateTransportConfig { config -> + // Set the Ditto Websocket URL (online mode only) + config.connect.websocketUrls.add(webSocketURL) + } } ditto.store.execute("ALTER SYSTEM SET DQL_STRICT_MODE = false") diff --git a/android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/DittoModeTest.kt b/android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/DittoModeTest.kt new file mode 100644 index 000000000..89266d8ef --- /dev/null +++ b/android-kotlin/QuickStartTasks/app/src/test/java/live/ditto/quickstart/tasks/DittoModeTest.kt @@ -0,0 +1,26 @@ +package live.ditto.quickstart.tasks + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DittoModeTest { + @Test + fun nullTokenSelectsOnline() { + assertEquals(DittoMode.ONLINE_PLAYGROUND, DittoMode.select(null)) + } + + @Test + fun emptyTokenSelectsOnline() { + assertEquals(DittoMode.ONLINE_PLAYGROUND, DittoMode.select("")) + } + + @Test + fun whitespaceOnlyTokenSelectsOnline() { + assertEquals(DittoMode.ONLINE_PLAYGROUND, DittoMode.select(" \t\n ")) + } + + @Test + fun nonEmptyTokenSelectsOffline() { + assertEquals(DittoMode.OFFLINE, DittoMode.select("any-real-license-token")) + } +} diff --git a/android-kotlin/README.md b/android-kotlin/README.md index 5b7379d14..9878fa881 100644 --- a/android-kotlin/README.md +++ b/android-kotlin/README.md @@ -101,3 +101,11 @@ It is implemented in associated view model is in `app/src/main/java/live/ditto/quickstart/tasks/list/edit/EditScreenViewModel.kt`, and this contains the associated code for manipulating data in the Ditto store. + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/docs/offline-mode-followup-for-v5-migration-prs.md b/docs/offline-mode-followup-for-v5-migration-prs.md new file mode 100644 index 000000000..49a494478 --- /dev/null +++ b/docs/offline-mode-followup-for-v5-migration-prs.md @@ -0,0 +1,186 @@ +# Offline-mode follow-up for the v5 migration PRs + +This PR added `DITTO_OFFLINE_LICENSE_TOKEN` support across every shipped +quickstart. Four v5 migration PRs from @biozal were open at the time of +writing and will overwrite the init code we just edited: + +- [#239 — Android Kotlin v5](https://github.com/getditto/quickstart/pull/239) +- [#237 — Flutter v5](https://github.com/getditto/quickstart/pull/237) +- [#267 — React Native v5](https://github.com/getditto/quickstart/pull/267) +- [#242 — Rust TUI v5](https://github.com/getditto/quickstart/pull/242) + +When each migration PR lands, the offline-mode switch from this PR will be +lost (the init files are fully rewritten in those PRs). The recipe below +re-applies the switch on top of each migrated app. + +## Universal contract + +Every quickstart reads `DITTO_OFFLINE_LICENSE_TOKEN` from `.env`. If the +value is non-empty after trimming whitespace, the app initializes Ditto in +offline-only mode using the SDK's `SmallPeersOnly` connect variant and +calls `setOfflineOnlyLicenseToken(token)` on the Ditto instance. Otherwise +it falls back to the existing online playground init. + +**Important — v4-only calls are gone in v5:** +`disableSyncWithV3()` and `ALTER SYSTEM SET DQL_STRICT_MODE = false` were +needed on the v4 SDK in both online and offline branches. They do **not** +exist in the v5 SDK and the v5 migration PRs already drop them. Do not +re-introduce them when applying the offline branch. + +## Per-PR recipes + +### #239 — Android Kotlin v5 + +Files: `android-kotlin/QuickStartTasks/app/src/main/java/live/ditto/quickstart/tasks/TasksApplication.kt` + +After Aaron's migration, the init uses: + +```kotlin +val config = DittoConfig( + databaseId = secrets.DITTO_APP_ID, + connect = DittoConfig.Connect.Server(url = secrets.DITTO_AUTH_URL), +) +val ditto = DittoFactory.create(config) +ditto.auth.expirationHandler = { /* ... */ } +``` + +Add the offline branch like this: + +```kotlin +val offlineLicenseToken = secrets.DITTO_OFFLINE_LICENSE_TOKEN.trim() +val isOffline = offlineLicenseToken.isNotEmpty() + +val config = DittoConfig( + databaseId = secrets.DITTO_APP_ID, + connect = if (isOffline) { + DittoConfig.Connect.SmallPeersOnly(privateKey = null) + } else { + DittoConfig.Connect.Server(url = secrets.DITTO_AUTH_URL) + }, +) +val ditto = DittoFactory.create(config) +if (isOffline) { + ditto.setOfflineOnlyLicenseToken(offlineLicenseToken) +} else { + ditto.auth.expirationHandler = { /* unchanged */ } +} +``` + +Port the `DittoMode` enum and its JUnit test from this PR alongside the +init change. + +### #237 — Flutter v5 + +Files: `flutter_app/lib/main.dart` + +After Aaron's migration, the init likely uses `DittoConfig` + `DittoConnect.server(...)` and `Ditto.open(config: config)`. Apply the same branching: + +```dart +final offlineLicenseToken = + (dotenv.env['DITTO_OFFLINE_LICENSE_TOKEN'] ?? '').trim(); +final isOffline = offlineLicenseToken.isNotEmpty; + +final config = DittoConfig( + databaseId: appID, + connect: isOffline + ? DittoConnect.smallPeersOnly() + : DittoConnect.server(url: 'https://$databaseId.cloud.ditto.live'), +); +final ditto = await Ditto.open(config: config); + +if (isOffline) { + ditto.setOfflineOnlyLicenseToken(offlineLicenseToken); +} else { + ditto.auth?.expirationHandler = (instance, secondsRemaining) async { + // unchanged playground auth + }; +} +``` + +Note: v5 does **not** need the random `SiteID.fromInt(...)` workaround that +v4 required. The v5 `SmallPeersOnly` connect variant manages peer identity +internally. Port the `DittoMode` enum and `ditto_mode_test.dart` from this PR. + +### #267 — React Native v5 + +Files: `react-native/App.tsx` (and `react-native/types/env.d.ts`) + +The v5 init path is identical to the JS pattern this PR already uses for +`javascript-tui`, `javascript-web`, and `electron`. Aaron's migration may +restructure the file, but the branching contract stays the same: + +```ts +const offlineLicenseToken = (DITTO_OFFLINE_LICENSE_TOKEN ?? '').trim(); +const mode = selectMode(offlineLicenseToken); // helper from dittoMode.ts + +const connectConfig: DittoConfigConnect = + mode === 'offline' + ? { mode: 'smallPeersOnly' } + : { mode: 'server', url: DITTO_AUTH_URL }; + +const config = new DittoConfig(databaseId, connectConfig, 'custom-folder'); +ditto.current = await Ditto.open(config); + +if (mode === 'offline') { + ditto.current.setOfflineOnlyLicenseToken(offlineLicenseToken); +} else { + // existing playground auth via setExpirationHandler + auth.login +} +``` + +Port `dittoMode.ts` and `__tests__/dittoMode.test.ts` from this PR. + +### #242 — Rust TUI v5 + +Files: `rust-tui/src/bin/main.rs` + +After Aaron's migration, the init switches from v4 builders to: + +```rust +let config = DittoConfig::new( + database_id, + DittoConfigConnect::Server { url: ... }, +); +let ditto = Ditto::open_sync(config)?; +ditto.auth()?.set_expiration_handler(...)?; +``` + +Apply the offline branch: + +```rust +let offline_license_token = cli.offline_license_token.trim().to_string(); +let mode = select_mode(&offline_license_token); + +let connect = match mode { + DittoMode::Offline => DittoConfigConnect::SmallPeersOnly { private_key: None }, + DittoMode::OnlinePlayground => DittoConfigConnect::Server { + url: cli.custom_auth_url.parse()?, + }, +}; +let config = DittoConfig::new(cli.app_id.clone(), connect); +let ditto = Ditto::open_sync(config)?; + +if mode == DittoMode::Offline { + ditto.set_offline_only_license_token(&offline_license_token)?; +} else { + ditto.auth()?.set_expiration_handler(/* existing handler */)?; +} +``` + +Port `select_mode`, the `DittoMode` enum, and the `#[cfg(test)] mod tests` +block from this PR. + +## When to do this + +After each of the four PRs above merges, rebase that app's offline switch +on top of it. The fastest path is probably one small follow-up PR per +migrated app rather than a single bundled one — each rebase is mechanical +but touches a different language. + +## Cleanup + +Once all four apps above have the offline branch re-applied on top of +their v5 migrations, **delete this file** in the final follow-up PR. This +is a transient planning doc, not durable documentation — leaving it in +the repo after the work is done invites confusion when someone reads it +months later and wonders whether the listed PRs are still open. diff --git a/dotnet-maui/DittoMauiTasksApp/MauiProgram.cs b/dotnet-maui/DittoMauiTasksApp/MauiProgram.cs index e316382a6..fed3ca28d 100644 --- a/dotnet-maui/DittoMauiTasksApp/MauiProgram.cs +++ b/dotnet-maui/DittoMauiTasksApp/MauiProgram.cs @@ -40,41 +40,73 @@ public static MauiApp CreateMauiApp() private static Ditto SetupDitto() { var envVars = LoadEnvVariables(); - AppId = envVars["DITTO_APP_ID"]; - PlaygroundToken = envVars["DITTO_PLAYGROUND_TOKEN"]; - var authUrl = envVars["DITTO_AUTH_URL"]; + AppId = envVars.TryGetValue("DITTO_APP_ID", out var rawAppId) ? rawAppId : ""; + PlaygroundToken = envVars.TryGetValue("DITTO_PLAYGROUND_TOKEN", out var rawToken1) ? rawToken1 : ""; + var authUrl = envVars.TryGetValue("DITTO_AUTH_URL", out var rawAuthUrl) ? rawAuthUrl : ""; + var websocketUrl = envVars.TryGetValue("DITTO_WEBSOCKET_URL", out var rawWsUrl) ? rawWsUrl : ""; + var offlineLicenseToken = envVars.TryGetValue("DITTO_OFFLINE_LICENSE_TOKEN", out var rawToken) + ? rawToken.Trim() + : ""; + var isOffline = !string.IsNullOrEmpty(offlineLicenseToken); + + if (string.IsNullOrWhiteSpace(AppId)) + { + throw new InvalidOperationException("DITTO_APP_ID is required."); + } + if (!isOffline) + { + var missing = new List(); + if (string.IsNullOrWhiteSpace(PlaygroundToken)) missing.Add("DITTO_PLAYGROUND_TOKEN"); + if (string.IsNullOrWhiteSpace(authUrl)) missing.Add("DITTO_AUTH_URL"); + if (string.IsNullOrWhiteSpace(websocketUrl)) missing.Add("DITTO_WEBSOCKET_URL"); + if (missing.Count > 0) + { + throw new InvalidOperationException( + $"Online Playground mode requires: {string.Join(", ", missing)}. " + + "Set DITTO_OFFLINE_LICENSE_TOKEN to use offline mode instead."); + } + } // New Initialization code - https://docs.ditto.live/sdk/latest/ditto-config + DittoConfigConnect connect = isOffline + ? new DittoConfigConnect.SmallPeersOnly() + : new DittoConfigConnect.Server(new Uri(authUrl)); + var dittoConfig = new DittoConfig( - AppId, - new DittoConfigConnect.Server( - new Uri(authUrl) - ), + AppId, + connect, Path.Combine(FileSystem.Current.AppDataDirectory, "ditto") ); - var ditto = Ditto.Open(dittoConfig); - - // Set up authentication expiration handler (required for server connections) - ditto.Auth.ExpirationHandler = async (dittoAuth, secondsRemaining) => + var ditto = Ditto.Open(dittoConfig); + + if (isOffline) { - // Authenticate when token is expiring - try - { - await dittoAuth.Auth.LoginAsync( - // Your development token, replace with your actual token - PlaygroundToken, - // Use DittoAuthenticationProvider.Development for playground, or your actual provider - DittoAuthenticationProvider.Development - ); - Console.WriteLine("Authentication successful"); - } - catch (Exception error) + ditto.SetOfflineOnlyLicenseToken(offlineLicenseToken); + } + else + { + // Set up authentication expiration handler (required for server connections) + ditto.Auth.ExpirationHandler = async (dittoAuth, secondsRemaining) => { - Console.WriteLine($"Authentication failed: {error}"); - } - }; - + // Authenticate when token is expiring + try + { + await dittoAuth.Auth.LoginAsync( + // Your development token, replace with your actual token + PlaygroundToken, + // Use DittoAuthenticationProvider.Development for playground, or your actual provider + DittoAuthenticationProvider.Development + ); + Console.WriteLine("Authentication successful"); + } + catch (Exception error) + { + Console.WriteLine($"Authentication failed: {error}"); + } + }; + } + return ditto; } diff --git a/dotnet-maui/README.md b/dotnet-maui/README.md index 62a3e5885..b908ff9d8 100644 --- a/dotnet-maui/README.md +++ b/dotnet-maui/README.md @@ -111,3 +111,11 @@ cd UITests.Android APPIUM_UDID="emulator-5554" \ DITTO_CLOUD_TASK_TITLE="" dotnet run ``` + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/dotnet-tui/DittoDotNetTasksConsole.Tests/DittoModeTests.cs b/dotnet-tui/DittoDotNetTasksConsole.Tests/DittoModeTests.cs new file mode 100644 index 000000000..d1c046bf4 --- /dev/null +++ b/dotnet-tui/DittoDotNetTasksConsole.Tests/DittoModeTests.cs @@ -0,0 +1,30 @@ +using DittoDotNetTasksConsole; + +namespace DittoDotNetTasksConsole.Tests; + +public class DittoModeTests +{ + [Fact] + public void NullTokenSelectsOnline() + { + Assert.Equal(DittoMode.OnlinePlayground, DittoModeSelector.Select(null)); + } + + [Fact] + public void EmptyTokenSelectsOnline() + { + Assert.Equal(DittoMode.OnlinePlayground, DittoModeSelector.Select("")); + } + + [Fact] + public void WhitespaceOnlyTokenSelectsOnline() + { + Assert.Equal(DittoMode.OnlinePlayground, DittoModeSelector.Select(" \t\n ")); + } + + [Fact] + public void NonEmptyTokenSelectsOffline() + { + Assert.Equal(DittoMode.Offline, DittoModeSelector.Select("any-real-license-token")); + } +} diff --git a/dotnet-tui/DittoDotNetTasksConsole/DittoMode.cs b/dotnet-tui/DittoDotNetTasksConsole/DittoMode.cs new file mode 100644 index 000000000..dba90a93e --- /dev/null +++ b/dotnet-tui/DittoDotNetTasksConsole/DittoMode.cs @@ -0,0 +1,23 @@ +namespace DittoDotNetTasksConsole; + +/// +/// Identity-mode selection based on env vars. +/// +/// Non-empty DITTO_OFFLINE_LICENSE_TOKEN (after trim) selects +/// ; otherwise the app uses . +/// +public enum DittoMode +{ + OnlinePlayground, + Offline, +} + +public static class DittoModeSelector +{ + public static DittoMode Select(string offlineLicenseToken) + { + return string.IsNullOrWhiteSpace(offlineLicenseToken) + ? DittoMode.OnlinePlayground + : DittoMode.Offline; + } +} diff --git a/dotnet-tui/DittoDotNetTasksConsole/Program.cs b/dotnet-tui/DittoDotNetTasksConsole/Program.cs index 258fcd308..efcd9eac2 100644 --- a/dotnet-tui/DittoDotNetTasksConsole/Program.cs +++ b/dotnet-tui/DittoDotNetTasksConsole/Program.cs @@ -17,11 +17,12 @@ public static async Task Main(string[] args) { var env = LoadEnvVariables(); var appId = env["DITTO_APP_ID"]; - var playgroundToken = env["DITTO_PLAYGROUND_TOKEN"]; - var websocketUrl = env["DITTO_WEBSOCKET_URL"]; - var authUrl = env["DITTO_AUTH_URL"]; + var playgroundToken = env.GetValueOrDefault("DITTO_PLAYGROUND_TOKEN", ""); + var websocketUrl = env.GetValueOrDefault("DITTO_WEBSOCKET_URL", ""); + var authUrl = env.GetValueOrDefault("DITTO_AUTH_URL", ""); + var offlineLicenseToken = env.GetValueOrDefault("DITTO_OFFLINE_LICENSE_TOKEN", ""); - using var peer = await TasksPeer.Create(appId, playgroundToken, authUrl, websocketUrl); + using var peer = await TasksPeer.Create(appId, playgroundToken, authUrl, websocketUrl, offlineLicenseToken); // Disable Ditto's standard-error logging, which would interfere // with the the Terminal.Gui UI. diff --git a/dotnet-tui/DittoDotNetTasksConsole/TasksPeer.cs b/dotnet-tui/DittoDotNetTasksConsole/TasksPeer.cs index 200fe4f4b..d0ba6982c 100644 --- a/dotnet-tui/DittoDotNetTasksConsole/TasksPeer.cs +++ b/dotnet-tui/DittoDotNetTasksConsole/TasksPeer.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Threading.Tasks; +using DittoDotNetTasksConsole; using DittoSDK; using DittoSDK.Auth; using DittoSDK.Store; @@ -20,6 +21,8 @@ public class TasksPeer : IDisposable public string PlaygroundToken { get; private set; } public string AuthUrl { get; private set; } public string WebsocketUrl { get; private set; } + public string OfflineLicenseToken { get; private set; } + public DittoMode Mode { get; private set; } public bool IsSyncActive => _ditto.Sync.IsActive; @@ -32,10 +35,11 @@ public static async Task Create( string appId, string playgroundToken, string authUrl, - string websocketUrl) + string websocketUrl, + string offlineLicenseToken = "") { - var peer = new TasksPeer(appId, playgroundToken, authUrl, websocketUrl); - peer.Authenticate(); + var peer = new TasksPeer(appId, playgroundToken, authUrl, websocketUrl, offlineLicenseToken); + peer.Activate(); peer.RegisterSubscription(); await peer.InsertInitialTasks(); peer.StartSync(); @@ -43,25 +47,32 @@ public static async Task Create( return peer; } - private void Authenticate() + private void Activate() { - _ditto.Auth.ExpirationHandler = async (ditto, secondsRemaining) => + if (Mode == DittoMode.Offline) { - // Authenticate when token is expiring - try - { - await ditto.Auth.LoginAsync( - // Your development token, replace with your actual token - PlaygroundToken, - // Use DittoAuthenticationProvider.Development for playground, or your actual provider - DittoAuthenticationProvider.Development - ); - } - catch (Exception error) + _ditto.SetOfflineOnlyLicenseToken(OfflineLicenseToken); + } + else + { + _ditto.Auth.ExpirationHandler = async (ditto, secondsRemaining) => { - Console.WriteLine($"Authentication failed: {error}"); - } - }; + // Authenticate when token is expiring + try + { + await ditto.Auth.LoginAsync( + // Your development token, replace with your actual token + PlaygroundToken, + // Use DittoAuthenticationProvider.Development for playground, or your actual provider + DittoAuthenticationProvider.Development + ); + } + catch (Exception error) + { + Console.WriteLine($"Authentication failed: {error}"); + } + }; + } } /// @@ -93,17 +104,35 @@ private void RegisterSubscription() /// Ditto online playground token /// Ditto Auth URL /// Ditto Websocket URL - private TasksPeer(string appId, string playgroundToken, string authUrl, string websocketUrl) + /// Optional offline-only license token. When non-empty, the peer initializes in offline-only mode. + private TasksPeer(string appId, string playgroundToken, string authUrl, string websocketUrl, string offlineLicenseToken) { AppId = appId; PlaygroundToken = playgroundToken; AuthUrl = authUrl; WebsocketUrl = websocketUrl; + OfflineLicenseToken = (offlineLicenseToken ?? string.Empty).Trim(); + Mode = DittoModeSelector.Select(OfflineLicenseToken); + + if (Mode == DittoMode.OnlinePlayground) + { + var missing = new List(); + if (string.IsNullOrWhiteSpace(playgroundToken)) missing.Add("DITTO_PLAYGROUND_TOKEN"); + if (string.IsNullOrWhiteSpace(authUrl)) missing.Add("DITTO_AUTH_URL"); + if (string.IsNullOrWhiteSpace(websocketUrl)) missing.Add("DITTO_WEBSOCKET_URL"); + if (missing.Count > 0) + { + throw new InvalidOperationException( + $"Online Playground mode requires: {string.Join(", ", missing)}. " + + "Set DITTO_OFFLINE_LICENSE_TOKEN to use offline mode instead."); + } + } + + DittoConfigConnect connect = Mode == DittoMode.Offline + ? new DittoConfigConnect.SmallPeersOnly() + : new DittoConfigConnect.Server(new Uri(authUrl)); - var config = new DittoConfig( - AppId, - new DittoConfigConnect.Server(new Uri(authUrl)) - ); + var config = new DittoConfig(AppId, connect); _ditto = Ditto.Open(config); } diff --git a/dotnet-tui/README.md b/dotnet-tui/README.md index bf36e7a40..e2c96b5d4 100644 --- a/dotnet-tui/README.md +++ b/dotnet-tui/README.md @@ -23,3 +23,11 @@ cd DittoDotNetTasksConsole dotnet build dotnet run ``` + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/dotnet-winforms-net48/AppConfiguration.cs b/dotnet-winforms-net48/AppConfiguration.cs index 4fa69131f..977da6cbb 100644 --- a/dotnet-winforms-net48/AppConfiguration.cs +++ b/dotnet-winforms-net48/AppConfiguration.cs @@ -12,6 +12,14 @@ public static class AppConfiguration public static string AppId { get; private set; } public static string PlaygroundToken { get; private set; } public static string AuthUrl { get; private set; } + public static string OfflineLicenseToken { get; private set; } + + /// + /// True when DITTO_OFFLINE_LICENSE_TOKEN is set and non-empty after trimming + /// whitespace; the app initializes in offline-only mode in that case. + /// + public static bool IsOffline => + !string.IsNullOrWhiteSpace(OfflineLicenseToken); /// /// Loads configuration from .env file in the application directory @@ -51,13 +59,20 @@ public static void Load() case "DITTO_AUTH_URL": AuthUrl = value; break; + case "DITTO_OFFLINE_LICENSE_TOKEN": + OfflineLicenseToken = value; + break; } } } - // Validate required fields + // Validate required fields. App ID is always required. The playground/auth + // values are only required for the default online mode; offline mode swaps + // them for the offline-only license token. if (string.IsNullOrWhiteSpace(AppId)) throw new InvalidOperationException("DITTO_APP_ID is required in .env file"); + if (IsOffline) + return; if (string.IsNullOrWhiteSpace(PlaygroundToken)) throw new InvalidOperationException("DITTO_PLAYGROUND_TOKEN is required in .env file"); if (string.IsNullOrWhiteSpace(AuthUrl)) diff --git a/dotnet-winforms-net48/Program.cs b/dotnet-winforms-net48/Program.cs index e5b69f174..3f121b102 100644 --- a/dotnet-winforms-net48/Program.cs +++ b/dotnet-winforms-net48/Program.cs @@ -22,8 +22,9 @@ static void Main() // Initialize TasksPeerService asynchronously var initTask = TasksPeerService.Instance.InitializeAsync( AppConfiguration.AppId, - AppConfiguration.PlaygroundToken, - AppConfiguration.AuthUrl + AppConfiguration.PlaygroundToken ?? string.Empty, + AppConfiguration.AuthUrl ?? string.Empty, + AppConfiguration.OfflineLicenseToken ?? string.Empty ); // Show loading form while initializing diff --git a/dotnet-winforms-net48/README.md b/dotnet-winforms-net48/README.md index c03da56d9..dd7961e98 100644 --- a/dotnet-winforms-net48/README.md +++ b/dotnet-winforms-net48/README.md @@ -19,4 +19,11 @@ ## .NET Windows Forms Application -This is a Windows Form Application is targeting .NET 4.8. It will NOT run on MacOS or Linux and will not run on modern .NET. To run the app, open the `dotnet-winforms-net48` folder in Visual Studio for Windows and select the solution file in it (`Taskapp.WinForms.Net48.sln`). \ No newline at end of file +This is a Windows Form Application is targeting .NET 4.8. It will NOT run on MacOS or Linux and will not run on modern .NET. To run the app, open the `dotnet-winforms-net48` folder in Visual Studio for Windows and select the solution file in it (`Taskapp.WinForms.Net48.sln`). +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/dotnet-winforms-net48/TasksPeer.cs b/dotnet-winforms-net48/TasksPeer.cs index cf973f706..e72f2839a 100644 --- a/dotnet-winforms-net48/TasksPeer.cs +++ b/dotnet-winforms-net48/TasksPeer.cs @@ -20,6 +20,8 @@ public class TasksPeer : IDisposable public string AppId { get; private set; } public string PlaygroundToken { get; private set; } public string AuthUrl { get; private set; } + public string OfflineLicenseToken { get; private set; } + public bool IsOffline { get; private set; } public bool IsSyncActive => _ditto.Sync.IsActive; @@ -31,10 +33,11 @@ public class TasksPeer : IDisposable public static async Task Create( string appId, string playgroundToken, - string authUrl) + string authUrl, + string offlineLicenseToken = "") { - var peer = new TasksPeer(appId, playgroundToken, authUrl); - peer.Authenticate(); + var peer = new TasksPeer(appId, playgroundToken, authUrl, offlineLicenseToken); + peer.Activate(); await peer.DisableStrictMode(); peer.RegisterSubscription(); await peer.InsertInitialTasks(); @@ -43,8 +46,13 @@ public static async Task Create( return peer; } - private void Authenticate() + private void Activate() { + if (IsOffline) + { + _ditto.SetOfflineOnlyLicenseToken(OfflineLicenseToken); + return; + } _ditto.Auth.ExpirationHandler = async (ditto, secondsRemaining) => { // Authenticate when token is expiring @@ -93,16 +101,20 @@ private void RegisterSubscription() /// Ditto application ID /// Ditto online playground token /// Ditto Auth URL - private TasksPeer(string appId, string playgroundToken, string authUrl) + /// Optional offline-only license token. When non-empty, the peer initializes in offline-only mode. + private TasksPeer(string appId, string playgroundToken, string authUrl, string offlineLicenseToken) { AppId = appId; PlaygroundToken = playgroundToken; AuthUrl = authUrl; + OfflineLicenseToken = (offlineLicenseToken ?? string.Empty).Trim(); + IsOffline = !string.IsNullOrEmpty(OfflineLicenseToken); + + DittoConfigConnect connect = IsOffline + ? (DittoConfigConnect)new DittoConfigConnect.SmallPeersOnly() + : new DittoConfigConnect.Server(new Uri(authUrl)); - var config = new DittoConfig( - AppId, - new DittoConfigConnect.Server(new Uri(authUrl)) - ); + var config = new DittoConfig(AppId, connect); _ditto = Ditto.Open(config); // Required on the 4.x SDK to allow DQL usage; not needed in v5. diff --git a/dotnet-winforms-net48/TasksPeerService.cs b/dotnet-winforms-net48/TasksPeerService.cs index 75dcdb2e5..7000aceb4 100644 --- a/dotnet-winforms-net48/TasksPeerService.cs +++ b/dotnet-winforms-net48/TasksPeerService.cs @@ -26,12 +26,12 @@ private TasksPeerService() /// /// Initializes the TasksPeer with configuration values /// - public async Task InitializeAsync(string appId, string playgroundToken, string authUrl) + public async Task InitializeAsync(string appId, string playgroundToken, string authUrl, string offlineLicenseToken = "") { if (_isInitialized) throw new InvalidOperationException("TasksPeerService is already initialized"); - _tasksPeer = await TasksPeer.Create(appId, playgroundToken, authUrl); + _tasksPeer = await TasksPeer.Create(appId, playgroundToken, authUrl, offlineLicenseToken); _isInitialized = true; } diff --git a/dotnet-winforms/IntegrationTest/IntegrationTest.csproj b/dotnet-winforms/IntegrationTest/IntegrationTest.csproj index 3736034f9..1e31c3a96 100644 --- a/dotnet-winforms/IntegrationTest/IntegrationTest.csproj +++ b/dotnet-winforms/IntegrationTest/IntegrationTest.csproj @@ -16,6 +16,7 @@ + diff --git a/dotnet-winforms/README.md b/dotnet-winforms/README.md index 3c9ea476f..0d41d7c01 100644 --- a/dotnet-winforms/README.md +++ b/dotnet-winforms/README.md @@ -28,3 +28,11 @@ dotnet build dotnet run ``` + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/dotnet-winforms/TasksApp/DittoMode.cs b/dotnet-winforms/TasksApp/DittoMode.cs new file mode 100644 index 000000000..55f6248b0 --- /dev/null +++ b/dotnet-winforms/TasksApp/DittoMode.cs @@ -0,0 +1,23 @@ +namespace DittoTasksApp; + +/// +/// Identity-mode selection based on env vars. +/// +/// Non-empty DITTO_OFFLINE_LICENSE_TOKEN (after trim) selects +/// ; otherwise the app uses . +/// +public enum DittoMode +{ + OnlinePlayground, + Offline, +} + +public static class DittoModeSelector +{ + public static DittoMode Select(string? offlineLicenseToken) + { + return string.IsNullOrWhiteSpace(offlineLicenseToken) + ? DittoMode.OnlinePlayground + : DittoMode.Offline; + } +} diff --git a/dotnet-winforms/TasksApp/Program.cs b/dotnet-winforms/TasksApp/Program.cs index 6394c2e2e..38aa30c9c 100644 --- a/dotnet-winforms/TasksApp/Program.cs +++ b/dotnet-winforms/TasksApp/Program.cs @@ -14,11 +14,12 @@ static async Task Main() { var env = LoadEnvVariables(); var appId = env["DITTO_APP_ID"]; - var playgroundToken = env["DITTO_PLAYGROUND_TOKEN"]; - var websocketUrl = env["DITTO_WEBSOCKET_URL"]; - var authUrl = env["DITTO_AUTH_URL"]; + var playgroundToken = env.GetValueOrDefault("DITTO_PLAYGROUND_TOKEN", ""); + var websocketUrl = env.GetValueOrDefault("DITTO_WEBSOCKET_URL", ""); + var authUrl = env.GetValueOrDefault("DITTO_AUTH_URL", ""); + var offlineLicenseToken = env.GetValueOrDefault("DITTO_OFFLINE_LICENSE_TOKEN", ""); - using var peer = await TasksPeer.Create(appId, playgroundToken, authUrl, websocketUrl); + using var peer = await TasksPeer.Create(appId, playgroundToken, authUrl, websocketUrl, offlineLicenseToken); // Disable Ditto's standard-error logging DittoLogger.IsEnabled = true; diff --git a/dotnet-winforms/TasksApp/TasksPeer.cs b/dotnet-winforms/TasksApp/TasksPeer.cs index 59dfe2935..418f24fa5 100644 --- a/dotnet-winforms/TasksApp/TasksPeer.cs +++ b/dotnet-winforms/TasksApp/TasksPeer.cs @@ -24,6 +24,8 @@ public class TasksPeer : IDisposable public string PlaygroundToken { get; private set; } public string AuthUrl { get; private set; } public string WebsocketUrl { get; private set; } + public string OfflineLicenseToken { get; private set; } + public DittoMode Mode { get; private set; } public bool IsSyncActive => _ditto.Sync.IsActive; @@ -36,10 +38,11 @@ public static async Task Create( string appId, string playgroundToken, string authUrl, - string websocketUrl) + string websocketUrl, + string offlineLicenseToken = "") { - var peer = new TasksPeer(appId, playgroundToken, authUrl, websocketUrl); - peer.Authenticate(); + var peer = new TasksPeer(appId, playgroundToken, authUrl, websocketUrl, offlineLicenseToken); + peer.Activate(); peer.RegisterSubscription(); await peer.InsertInitialTasks(); peer.StartSync(); @@ -47,26 +50,33 @@ public static async Task Create( return peer; } - private void Authenticate() + private void Activate() { - _ditto.Auth.ExpirationHandler = async (ditto, secondsRemaining) => + if (Mode == DittoMode.Offline) { - // Authenticate when token is expiring - try - { - await ditto.Auth.LoginAsync( - // Your development token, replace with your actual token - PlaygroundToken, - // Use DittoAuthenticationProvider.Development for playground, or your actual provider - DittoAuthenticationProvider.Development - ); - Console.WriteLine("Authentication successful"); - } - catch (Exception error) + _ditto.SetOfflineOnlyLicenseToken(OfflineLicenseToken); + } + else + { + _ditto.Auth.ExpirationHandler = async (ditto, secondsRemaining) => { - Console.WriteLine($"Authentication failed: {error}"); - } - }; + // Authenticate when token is expiring + try + { + await ditto.Auth.LoginAsync( + // Your development token, replace with your actual token + PlaygroundToken, + // Use DittoAuthenticationProvider.Development for playground, or your actual provider + DittoAuthenticationProvider.Development + ); + Console.WriteLine("Authentication successful"); + } + catch (Exception error) + { + Console.WriteLine($"Authentication failed: {error}"); + } + }; + } } /// @@ -98,17 +108,35 @@ private void RegisterSubscription() /// Ditto online playground token /// Ditto Auth URL /// Ditto Websocket URL - private TasksPeer(string appId, string playgroundToken, string authUrl, string websocketUrl) + /// Optional offline-only license token. When non-empty, the peer initializes in offline-only mode. + private TasksPeer(string appId, string playgroundToken, string authUrl, string websocketUrl, string offlineLicenseToken) { AppId = appId; PlaygroundToken = playgroundToken; AuthUrl = authUrl; WebsocketUrl = websocketUrl; + OfflineLicenseToken = (offlineLicenseToken ?? string.Empty).Trim(); + Mode = DittoModeSelector.Select(OfflineLicenseToken); + + if (Mode == DittoMode.OnlinePlayground) + { + var missing = new List(); + if (string.IsNullOrWhiteSpace(playgroundToken)) missing.Add("DITTO_PLAYGROUND_TOKEN"); + if (string.IsNullOrWhiteSpace(authUrl)) missing.Add("DITTO_AUTH_URL"); + if (string.IsNullOrWhiteSpace(websocketUrl)) missing.Add("DITTO_WEBSOCKET_URL"); + if (missing.Count > 0) + { + throw new InvalidOperationException( + $"Online Playground mode requires: {string.Join(", ", missing)}. " + + "Set DITTO_OFFLINE_LICENSE_TOKEN to use offline mode instead."); + } + } + + DittoConfigConnect connect = Mode == DittoMode.Offline + ? new DittoConfigConnect.SmallPeersOnly() + : new DittoConfigConnect.Server(new Uri(authUrl)); - var config = new DittoConfig( - AppId, - new DittoConfigConnect.Server(new Uri(authUrl)) - ); + var config = new DittoConfig(AppId, connect); _ditto = Ditto.Open(config); } diff --git a/electron/README.md b/electron/README.md index b9ec00a9a..05141e114 100644 --- a/electron/README.md +++ b/electron/README.md @@ -86,3 +86,11 @@ node node_modules/electron/install.js ``` Then re-run `npm run dev`. + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/electron/src/main/ditto.ts b/electron/src/main/ditto.ts index 330dc65ff..7c6c88321 100644 --- a/electron/src/main/ditto.ts +++ b/electron/src/main/ditto.ts @@ -17,31 +17,42 @@ let currentTasks: Task[] = []; export async function initDitto( onTasksUpdated: (tasks: Task[]) => void, ): Promise { + const connect = + env.mode === 'offline' + ? ({ mode: 'smallPeersOnly' } as const) + : ({ mode: 'server', url: env.authUrl } as const); + const config = new DittoConfig( env.appId, - { mode: 'server', url: env.authUrl }, + connect, app.getPath('userData'), ); ditto = await Ditto.open(config); - // Authenticate with the playground token, and re-authenticate when it expires. - await ditto.auth.setExpirationHandler(async (instance) => { - const result = await instance.auth.login( - env.token, - Authenticator.DEVELOPMENT_PROVIDER, - ); - if (result.error) { - console.error('Re-authentication failed:', result.error); - } - }); + if (env.mode === 'offline') { + ditto.setOfflineOnlyLicenseToken(env.offlineLicenseToken); + } else { + // Authenticate with the playground token, and re-authenticate when it expires. + await ditto.auth.setExpirationHandler(async (instance) => { + const result = await instance.auth.login( + env.token, + Authenticator.DEVELOPMENT_PROVIDER, + ); + if (result.error) { + console.error('Re-authentication failed:', result.error); + } + }); + } // BLE and AWDL require macOS entitlements that only signed app bundles get, // so they're disabled here for unsigned `npm run dev` builds. LAN (TCP + // mDNS) provides peer-to-peer sync across the local network; the websocket - // URL provides cloud sync to Ditto's Big Peer. + // URL provides cloud sync to Ditto's Big Peer (skipped in offline mode). ditto.updateTransportConfig((cfg) => { - cfg.connect.websocketURLs = [env.websocketUrl]; + if (env.mode !== 'offline') { + cfg.connect.websocketURLs = [env.websocketUrl]; + } cfg.peerToPeer.bluetoothLE.isEnabled = false; cfg.peerToPeer.awdl.isEnabled = false; cfg.peerToPeer.lan.isEnabled = true; diff --git a/electron/src/main/env.ts b/electron/src/main/env.ts index 4b3778ffd..e3c06120c 100644 --- a/electron/src/main/env.ts +++ b/electron/src/main/env.ts @@ -3,14 +3,35 @@ import { resolve } from 'node:path'; dotenv.config({ path: resolve(process.cwd(), '..', '.env') }); +export type DittoMode = 'online' | 'offline'; + +export function selectMode(licenseToken: string | null | undefined): DittoMode { + return licenseToken && licenseToken.trim().length > 0 ? 'offline' : 'online'; +} + +const offlineLicenseToken = ( + process.env.DITTO_OFFLINE_LICENSE_TOKEN ?? '' +).trim(); + export const env = { appId: process.env.DITTO_APP_ID ?? '', token: process.env.DITTO_PLAYGROUND_TOKEN ?? '', authUrl: process.env.DITTO_AUTH_URL ?? '', websocketUrl: process.env.DITTO_WEBSOCKET_URL ?? '', + offlineLicenseToken, + mode: selectMode(offlineLicenseToken), }; export function assertEnv(): void { + if (env.mode === 'offline') { + if (!env.appId) { + throw new Error( + `Offline mode requires DITTO_APP_ID. ` + + `Set it in .env at the repo root.`, + ); + } + return; + } const missing = ( ['appId', 'token', 'authUrl', 'websocketUrl'] as const ).filter((k) => !env[k]); diff --git a/flutter_app/README.md b/flutter_app/README.md index 082ebc4cf..b006d03e2 100644 --- a/flutter_app/README.md +++ b/flutter_app/README.md @@ -125,3 +125,11 @@ Explore the following links and resources to learn more about Ditto: - [Ditto Data Store CRUD](https://docs.ditto.live/crud/create) - [Ditto Data Sync Subscriptions](https://docs.ditto.live/sync/subscriptions-management) - [Ditto Query Language](https://docs.ditto.live/dql) + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/flutter_app/lib/ditto_mode.dart b/flutter_app/lib/ditto_mode.dart new file mode 100644 index 000000000..143b42a4a --- /dev/null +++ b/flutter_app/lib/ditto_mode.dart @@ -0,0 +1,10 @@ +/// Identity-mode selection based on env vars. +/// +/// Non-empty `DITTO_OFFLINE_LICENSE_TOKEN` (after trim) selects +/// [DittoMode.offline]; otherwise the app uses [DittoMode.onlinePlayground]. +enum DittoMode { onlinePlayground, offline } + +DittoMode selectDittoMode(String? offlineLicenseToken) { + final token = offlineLicenseToken?.trim() ?? ''; + return token.isEmpty ? DittoMode.onlinePlayground : DittoMode.offline; +} diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index 4f2b1b8dd..105f41f5c 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -1,8 +1,10 @@ import 'dart:io' show Platform; +import 'dart:math'; import 'package:ditto_live/ditto_live.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_quickstart/dialog.dart'; +import 'package:flutter_quickstart/ditto_mode.dart'; import 'package:flutter_quickstart/dql_builder.dart'; import 'package:flutter_quickstart/task.dart'; import 'package:flutter/material.dart'; @@ -27,11 +29,12 @@ class _DittoExampleState extends State { Ditto? _ditto; final appID = dotenv.env['DITTO_APP_ID'] ?? (throw Exception("env not found")); - final token = dotenv.env['DITTO_PLAYGROUND_TOKEN'] ?? - (throw Exception("env not found")); + final token = dotenv.env['DITTO_PLAYGROUND_TOKEN'] ?? ''; final authUrl = dotenv.env['DITTO_AUTH_URL']; - final websocketUrl = - dotenv.env['DITTO_WEBSOCKET_URL'] ?? (throw Exception("env not found")); + final websocketUrl = dotenv.env['DITTO_WEBSOCKET_URL'] ?? ''; + final offlineLicenseToken = + (dotenv.env['DITTO_OFFLINE_LICENSE_TOKEN'] ?? '').trim(); + late final DittoMode mode = selectDittoMode(offlineLicenseToken); @override void initState() { @@ -70,19 +73,49 @@ class _DittoExampleState extends State { await Ditto.init(); - final identity = OnlinePlaygroundIdentity( + if (mode == DittoMode.onlinePlayground) { + final missing = [ + if (token.trim().isEmpty) 'DITTO_PLAYGROUND_TOKEN', + if ((authUrl ?? '').trim().isEmpty) 'DITTO_AUTH_URL', + if (websocketUrl.trim().isEmpty) 'DITTO_WEBSOCKET_URL', + ]; + if (missing.isNotEmpty) { + throw Exception( + 'Online Playground mode requires: ${missing.join(', ')}. ' + 'Set DITTO_OFFLINE_LICENSE_TOKEN to use offline mode instead.', + ); + } + } + + final Identity identity; + if (mode == DittoMode.offline) { + // Site IDs distinguish peers; generate a per-run value to avoid collisions + // when multiple offline instances run on the same machine. + identity = OfflinePlaygroundIdentity( appID: appID, - token: token, - enableDittoCloudSync: - false, // This is required to be set to false to use the correct URLs - customAuthUrl: authUrl); + siteID: SiteID.fromInt(2 + Random().nextInt(0x7FFFFFFF)), + ); + } else { + identity = OnlinePlaygroundIdentity( + appID: appID, + token: token, + enableDittoCloudSync: + false, // This is required to be set to false to use the correct URLs + customAuthUrl: authUrl); + } final ditto = await Ditto.open(identity: identity); + if (mode == DittoMode.offline) { + ditto.setOfflineOnlyLicenseToken(offlineLicenseToken); + } + ditto.updateTransportConfig((config) { // Note: this will not enable peer-to-peer sync on the web platform config.setAllPeerToPeerEnabled(true); - config.connect.webSocketUrls.add(websocketUrl); + if (mode == DittoMode.onlinePlayground) { + config.connect.webSocketUrls.add(websocketUrl); + } }); // Disable DQL strict mode diff --git a/flutter_app/test/ditto_mode_test.dart b/flutter_app/test/ditto_mode_test.dart new file mode 100644 index 000000000..59dd8192a --- /dev/null +++ b/flutter_app/test/ditto_mode_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_quickstart/ditto_mode.dart'; + +void main() { + group('selectDittoMode', () { + test('null token selects online', () { + expect(selectDittoMode(null), DittoMode.onlinePlayground); + }); + + test('empty token selects online', () { + expect(selectDittoMode(''), DittoMode.onlinePlayground); + }); + + test('whitespace-only token selects online', () { + expect(selectDittoMode(' \t\n '), DittoMode.onlinePlayground); + }); + + test('non-empty token selects offline', () { + expect(selectDittoMode('any-real-license-token'), DittoMode.offline); + }); + }); +} diff --git a/go-tui/README.md b/go-tui/README.md index 17e26f69f..d3679999f 100644 --- a/go-tui/README.md +++ b/go-tui/README.md @@ -137,3 +137,11 @@ The app follows an event-driven architecture: - Manual text input handling for create/edit modes - Async updates from Ditto observers via Go channels - Real-time sync with other Ditto peers running the same app + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/go-tui/main.go b/go-tui/main.go index 673703fae..f09aee1ef 100644 --- a/go-tui/main.go +++ b/go-tui/main.go @@ -86,9 +86,14 @@ func main() { appID := os.Getenv("DITTO_APP_ID") token := os.Getenv("DITTO_PLAYGROUND_TOKEN") authURL := os.Getenv("DITTO_AUTH_URL") + offlineLicenseToken := strings.TrimSpace(os.Getenv("DITTO_OFFLINE_LICENSE_TOKEN")) + isOffline := offlineLicenseToken != "" - if appID == "" || token == "" || authURL == "" { - log.Fatal("Missing required environment variables. Please set DITTO_APP_ID, DITTO_PLAYGROUND_TOKEN, and DITTO_AUTH_URL") + if appID == "" { + log.Fatal("Missing required environment variable DITTO_APP_ID") + } + if !isOffline && (token == "" || authURL == "") { + log.Fatal("Missing required environment variables. Please set DITTO_APP_ID, DITTO_PLAYGROUND_TOKEN, and DITTO_AUTH_URL (or set DITTO_OFFLINE_LICENSE_TOKEN to run in offline mode)") } // Create temp directory for persistence @@ -98,11 +103,19 @@ func main() { } defer os.RemoveAll(tempDir) - // Initialize Ditto with Server connection API + // Initialize Ditto. In offline mode the SmallPeersOnly connect variant is used + // and the app activates with the offline-only license token; otherwise we use + // the Server connect variant and authenticate with the playground token. + var connect ditto.DittoConfigConnect + if isOffline { + connect = &ditto.DittoConfigConnectSmallPeersOnly{} + } else { + connect = &ditto.DittoConfigConnectServer{URL: authURL} + } config := ditto.DefaultDittoConfig(). WithDatabaseID(appID). WithPersistenceDirectory(tempDir). - WithConnect(&ditto.DittoConfigConnectServer{URL: authURL}) + WithConnect(connect) d, err := ditto.Open(config) if err != nil { @@ -110,24 +123,30 @@ func main() { } defer d.Close() - // Set up authentication handler for development mode - if auth := d.Auth(); auth != nil { - auth.SetExpirationHandler( - func(d *ditto.Ditto, timeUntilExpiration time.Duration) { - log.Printf("Expiration handler called with time until expiration: %v", timeUntilExpiration) - - // For development mode, login with the playground token - provider := ditto.DevelopmentAuthenticationProvider() - clientInfoJSON, err := d.Auth().Login(token, provider) - if err != nil { - log.Printf("Failed to login: %v", err) - } else { - log.Printf("Login successful") - if clientInfoJSON != "" { - log.Printf("Client info: %s", clientInfoJSON) + if isOffline { + if err := d.SetOfflineOnlyLicenseToken(offlineLicenseToken); err != nil { + log.Fatal("Failed to set offline-only license token:", err) + } + } else { + // Set up authentication handler for development mode + if auth := d.Auth(); auth != nil { + auth.SetExpirationHandler( + func(d *ditto.Ditto, timeUntilExpiration time.Duration) { + log.Printf("Expiration handler called with time until expiration: %v", timeUntilExpiration) + + // For development mode, login with the playground token + provider := ditto.DevelopmentAuthenticationProvider() + clientInfoJSON, err := d.Auth().Login(token, provider) + if err != nil { + log.Printf("Failed to login: %v", err) + } else { + log.Printf("Login successful") + if clientInfoJSON != "" { + log.Printf("Client info: %s", clientInfoJSON) + } } - } - }) + }) + } } // Start sync (authentication handler will be called automatically if needed) diff --git a/java-server/README.md b/java-server/README.md index 93afeeb3f..968e30e02 100644 --- a/java-server/README.md +++ b/java-server/README.md @@ -18,3 +18,11 @@ For more information, see - [Java Install Guide](https://docs.ditto.live/sdk/lat - [Java Roadmap and Support Policy](https://docs.ditto.live/sdk/latest/install-guides/java/roadmap) - [API Reference](https://software.ditto.live/java/ditto-java/4.11.0-preview.1/api-reference/) + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/java-server/buildSrc/src/main/kotlin/quickstart-conventions.gradle.kts b/java-server/buildSrc/src/main/kotlin/quickstart-conventions.gradle.kts index 6a8f8bc98..144028744 100644 --- a/java-server/buildSrc/src/main/kotlin/quickstart-conventions.gradle.kts +++ b/java-server/buildSrc/src/main/kotlin/quickstart-conventions.gradle.kts @@ -45,6 +45,11 @@ val generateSecretProperties by tasks.registering { """.trimIndent()) } + // Optional keys default to empty string so generated code always + // compiles. DITTO_OFFLINE_LICENSE_TOKEN is the offline-mode toggle: + // non-empty after trim means "init in offline mode." + properties.putIfAbsent("DITTO_OFFLINE_LICENSE_TOKEN", "") + val javaSource = """ |package com.ditto.example.spring.quickstart.configuration; | diff --git a/java-server/src/main/java/com/ditto/example/spring/quickstart/configuration/DittoMode.java b/java-server/src/main/java/com/ditto/example/spring/quickstart/configuration/DittoMode.java new file mode 100644 index 000000000..35880e8a6 --- /dev/null +++ b/java-server/src/main/java/com/ditto/example/spring/quickstart/configuration/DittoMode.java @@ -0,0 +1,19 @@ +package com.ditto.example.spring.quickstart.configuration; + +/** + * Identity-mode selection based on env vars. + * + *

Non-empty {@code DITTO_OFFLINE_LICENSE_TOKEN} (after trim) selects {@link #OFFLINE}; + * otherwise the app uses {@link #ONLINE_PLAYGROUND}. + */ +public enum DittoMode { + ONLINE_PLAYGROUND, + OFFLINE; + + public static DittoMode select(String offlineLicenseToken) { + if (offlineLicenseToken == null) { + return ONLINE_PLAYGROUND; + } + return offlineLicenseToken.trim().isEmpty() ? ONLINE_PLAYGROUND : OFFLINE; + } +} diff --git a/java-server/src/main/java/com/ditto/example/spring/quickstart/service/DittoService.java b/java-server/src/main/java/com/ditto/example/spring/quickstart/service/DittoService.java index c47c15bed..84607fff4 100644 --- a/java-server/src/main/java/com/ditto/example/spring/quickstart/service/DittoService.java +++ b/java-server/src/main/java/com/ditto/example/spring/quickstart/service/DittoService.java @@ -1,6 +1,7 @@ package com.ditto.example.spring.quickstart.service; import com.ditto.example.spring.quickstart.configuration.DittoConfigurationKeys; +import com.ditto.example.spring.quickstart.configuration.DittoMode; import com.ditto.example.spring.quickstart.configuration.DittoSecretsConfiguration; import com.ditto.java.*; import com.ditto.java.serialization.DittoCborSerializable; @@ -41,32 +42,48 @@ public class DittoService implements DisposableBean { File dittoDir = new File(environment.getRequiredProperty(DittoConfigurationKeys.DITTO_DIR)); dittoDir.mkdirs(); + String offlineLicenseToken = DittoSecretsConfiguration.DITTO_OFFLINE_LICENSE_TOKEN.trim(); + boolean isOffline = DittoMode.select(offlineLicenseToken) == DittoMode.OFFLINE; + /* * Setup Ditto Config * https://docs.ditto.live/sdk/latest/install-guides/java#integrating-and-initializing */ - DittoConfig dittoConfig = new DittoConfig.Builder(DittoSecretsConfiguration.DITTO_APP_ID) -// .persistenceDirectory("/tmp/ditto-quickstart") - .serverConnect(DittoSecretsConfiguration.DITTO_AUTH_URL) - .build(); + DittoConfig.Builder configBuilder = new DittoConfig.Builder(DittoSecretsConfiguration.DITTO_APP_ID); + if (isOffline) { + configBuilder.smallPeersOnlyConnect(null); + } else { + configBuilder.serverConnect(DittoSecretsConfiguration.DITTO_AUTH_URL); + } + DittoConfig dittoConfig = configBuilder.build(); this.ditto = DittoFactory.create(dittoConfig); - this.ditto.getAuth().setExpirationHandler((expiringDitto, _timeUntilExpiration) -> - expiringDitto.getAuth() - .login( - DittoSecretsConfiguration.DITTO_PLAYGROUND_TOKEN, - DittoAuthenticationProvider.development() - ).thenRun(() -> { }) - ); + if (isOffline) { + try { + this.ditto.setOfflineOnlyLicenseToken(offlineLicenseToken); + } catch (DittoException e) { + throw new RuntimeException("Failed to activate offline license token", e); + } + } else { + this.ditto.getAuth().setExpirationHandler((expiringDitto, _timeUntilExpiration) -> + expiringDitto.getAuth() + .login( + DittoSecretsConfiguration.DITTO_PLAYGROUND_TOKEN, + DittoAuthenticationProvider.development() + ).thenRun(() -> { }) + ); + } this.ditto.setDeviceName("Java"); this.ditto.updateTransportConfig(config -> { - config.connect(connect -> { - // Set the Ditto Websocket URL - connect.websocketUrls().add(DittoSecretsConfiguration.DITTO_WEBSOCKET_URL); - }); + if (!isOffline) { + config.connect(connect -> { + // Set the Ditto Websocket URL + connect.websocketUrls().add(DittoSecretsConfiguration.DITTO_WEBSOCKET_URL); + }); + } config.peerToPeer(p2p -> { p2p.bluetoothLe().isEnabled(true); p2p.lan().isEnabled(true); diff --git a/java-server/src/test/java/com/ditto/example/spring/quickstart/configuration/DittoModeTest.java b/java-server/src/test/java/com/ditto/example/spring/quickstart/configuration/DittoModeTest.java new file mode 100644 index 000000000..1e2e0bdd1 --- /dev/null +++ b/java-server/src/test/java/com/ditto/example/spring/quickstart/configuration/DittoModeTest.java @@ -0,0 +1,27 @@ +package com.ditto.example.spring.quickstart.configuration; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DittoModeTest { + @Test + void nullTokenSelectsOnline() { + assertEquals(DittoMode.ONLINE_PLAYGROUND, DittoMode.select(null)); + } + + @Test + void emptyTokenSelectsOnline() { + assertEquals(DittoMode.ONLINE_PLAYGROUND, DittoMode.select("")); + } + + @Test + void whitespaceOnlyTokenSelectsOnline() { + assertEquals(DittoMode.ONLINE_PLAYGROUND, DittoMode.select(" \t\n ")); + } + + @Test + void nonEmptyTokenSelectsOffline() { + assertEquals(DittoMode.OFFLINE, DittoMode.select("any-real-license-token")); + } +} diff --git a/javascript-tui/README.md b/javascript-tui/README.md index 41cc1c0b5..323d10469 100644 --- a/javascript-tui/README.md +++ b/javascript-tui/README.md @@ -90,3 +90,11 @@ npm run dev # Watch mode (rebuilds on file changes) npm run format # Format code with Prettier npm test # Run format check and integration tests ``` + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/javascript-tui/source/cli.js b/javascript-tui/source/cli.js index 2db1e48a8..b72011551 100644 --- a/javascript-tui/source/cli.js +++ b/javascript-tui/source/cli.js @@ -52,17 +52,24 @@ const appID = cli.flags.appId ?? process.env.DITTO_APP_ID; const token = cli.flags.playgroundToken ?? process.env.DITTO_PLAYGROUND_TOKEN; const authURL = cli.flags.authURL ?? process.env.DITTO_AUTH_URL; const websocketURL = cli.flags.websocketURL ?? process.env.DITTO_WEBSOCKET_URL; +const offlineLicenseToken = ( + process.env.DITTO_OFFLINE_LICENSE_TOKEN ?? '' +).trim(); +const isOffline = offlineLicenseToken.length > 0; // Create a new Ditto instance with the DittoConfig // https://docs.ditto.live/sdk/latest/install-guides/nodejs#installing-the-demo-task-app -const connectConfig = { - mode: 'server', - url: authURL, -}; +const connectConfig = isOffline + ? {mode: 'smallPeersOnly'} + : {mode: 'server', url: authURL}; const config = new DittoConfig(appID, connectConfig, tempdir); const ditto = await Ditto.open(config); +if (isOffline) { + ditto.setOfflineOnlyLicenseToken(offlineLicenseToken); +} + // Initialize transport config — enable LAN P2P and WebSocket. // BLE and AWDL are disabled because they require macOS entitlements // that are only available to signed app bundles, not Node.js processes. diff --git a/javascript-web/README.md b/javascript-web/README.md index 7054ac1d2..af83d49e8 100644 --- a/javascript-web/README.md +++ b/javascript-web/README.md @@ -48,3 +48,11 @@ Next, run the quickstart app with the following command: ``` npm install && npm run dev ``` + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/javascript-web/src/App.tsx b/javascript-web/src/App.tsx index 902d8f708..bf0c746a6 100644 --- a/javascript-web/src/App.tsx +++ b/javascript-web/src/App.tsx @@ -50,36 +50,58 @@ const App = () => { // Step 1: Initialize WASM (MUST be first) await init(); + const offlineLicenseToken = ( + import.meta.env.DITTO_OFFLINE_LICENSE_TOKEN ?? '' + ).trim(); + const isOffline = offlineLicenseToken.length > 0; + // Step 2: Create config (AFTER init) - const config = new DittoConfig(import.meta.env.DITTO_APP_ID, { - mode: 'server', - url: import.meta.env.DITTO_AUTH_URL, - }); + const config = isOffline + ? new DittoConfig(import.meta.env.DITTO_APP_ID, { + mode: 'smallPeersOnly', + }) + : new DittoConfig(import.meta.env.DITTO_APP_ID, { + mode: 'server', + url: import.meta.env.DITTO_AUTH_URL, + }); // Step 3: Open Ditto instance ditto.current = await Ditto.open(config); - // Step 4: Set up authentication expiration handler (required for server connections) - await ditto.current.auth.setExpirationHandler(async (dittoInstance) => { - // Authenticate when token is expiring. Any errors will be logged in the Ditto logger. - const loginResult = await dittoInstance.auth.login( - import.meta.env.DITTO_PLAYGROUND_TOKEN, - Authenticator.DEVELOPMENT_PROVIDER, + if (isOffline) { + // Step 4 (offline): activate with the offline-only license token. + ditto.current.setOfflineOnlyLicenseToken(offlineLicenseToken); + } else { + // Step 4 (online): set up authentication expiration handler + // (required for server connections). + await ditto.current.auth.setExpirationHandler( + async (dittoInstance) => { + // Authenticate when token is expiring. Any errors will be logged in the Ditto logger. + const loginResult = await dittoInstance.auth.login( + import.meta.env.DITTO_PLAYGROUND_TOKEN, + Authenticator.DEVELOPMENT_PROVIDER, + ); + if (loginResult.error) { + console.error( + '❌ Re-authentication failed:', + loginResult.error, + ); + } else { + console.log( + '✅ Successfully re-authenticated with info:', + loginResult, + ); + } + }, ); - if (loginResult.error) { - console.error('❌ Re-authentication failed:', loginResult.error); - } else { - console.log( - '✅ Successfully re-authenticated with info:', - loginResult, - ); - } - }); - - // Step 5: Configure transport - ditto.current.updateTransportConfig((config) => { - config.connect.websocketURLs = [import.meta.env.DITTO_WEBSOCKET_URL]; - }); + + // Step 5: Configure transport (websocket URL is online-only) + ditto.current.updateTransportConfig((config) => { + config.connect.websocketURLs = [ + import.meta.env.DITTO_WEBSOCKET_URL, + ]; + }); + } // Step 6: Start sync ditto.current.sync.start(); diff --git a/kotlin-multiplatform/README.md b/kotlin-multiplatform/README.md index dac8044c7..5fff61794 100644 --- a/kotlin-multiplatform/README.md +++ b/kotlin-multiplatform/README.md @@ -22,3 +22,11 @@ For more information, see - [Kotlin Multiplatform Install Guide](https://docs.di - [Kotlin Multiplatform Roadmap and Support Policy](https://docs.ditto.live/sdk/latest/install-guides/kotlin/multiplatform-roadmap) - [API Reference](https://software.ditto.live/java/ditto-java/5.0.0-preview.3/api-reference/) + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/kotlin-multiplatform/build-logic/src/main/kotlin/quickstart-conventions.gradle.kts b/kotlin-multiplatform/build-logic/src/main/kotlin/quickstart-conventions.gradle.kts index 24d9825a1..c61d58c4e 100644 --- a/kotlin-multiplatform/build-logic/src/main/kotlin/quickstart-conventions.gradle.kts +++ b/kotlin-multiplatform/build-logic/src/main/kotlin/quickstart-conventions.gradle.kts @@ -43,6 +43,11 @@ val generateSecretProperties by tasks.registering { """.trimIndent()) } + // Optional keys default to empty string so generated code always + // compiles. DITTO_OFFLINE_LICENSE_TOKEN is the offline-mode toggle: + // non-empty after trim means "init in offline mode." + properties.putIfAbsent("DITTO_OFFLINE_LICENSE_TOKEN", "") + val javaSource = """ |package com.ditto.example.kotlin.quickstart.configuration | diff --git a/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/ditto/DittoManager.kt b/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/ditto/DittoManager.kt index 49e137614..2bc4f0b66 100644 --- a/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/ditto/DittoManager.kt +++ b/kotlin-multiplatform/composeApp/src/commonMain/kotlin/com/ditto/quickstart/ditto/DittoManager.kt @@ -46,23 +46,37 @@ class DittoManager( ditto = try { DittoLogger.minimumLogLevel = DittoLogLevel.Info - val config = DittoConfig( - databaseId = secrets.DITTO_APP_ID, - connect = DittoConfig.Connect.Server( - url = secrets.DITTO_AUTH_URL, - ), - ) + val offlineLicenseToken = secrets.DITTO_OFFLINE_LICENSE_TOKEN.trim() + val isOffline = offlineLicenseToken.isNotEmpty() + + val config = if (isOffline) { + DittoConfig( + databaseId = secrets.DITTO_APP_ID, + connect = DittoConfig.Connect.SmallPeersOnly(privateKey = null), + ) + } else { + DittoConfig( + databaseId = secrets.DITTO_APP_ID, + connect = DittoConfig.Connect.Server( + url = secrets.DITTO_AUTH_URL, + ), + ) + } createDitto( config = config ).apply { - auth?.setExpirationHandler { ditto, _ -> - // Authenticate when a token is expiring - val clientInfo = ditto.auth?.login( - token = secrets.DITTO_PLAYGROUND_TOKEN, - provider = DittoAuthenticationProvider.development(), - ) - DittoLog.d(TAG, "Auth response: $clientInfo") + if (isOffline) { + setOfflineOnlyLicenseToken(offlineLicenseToken) + } else { + auth?.setExpirationHandler { ditto, _ -> + // Authenticate when a token is expiring + val clientInfo = ditto.auth?.login( + token = secrets.DITTO_PLAYGROUND_TOKEN, + provider = DittoAuthenticationProvider.development(), + ) + DittoLog.d(TAG, "Auth response: $clientInfo") + } } updateTransportConfig { config -> config.peerToPeer.lan.enabled = true diff --git a/react-native-expo/App.tsx b/react-native-expo/App.tsx index 5ca482c1a..7a3956d86 100644 --- a/react-native-expo/App.tsx +++ b/react-native-expo/App.tsx @@ -22,6 +22,7 @@ import { DITTO_PLAYGROUND_TOKEN, DITTO_AUTH_URL, DITTO_WEBSOCKET_URL, + DITTO_OFFLINE_LICENSE_TOKEN, } from "@env"; import Fab from "./components/Fab"; @@ -122,22 +123,25 @@ const App = () => { // https://docs.ditto.live/sdk/latest/install-guides/react-native#onlineplayground const databaseId = DITTO_APP_ID; const playgroundToken = DITTO_PLAYGROUND_TOKEN; + const offlineLicenseToken = (DITTO_OFFLINE_LICENSE_TOKEN ?? '').trim(); + const isOffline = offlineLicenseToken.length > 0; - const connectConfig: DittoConfigConnect = { - mode: 'server', - url: DITTO_AUTH_URL, - }; + const connectConfig: DittoConfigConnect = isOffline + ? {mode: 'smallPeersOnly'} + : {mode: 'server', url: DITTO_AUTH_URL}; const config = new DittoConfig(databaseId, connectConfig, 'custom-folder'); ditto.current = await Ditto.open(config); - // Configure websocket URL for transport - ditto.current.updateTransportConfig((transportConfig) => { - transportConfig.connect.websocketURLs = [DITTO_WEBSOCKET_URL]; - }); + if (isOffline) { + ditto.current.setOfflineOnlyLicenseToken(offlineLicenseToken); + } else { + // Configure websocket URL for transport (online mode only) + ditto.current.updateTransportConfig((transportConfig) => { + transportConfig.connect.websocketURLs = [DITTO_WEBSOCKET_URL]; + }); - if (connectConfig.mode === 'server') { await ditto.current.auth.setExpirationHandler(async (dittoInstance, timeUntilExpiration) => { console.log('Authentication expiring soon, time until expiration:', timeUntilExpiration); diff --git a/react-native-expo/README.md b/react-native-expo/README.md index ae179ef87..33b6047c8 100644 --- a/react-native-expo/README.md +++ b/react-native-expo/README.md @@ -53,3 +53,11 @@ Clean everything and start fresh: ```sh yarn clean ``` + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/react-native-expo/types/env.d.ts b/react-native-expo/types/env.d.ts index c6ed728f5..a0bf61637 100644 --- a/react-native-expo/types/env.d.ts +++ b/react-native-expo/types/env.d.ts @@ -2,6 +2,8 @@ declare module '@env' { export const DITTO_APP_ID: string; - export const DITTO_PLAYGROUND_TOKEN: string; + export const DITTO_AUTH_URL: string; + export const DITTO_WEBSOCKET_URL: string; + export const DITTO_OFFLINE_LICENSE_TOKEN: string; } diff --git a/react-native/App.tsx b/react-native/App.tsx index 5c5e4a10a..5d8220c9a 100644 --- a/react-native/App.tsx +++ b/react-native/App.tsx @@ -22,7 +22,9 @@ import { DITTO_PLAYGROUND_TOKEN, DITTO_AUTH_URL, DITTO_WEBSOCKET_URL, + DITTO_OFFLINE_LICENSE_TOKEN, } from '@env'; +import {selectMode} from './dittoMode'; import Fab from './components/Fab'; import NewTaskModal from './components/NewTaskModal'; @@ -122,22 +124,26 @@ const App = () => { // https://docs.ditto.live/sdk/latest/install-guides/react-native#onlineplayground const databaseId = DITTO_APP_ID; const playgroundToken = DITTO_PLAYGROUND_TOKEN; + const offlineLicenseToken = (DITTO_OFFLINE_LICENSE_TOKEN ?? '').trim(); + const mode = selectMode(offlineLicenseToken); - const connectConfig: DittoConfigConnect = { - mode: 'server', - url: DITTO_AUTH_URL, - }; + const connectConfig: DittoConfigConnect = + mode === 'offline' + ? {mode: 'smallPeersOnly'} + : {mode: 'server', url: DITTO_AUTH_URL}; const config = new DittoConfig(databaseId, connectConfig, 'custom-folder'); ditto.current = await Ditto.open(config); - // Configure websocket URL for transport - ditto.current.updateTransportConfig((transportConfig) => { - transportConfig.connect.websocketURLs = [DITTO_WEBSOCKET_URL]; - }); + if (mode === 'offline') { + ditto.current.setOfflineOnlyLicenseToken(offlineLicenseToken); + } else { + // Configure websocket URL for transport (online mode only) + ditto.current.updateTransportConfig((transportConfig) => { + transportConfig.connect.websocketURLs = [DITTO_WEBSOCKET_URL]; + }); - if (connectConfig.mode === 'server') { await ditto.current.auth.setExpirationHandler(async (dittoInstance, timeUntilExpiration) => { console.log('Authentication expiring soon, time until expiration:', timeUntilExpiration); diff --git a/react-native/README.md b/react-native/README.md index 20c3f646b..0ca2af274 100644 --- a/react-native/README.md +++ b/react-native/README.md @@ -96,3 +96,11 @@ Should you encounter any issues, please refer to the [Ditto documentation](https ### Contact For support or queries, reach out to us via [support@ditto.com](mailto:support@ditto.com). + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/react-native/__tests__/dittoMode.test.ts b/react-native/__tests__/dittoMode.test.ts new file mode 100644 index 000000000..0e6561fce --- /dev/null +++ b/react-native/__tests__/dittoMode.test.ts @@ -0,0 +1,23 @@ +import {selectMode} from '../dittoMode'; + +describe('selectMode', () => { + it('null token selects online', () => { + expect(selectMode(null)).toBe('online'); + }); + + it('undefined token selects online', () => { + expect(selectMode(undefined)).toBe('online'); + }); + + it('empty token selects online', () => { + expect(selectMode('')).toBe('online'); + }); + + it('whitespace-only token selects online', () => { + expect(selectMode(' \t\n ')).toBe('online'); + }); + + it('non-empty token selects offline', () => { + expect(selectMode('any-real-license-token')).toBe('offline'); + }); +}); diff --git a/react-native/dittoMode.ts b/react-native/dittoMode.ts new file mode 100644 index 000000000..5038be5c0 --- /dev/null +++ b/react-native/dittoMode.ts @@ -0,0 +1,15 @@ +/** + * Identity-mode selection based on env vars. + * + * Non-empty `DITTO_OFFLINE_LICENSE_TOKEN` (after trim) selects `'offline'`; + * otherwise the app uses `'online'`. + */ +export type DittoMode = 'online' | 'offline'; + +export function selectMode( + offlineLicenseToken: string | null | undefined, +): DittoMode { + return offlineLicenseToken && offlineLicenseToken.trim().length > 0 + ? 'offline' + : 'online'; +} diff --git a/react-native/types/env.d.ts b/react-native/types/env.d.ts index 7d4c20d09..211978185 100644 --- a/react-native/types/env.d.ts +++ b/react-native/types/env.d.ts @@ -4,4 +4,5 @@ declare module '@env' { export const DITTO_PLAYGROUND_TOKEN: string; export const DITTO_AUTH_URL: string; export const DITTO_WEBSOCKET_URL: string; + export const DITTO_OFFLINE_LICENSE_TOKEN: string; } diff --git a/rust-tui/README.md b/rust-tui/README.md index 250b586fd..ba660da79 100644 --- a/rust-tui/README.md +++ b/rust-tui/README.md @@ -42,3 +42,11 @@ cargo run 2>/dev/null > that would interfere with the TUI application. Without it, the screen will > quickly become garbled. + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/rust-tui/src/bin/main.rs b/rust-tui/src/bin/main.rs index d75659af3..dbd67148c 100644 --- a/rust-tui/src/bin/main.rs +++ b/rust-tui/src/bin/main.rs @@ -3,7 +3,11 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; use anyhow::{anyhow, Context, Result}; use clap::Parser; use ditto_quickstart::{term, tui::TuiTask, Shutdown}; -use dittolive_ditto::{fs::TempRoot, identity::OnlinePlayground, AppId, Ditto}; +use dittolive_ditto::{ + fs::TempRoot, + identity::{OfflinePlayground, OnlinePlayground}, + AppId, Ditto, +}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[derive(Debug, Parser)] @@ -13,17 +17,23 @@ pub struct Cli { app_id: AppId, /// The Online Playground token this app should use for authentication - #[clap(long, env = "DITTO_PLAYGROUND_TOKEN")] + #[clap(long, env = "DITTO_PLAYGROUND_TOKEN", default_value = "")] token: String, /// The custom auth URL this app should use for authentication - #[clap(long, env = "DITTO_AUTH_URL")] + #[clap(long, env = "DITTO_AUTH_URL", default_value = "")] custom_auth_url: String, /// The websocket URL this app should use for authentication - #[clap(long, env = "DITTO_WEBSOCKET_URL")] + #[clap(long, env = "DITTO_WEBSOCKET_URL", default_value = "")] websocket_url: String, + /// Optional offline-only license token. When non-empty, the app + /// initializes in offline-only mode and the playground/auth/websocket + /// values above are not used. + #[clap(long, env = "DITTO_OFFLINE_LICENSE_TOKEN", default_value = "")] + offline_license_token: String, + /// Optional client name to display in the TUI #[clap(long, env = "DITTO_CLIENT_NAME")] client_name: Option, @@ -65,6 +75,7 @@ async fn main() -> Result<()> { cli.token, cli.custom_auth_url, cli.websocket_url.clone(), + cli.offline_license_token.clone(), cli.p2p_enabled, ) .await?; @@ -110,8 +121,29 @@ async fn try_init_ditto( token: String, custom_auth_url: String, websocket_url: String, + offline_license_token: String, p2p_enabled: bool, ) -> Result { + let mode = select_mode(&offline_license_token); + + if mode == DittoMode::OnlinePlayground { + let missing: Vec<&str> = [ + ("DITTO_PLAYGROUND_TOKEN", token.trim()), + ("DITTO_AUTH_URL", custom_auth_url.trim()), + ("DITTO_WEBSOCKET_URL", websocket_url.trim()), + ] + .into_iter() + .filter(|(_, v)| v.is_empty()) + .map(|(k, _)| k) + .collect(); + if !missing.is_empty() { + anyhow::bail!( + "Online Playground mode requires: {}. Set DITTO_OFFLINE_LICENSE_TOKEN to use offline mode instead.", + missing.join(", ") + ); + } + } + // We use a temporary directory to store Ditto's local database. // This means that data will not be persistent between runs of the // application, but it allows us to run multiple instances of the @@ -119,18 +151,27 @@ async fn try_init_ditto( // application, we would want to store the database in a more permanent // location, and if multiple instances are needed, ensure that each // instance has its own persistence directory. - let ditto = Ditto::builder() - .with_root(Arc::new(TempRoot::new())) - .with_identity(|root| { - OnlinePlayground::new( - root, - app_id.clone(), - token, - false, // This is required to be set to false to use the correct URLs - Some(custom_auth_url.as_str()), - ) - })? - .build()?; + let builder = Ditto::builder().with_root(Arc::new(TempRoot::new())); + let ditto = match mode { + DittoMode::Offline => builder + .with_identity(|root| OfflinePlayground::new(root, app_id.clone()))? + .build()?, + DittoMode::OnlinePlayground => builder + .with_identity(|root| { + OnlinePlayground::new( + root, + app_id.clone(), + token, + false, // This is required to be set to false to use the correct URLs + Some(custom_auth_url.as_str()), + ) + })? + .build()?, + }; + + if let DittoMode::Offline = mode { + ditto.set_offline_only_license_token(offline_license_token.trim())?; + } ditto.update_transport_config(|config| { if p2p_enabled { @@ -142,8 +183,10 @@ async fn try_init_ditto( config.peer_to_peer.lan.enabled = false; } - // Set WebSocket URL for Big Peer connection - config.connect.websocket_urls.insert(websocket_url); + if let DittoMode::OnlinePlayground = mode { + // Set WebSocket URL for Big Peer connection (online mode only) + config.connect.websocket_urls.insert(websocket_url); + } }); // disable sync with v3 peers, required for DQL @@ -163,6 +206,43 @@ async fn try_init_ditto( Ok(ditto) } +/// Identity-mode selection based on env vars. Non-empty +/// `DITTO_OFFLINE_LICENSE_TOKEN` (after trim) selects [`DittoMode::Offline`]; +/// otherwise the app uses [`DittoMode::OnlinePlayground`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DittoMode { + OnlinePlayground, + Offline, +} + +pub fn select_mode(offline_license_token: &str) -> DittoMode { + if offline_license_token.trim().is_empty() { + DittoMode::OnlinePlayground + } else { + DittoMode::Offline + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_token_selects_online() { + assert_eq!(DittoMode::OnlinePlayground, select_mode("")); + } + + #[test] + fn whitespace_only_token_selects_online() { + assert_eq!(DittoMode::OnlinePlayground, select_mode(" \t\n ")); + } + + #[test] + fn non_empty_token_selects_offline() { + assert_eq!(DittoMode::Offline, select_mode("any-real-license-token")); + } +} + /// Load .env file from git repo root rather than `rust/` fn try_init_dotenv() -> Result<()> { let git_toplevel_output = std::process::Command::new("git") diff --git a/swift/README.md b/swift/README.md index 56596b8f7..f744c2f2b 100644 --- a/swift/README.md +++ b/swift/README.md @@ -35,3 +35,11 @@ edit, and delete tasks in the app. If you run the app on additional devices or emulators, the data will be synced between them. + +## Offline-only mode (optional) + +Set `DITTO_OFFLINE_LICENSE_TOKEN` in the repo-root `.env` to run this +app in offline-only mode (peer-to-peer only, no cloud sync). When the +token is non-empty, the playground/auth/websocket vars are not used. +Request a token from . See the top-level +[README](../README.md#offline-only-mode-optional) for full details. diff --git a/swift/Tasks/DittoManager.swift b/swift/Tasks/DittoManager.swift index 2eca54c2c..25e204ce8 100644 --- a/swift/Tasks/DittoManager.swift +++ b/swift/Tasks/DittoManager.swift @@ -28,28 +28,42 @@ class DittoManager: ObservableObject { DittoLogger.minimumLogLevel = .debug } - guard let authURL = URL(string: Env.DITTO_AUTH_URL) else { - throw DittoManagerError.invalidAuthURL(Env.DITTO_AUTH_URL) - } + let offlineLicenseToken = Env.DITTO_OFFLINE_LICENSE_TOKEN + .trimmingCharacters(in: .whitespacesAndNewlines) + let isOffline = !offlineLicenseToken.isEmpty // https://docs.ditto.live/sdk/latest/ditto-config - let config = DittoConfig( - databaseID: Env.DITTO_APP_ID, - connect: .server(url: authURL)) + let config: DittoConfig + if isOffline { + config = DittoConfig( + databaseID: Env.DITTO_APP_ID, + connect: .smallPeersOnly(privateKey: nil)) + } else { + guard let authURL = URL(string: Env.DITTO_AUTH_URL) else { + throw DittoManagerError.invalidAuthURL(Env.DITTO_AUTH_URL) + } + config = DittoConfig( + databaseID: Env.DITTO_APP_ID, + connect: .server(url: authURL)) + } do { let dittoOpened = try await Ditto.open(config: config) - dittoOpened.auth?.expirationHandler = { ditto, secondsRemaining in - // Authenticate when token is expiring. This closure must not throw. - ditto.auth?.login(token: Env.DITTO_PLAYGROUND_TOKEN, - provider: .development) { clientInfo, error in - if let error = error { - // Cannot throw from here; log the error instead. - print( - "Ditto auth refresh failed: \(error), " + - "client info: \(String(describing: clientInfo)), " + - "seconds remaining \(secondsRemaining)" - ) + if isOffline { + try dittoOpened.setOfflineOnlyLicenseToken(offlineLicenseToken) + } else { + dittoOpened.auth?.expirationHandler = { ditto, secondsRemaining in + // Authenticate when token is expiring. This closure must not throw. + ditto.auth?.login(token: Env.DITTO_PLAYGROUND_TOKEN, + provider: .development) { clientInfo, error in + if let error = error { + // Cannot throw from here; log the error instead. + print( + "Ditto auth refresh failed: \(error), " + + "client info: \(String(describing: clientInfo)), " + + "seconds remaining \(secondsRemaining)" + ) + } } } } diff --git a/swift/buildEnv.sh b/swift/buildEnv.sh index a76d9cb41..444476e7e 100755 --- a/swift/buildEnv.sh +++ b/swift/buildEnv.sh @@ -17,6 +17,8 @@ if [ $# -ne 2 ]; then exit 1 fi +has_offline_token=0 + if [ -f "$1" ]; then while IFS='' read -r line || [[ -n "$line" ]]; do line="${line//[$'\r\n']}" @@ -24,6 +26,9 @@ if [ -f "$1" ]; then if [ -n "$trimline" ] && [[ $trimline != \#* ]]; then KEY="${line%%=*}" VALUE="$(echo "${line#*=}" | sed 's/^[[:space:]]*//; s/^"//; s/"$//')" + if [ "$KEY" = "DITTO_OFFLINE_LICENSE_TOKEN" ]; then + has_offline_token=1 + fi code=$(cat <