diff --git a/README.md b/README.md
index f71ef32..e3ce5b8 100644
--- a/README.md
+++ b/README.md
@@ -24,53 +24,56 @@ Demo applications are located in the [`demos/`](./demos/) directory. Also see ou
- [demos/CommandLine](./demos/CommandLine/README.md): A CLI-based app demonstrating real-time data sync
- [demos/WPF](./demos/WPF/README.md): A Windows desktop to-do list app real-time data sync
- [demos/MAUITodo](./demos/MAUITodo/README.md): A cross-platform mobile and desktop to-do list app built with .NET MAUI, running on iOS, Android and Windows
+- [demos/UnoTodo](./demos/UnoTodo/ReadMe.md): A cross-platform mobile and desktop to-do list app built with Uno Platform
# Supported Frameworks
## Target Frameworks
This PowerSync SDK supports the following target frameworks:
+
- **.NET 9** - [Latest version](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
-- **.NET 8** - [Current LTS Version, used for development of this project](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
+- **.NET 8** - [Current LTS Version, used for development of this project](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
- **.NET 6** - supported for compatibility with older projects
-- **.NET Standard 2.0** - for compatibility with older libraries and frameworks, tested/verified older versions will be listed below.
+- **.NET Standard 2.0** - for compatibility with older libraries and frameworks, tested/verified older versions will be listed below.
- .NET Framework 4.8:
-
- To get a .NET Framework 4.8 working with this SDK add the following to your `.csproj` file:
-
- ```xml
-
- ...
-
- win-x86;win-x64
- win-x64
-
-
-
- ...
-
-
-
- ```
-
- and create a `IsExternalInit.cs` file in your project with the following contents:
-
- ```cs
- using System.ComponentModel;
-
- namespace System.Runtime.CompilerServices
- {
- [EditorBrowsable(EditorBrowsableState.Never)]
- internal class IsExternalInit { }
- }
- ```
-
--------
+
+ To get a .NET Framework 4.8 working with this SDK add the following to your `.csproj` file:
+
+ ```xml
+
+ ...
+
+ win-x86;win-x64
+ win-x64
+
+
+
+ ...
+
+
+
+ ```
+
+ and create a `IsExternalInit.cs` file in your project with the following contents:
+
+ ```cs
+ using System.ComponentModel;
+
+ namespace System.Runtime.CompilerServices
+ {
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ internal class IsExternalInit { }
+ }
+ ```
+
+---
When running commands such as `dotnet run` or `dotnet test`, you may need to specify the target framework explicitly using the `--framework` flag.
## Application Frameworks
+
- MAUI - Cross-platform native apps for mobile and desktop
- Platforms: iOS, Android, Windows
- WPF - Windows desktop applications
@@ -94,7 +97,7 @@ This project uses [EditorConfig](https://editorconfig.org/) for formatting, whic
Download PowerSync extension
```bash
-dotnet run --project Tools/Setup
+dotnet run --project Tools/Setup
```
Install dependencies
@@ -114,11 +117,12 @@ dotnet test -v n --framework net8.0
Run a specific test
```bash
-dotnet test -v n --framework net8.0 --filter "test-file-pattern"
+dotnet test -v n --framework net8.0 --filter "test-file-pattern"
```
### Integration Tests
-Integration tests in `PowerSync.Common.IntegrationTests` are intended to run against the [self-host nodejs demo](https://github.com/powersync-ja/self-host-demo/tree/main/demos/nodejs).
+
+Integration tests in `PowerSync.Common.IntegrationTests` are intended to run against the [self-host nodejs demo](https://github.com/powersync-ja/self-host-demo/tree/main/demos/nodejs).
The integration tests are disabled by default, define the following environment variable when running the tests.
```bash
@@ -132,6 +136,7 @@ RUN_INTEGRATION_TESTS=true dotnet test -v n --framework net8.0 --filter "Categor
```
### Performance Tests
+
Performance tests in `PowerSync.Common.PerformanceTests` are disabled by default, define the following environment variable when running the tests.
```bash
@@ -145,6 +150,7 @@ RUN_PERFORMANCE_TESTS=true dotnet test -v n --framework net8.0 --filter "Categor
```
## Using the PowerSync.Common package in your project
+
```bash
dotnet add package PowerSync.Common
```
diff --git a/demos/MAUITodo/Platforms/MacCatalyst/AppDelegate.cs b/demos/MAUITodo/Platforms/MacCatalyst/AppDelegate.cs
index cd3713f..d199c98 100644
--- a/demos/MAUITodo/Platforms/MacCatalyst/AppDelegate.cs
+++ b/demos/MAUITodo/Platforms/MacCatalyst/AppDelegate.cs
@@ -5,5 +5,5 @@ namespace MAUITodo;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
- protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
diff --git a/demos/MAUITodo/Platforms/MacCatalyst/Program.cs b/demos/MAUITodo/Platforms/MacCatalyst/Program.cs
index 838545d..444319f 100644
--- a/demos/MAUITodo/Platforms/MacCatalyst/Program.cs
+++ b/demos/MAUITodo/Platforms/MacCatalyst/Program.cs
@@ -1,15 +1,16 @@
using ObjCRuntime;
+
using UIKit;
namespace MAUITodo;
public class Program
{
- // This is the main entry point of the application.
- static void Main(string[] args)
- {
- // if you want to use a different Application Delegate class from "AppDelegate"
- // you can specify it here.
- UIApplication.Main(args, null, typeof(AppDelegate));
- }
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
}
diff --git a/demos/UnoTodo/App.xaml b/demos/UnoTodo/App.xaml
new file mode 100644
index 0000000..b70b4c6
--- /dev/null
+++ b/demos/UnoTodo/App.xaml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/UnoTodo/App.xaml.cs b/demos/UnoTodo/App.xaml.cs
new file mode 100644
index 0000000..2c35640
--- /dev/null
+++ b/demos/UnoTodo/App.xaml.cs
@@ -0,0 +1,82 @@
+using System.Diagnostics.CodeAnalysis;
+
+using Uno.Resizetizer;
+
+namespace UnoTodo;
+
+public partial class App : Application
+{
+ ///
+ /// Initializes the singleton application object. This is the first line of authored code
+ /// executed, and as such is the logical equivalent of main() or WinMain().
+ ///
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected Window? MainWindow { get; private set; }
+ public IHost? Host { get; private set; }
+
+ // [SuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Uno.Extensions APIs are used in a way that is safe for trimming in this template context.")]
+ protected async override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ var builder = this.CreateBuilder(args)
+ // Add navigation support for toolkit controls such as TabBar and NavigationView
+ .UseToolkitNavigation()
+ .Configure(host => host
+#if DEBUG
+ // Switch to Development environment when running in DEBUG
+ .UseEnvironment(Environments.Development)
+#endif
+ .UseLogging(configure: (context, logBuilder) =>
+ // Configure log levels for different categories of logging
+ logBuilder
+ .SetMinimumLevel(
+ context.HostingEnvironment.IsDevelopment() ?
+ LogLevel.Information :
+ LogLevel.Warning)
+
+ // Default filters for core Uno Platform namespaces
+ .CoreLogLevel(LogLevel.Warning), enableUnoLogging: true)
+ .UseConfiguration(configure: builder => builder.EmbeddedSource())
+ .ConfigureServices((context, services) => services.AddSingleton())
+ .UseNavigation(ReactiveViewModelMappings.ViewModelMappings, RegisterRoutes)
+ );
+ MainWindow = builder.Window;
+
+#if DEBUG
+ MainWindow.UseStudio();
+#endif
+ MainWindow.SetWindowIcon();
+
+ Host = await builder.NavigateAsync();
+ }
+
+ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes)
+ {
+ views.Register(
+ new ViewMap(ViewModel: typeof(ShellModel)),
+ new ViewMap(),
+ new ViewMap(),
+ new ViewMap(),
+ new DataViewMap()
+ );
+
+ routes.Register(
+ new RouteMap("", View: views.FindByViewModel(),
+ Nested:
+ [
+ new ("Main", View: views.FindByViewModel(), IsDefault:true,
+ Nested:
+ [
+ new ("Lists", View: views.FindByViewModel(), IsDefault:true),
+ new ("SqlConsole", View: views.FindByViewModel()),
+ ]
+ ),
+ new ("Todo", View: views.FindByViewModel()),
+ ]
+ )
+ );
+ }
+}
diff --git a/demos/UnoTodo/Assets/Icons/icon.svg b/demos/UnoTodo/Assets/Icons/icon.svg
new file mode 100644
index 0000000..a15af53
--- /dev/null
+++ b/demos/UnoTodo/Assets/Icons/icon.svg
@@ -0,0 +1,42 @@
+
+
diff --git a/demos/UnoTodo/Assets/Icons/icon_foreground.svg b/demos/UnoTodo/Assets/Icons/icon_foreground.svg
new file mode 100644
index 0000000..8ffc41a
--- /dev/null
+++ b/demos/UnoTodo/Assets/Icons/icon_foreground.svg
@@ -0,0 +1,137 @@
+
+
diff --git a/demos/UnoTodo/Assets/SharedAssets.md b/demos/UnoTodo/Assets/SharedAssets.md
new file mode 100644
index 0000000..b1cc4e7
--- /dev/null
+++ b/demos/UnoTodo/Assets/SharedAssets.md
@@ -0,0 +1,32 @@
+# Shared Assets
+
+See documentation about assets here: https://github.com/unoplatform/uno/blob/master/doc/articles/features/working-with-assets.md
+
+## Here is a cheat sheet
+
+1. Add the image file to the `Assets` directory of a shared project.
+2. Set the build action to `Content`.
+3. (Recommended) Provide an asset for various scales/dpi
+
+### Examples
+
+```text
+\Assets\Images\logo.scale-100.png
+\Assets\Images\logo.scale-200.png
+\Assets\Images\logo.scale-400.png
+
+\Assets\Images\scale-100\logo.png
+\Assets\Images\scale-200\logo.png
+\Assets\Images\scale-400\logo.png
+```
+
+### Table of scales
+
+| Scale | WinUI | iOS | Android |
+|-------|:-----------:|:---------------:|:-------:|
+| `100` | scale-100 | @1x | mdpi |
+| `125` | scale-125 | N/A | N/A |
+| `150` | scale-150 | N/A | hdpi |
+| `200` | scale-200 | @2x | xhdpi |
+| `300` | scale-300 | @3x | xxhdpi |
+| `400` | scale-400 | N/A | xxxhdpi |
diff --git a/demos/UnoTodo/Assets/Splash/splash_screen.svg b/demos/UnoTodo/Assets/Splash/splash_screen.svg
new file mode 100644
index 0000000..8ffc41a
--- /dev/null
+++ b/demos/UnoTodo/Assets/Splash/splash_screen.svg
@@ -0,0 +1,137 @@
+
+
diff --git a/demos/UnoTodo/Attachments/NodeRemoteStorageAdapter.cs b/demos/UnoTodo/Attachments/NodeRemoteStorageAdapter.cs
new file mode 100644
index 0000000..c5ee6ce
--- /dev/null
+++ b/demos/UnoTodo/Attachments/NodeRemoteStorageAdapter.cs
@@ -0,0 +1,33 @@
+using PowerSync.Common.Attachments;
+
+namespace UnoTodo.Attachments;
+
+public class NodeRemoteStorageAdapter(HttpClient client, string backendUrl) : IRemoteStorageAdapter
+{
+ private readonly HttpClient _client = client;
+ private readonly string _backendUrl = backendUrl;
+
+ public async Task UploadFileAsync(Stream fileData, Attachment attachment)
+ {
+ var content = new StreamContent(fileData);
+ content.Headers.ContentType = new(attachment.MediaType ?? "application/octet-stream");
+ var response = await _client.PutAsync($"{_backendUrl}/api/attachments/{attachment.Id}", content);
+ response.EnsureSuccessStatusCode();
+ }
+
+ public async Task DownloadFileAsync(Attachment attachment)
+ {
+ var response = await _client.GetAsync($"{_backendUrl}/api/attachments/{attachment.Id}");
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ return Stream.Null;
+ response.EnsureSuccessStatusCode();
+ return await response.Content.ReadAsStreamAsync();
+ }
+
+ public async Task DeleteFileAsync(Attachment attachment)
+ {
+ var response = await _client.DeleteAsync($"{_backendUrl}/api/attachments/{attachment.Id}");
+ if (response.StatusCode != System.Net.HttpStatusCode.NotFound)
+ response.EnsureSuccessStatusCode();
+ }
+}
diff --git a/demos/UnoTodo/Data/AppPaths.cs b/demos/UnoTodo/Data/AppPaths.cs
new file mode 100644
index 0000000..52d1fcb
--- /dev/null
+++ b/demos/UnoTodo/Data/AppPaths.cs
@@ -0,0 +1,9 @@
+namespace UnoTodo.Data;
+
+public static class AppPaths
+{
+ // Deliberately not ApplicationData.Current, which requires packaged app identity that
+ // an unpackaged Uno Skia desktop app does not have.
+ public static string AppDataDir { get; } = Directory.CreateDirectory(
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "UnoTodo")).FullName;
+}
diff --git a/demos/UnoTodo/Data/AppSchema.cs b/demos/UnoTodo/Data/AppSchema.cs
new file mode 100644
index 0000000..918c63f
--- /dev/null
+++ b/demos/UnoTodo/Data/AppSchema.cs
@@ -0,0 +1,13 @@
+using PowerSync.Common.Attachments;
+using PowerSync.Common.DB.Schema;
+
+namespace UnoTodo.Data;
+
+class AppSchema
+{
+ public static Table Todos = new(typeof(TodoItem));
+ public static Table Lists = new(typeof(TodoList));
+ public static Table Attachments = new(typeof(Attachment));
+
+ public static Schema PowerSyncSchema = new(Todos, Lists, Attachments);
+}
diff --git a/demos/UnoTodo/Data/AppServices.cs b/demos/UnoTodo/Data/AppServices.cs
new file mode 100644
index 0000000..1efa03f
--- /dev/null
+++ b/demos/UnoTodo/Data/AppServices.cs
@@ -0,0 +1,7 @@
+namespace UnoTodo.Data;
+
+public static class AppServices
+{
+ public static PowerSyncData PowerSyncData =>
+ ((App)Application.Current).Host!.Services.GetRequiredService();
+}
diff --git a/demos/UnoTodo/Data/NodeConnector.cs b/demos/UnoTodo/Data/NodeConnector.cs
new file mode 100644
index 0000000..781beee
--- /dev/null
+++ b/demos/UnoTodo/Data/NodeConnector.cs
@@ -0,0 +1,118 @@
+using System.Text;
+using System.Text.Json;
+
+using PowerSync.Common.Client;
+using PowerSync.Common.Client.Connection;
+using PowerSync.Common.DB.Crud;
+
+namespace UnoTodo.Data;
+
+public class NodeConnector : IPowerSyncBackendConnector
+{
+ private static string StorageFilePath => Path.Combine(AppPaths.AppDataDir, "user_id.txt");
+
+ private readonly HttpClient _httpClient;
+
+ public string BackendUrl { get; }
+ public string PowerSyncUrl { get; }
+ public string UserId { get; private set; }
+
+ public NodeConnector()
+ {
+ _httpClient = new HttpClient();
+
+ // Load or generate User ID
+ UserId = LoadOrGenerateUserId();
+
+#if ANDROID
+ BackendUrl = "http://10.0.2.2:6060";
+ PowerSyncUrl = "http://10.0.2.2:8080";
+#else
+ BackendUrl = "http://localhost:6060";
+ PowerSyncUrl = "http://localhost:8080";
+#endif
+ }
+
+ public static string LoadOrGenerateUserId()
+ {
+ if (File.Exists(StorageFilePath))
+ {
+ return File.ReadAllText(StorageFilePath);
+ }
+
+ string newUserId = Guid.NewGuid().ToString();
+ File.WriteAllText(StorageFilePath, newUserId);
+ return newUserId;
+ }
+
+ public async Task FetchCredentials()
+ {
+ string tokenEndpoint = "api/auth/token";
+ string url = $"{BackendUrl}/{tokenEndpoint}?user_id={UserId}";
+
+ HttpResponseMessage response = await _httpClient.GetAsync(url);
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new Exception($"Received {response.StatusCode} from {tokenEndpoint}: {await response.Content.ReadAsStringAsync()}");
+ }
+
+ string responseBody = await response.Content.ReadAsStringAsync();
+ var jsonResponse = JsonSerializer.Deserialize>(responseBody);
+
+ return jsonResponse == null || !jsonResponse.TryGetValue("token", out string? token)
+ ? throw new Exception("Invalid response received from authentication endpoint.")
+ : new PowerSyncCredentials(PowerSyncUrl, token);
+ }
+
+ public async Task UploadData(IPowerSyncDatabase database)
+ {
+ CrudTransaction? transaction;
+ try
+ {
+ transaction = await database.GetNextCrudTransaction();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"UploadData Error: {ex.Message}");
+ return;
+ }
+
+ if (transaction == null)
+ {
+ return;
+ }
+
+ try
+ {
+ var batch = new List