diff --git a/.vscode/launch.json b/.vscode/launch.json index efdaa6ea..42be176a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,21 @@ // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Debug - Android", + "type": "mono", + "preLaunchTask": "run-debug-android", + "request": "attach", + "address": "localhost", + "port": 10000 + }, + { + "name": "Attach - Android", + "type": "mono", + "request": "attach", + "address": "localhost", + "port": 10000 + }, { "name": "API", "type": "dotnet", @@ -33,12 +48,10 @@ "name": "Test PostIt.Android launch (Xamarin.UITest)", "type": "coreclr", "request": "launch", - "program": "${workspaceFolder}/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll", + "program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll", "args": [ - "--filter-method", - "PostIt.Tests.AndroidAppLaunchTests.PostIt_starts_and_draws_a_first_frame_on_the_emulator" ], - "cwd": "${workspaceFolder}/src/PostIt.Tests", + "cwd": "${workspaceFolder}/src/PostIt/PostIt.Tests", "console": "integratedTerminal", "stopAtEntry": false } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e45a9921..c900fa6a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,29 @@ { "version": "2.0.0", "tasks": [ + { + "label": "run-debug-android", + "command": "dotnet", + "type": "shell", + "options": { + "cwd": "${workspaceFolder}/src/PostIt/PostIt.Android", + "env": { + "DOTNET_HOST_PATH": "/usr/share/dotnet", + "ANDROID_HOME": "/opt/android-sdk", + "JAVA_HOME": "/usr/lib/jvm/java-1.21.0-openjdk-amd64" + } + }, + "args": [ + "build" + "-t:run", + "-p:TargetFramework=net10.0-android", + "-p:Configuration=Debug", + "-p:AndroidAttachDebugger=true", + "-p:AndroidSdbHostPort=10000", + "-p:AndroidSdbTargetPort=10000" + ], + "problemMatcher": "$msCompile" + }, { "label": "build", "command": "dotnet", diff --git a/Makefile b/Makefile index 830c48f2..d6d69196 100644 --- a/Makefile +++ b/Makefile @@ -161,13 +161,14 @@ ADB_SERIAL ?= emulator-5554 ANDROID_HOME ?= /opt/android-sdk POSTIT_RID ?= android-x64 EMU_HEADLESS ?= 0 -LOGCAT_LINES ?= 200 +LOGCAT_LINES ?= 600 LOGCAT_FOLLOW ?= 0 -LOGCAT_BOOT_WAIT ?= 30 +LOGCAT_BOOT_WAIT ?= 20 +ANDROID_PACKAGE_NAME = fr.pschneider.PostIt POSTIT_ANDROID_CSPROJ := src/PostIt/PostIt.Android/PostIt.Android.csproj POSTIT_APK_DIR := src/PostIt/PostIt.Android/bin/$(CONFIG)/net10.0-android/$(POSTIT_RID) -POSTIT_APK := $(POSTIT_APK_DIR)/fr.pschneider.PostIt-Signed.apk +POSTIT_APK := $(POSTIT_APK_DIR)/$(ANDROID_PACKAGE_NAME)-Signed.apk qemu-run: @echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..." @@ -214,24 +215,25 @@ qemu-build: -p:RuntimeIdentifier=$(POSTIT_RID) \ -p:EmbedAssembliesIntoApk=true \ --nologo - -qemu-install: qemu-build @if [ ! -f "$(POSTIT_APK)" ]; then \ echo " APK not found at $(POSTIT_APK)." >&2; \ echo " Files in $(POSTIT_APK_DIR):" >&2; \ ls -la "$(POSTIT_APK_DIR)" 2>/dev/null || echo " (directory does not exist)" >&2; \ exit 1; \ fi + + +qemu-install: qemu-build @echo " Installing $(POSTIT_APK) on $(ADB_SERIAL)..." - adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" + adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" -r qemu-uninstall: - adb -s $(ADB_SERIAL) uninstall fr.pschneider.PostIt + adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME) # Dump recent logcat output for the running PostIt.Android process. # By default, prints the last $(LOGCAT_LINES) lines (one-shot, with # `-d`). Set LOGCAT_FOLLOW=1 to follow the stream live instead. -# Filtering is by PID (pidof fr.pschneider.PostIt), not by tag, +# Filtering is by PID (pidof $(ANDROID_PACKAGE_NAME)), not by tag, # because Mono/Xamarin can emit logs under several tags # (mono, PostIt.Android, Avalonia.Android) and tag-based filtering # would miss the ones not matching. PID-based filtering is exact. @@ -239,17 +241,17 @@ qemu-uninstall: # silently with no output; that is the expected behaviour for # "no logs yet". qemu-logcat: - @PID=$$(adb -s $(ADB_SERIAL) shell pidof fr.pschneider.PostIt 2>/dev/null | tr -d '\r\n'); \ + @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ if [ -z "$$PID" ]; then \ - echo " fr.pschneider.PostIt is not running on $(ADB_SERIAL)."; \ - echo " Start the app first (am start -n fr.pschneider.PostIt/PostIt.Android.PostItMainActivity)"; \ + echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \ + echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \ exit 1; \ fi; \ echo " Following PID $$PID (LOGCAT_FOLLOW=$(LOGCAT_FOLLOW), LOGCAT_LINES=$(LOGCAT_LINES))"; \ if [ "$(LOGCAT_FOLLOW)" = "1" ]; then \ - adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID fr.pschneider.PostIt:F; \ + adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID $(ANDROID_PACKAGE_NAME); \ else \ - adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID fr.pschneider.PostIt:F; \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \ fi # Clear logcat, launch PostIt.Android, then dump everything that was @@ -263,19 +265,21 @@ LOGCAT_BOOT_WAIT ?= 15 qemu-logcat-boot: @echo " Clearing logcat buffer..." adb -s $(ADB_SERIAL) logcat -c - @echo " Launching fr.pschneider.PostIt..." + @echo " Launching $(ANDROID_PACKAGE_NAME)..." adb -s $(ADB_SERIAL) shell am start \ - -n fr.pschneider.PostIt/PostIt.Android.PostItMainActivity + -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity @echo " Waiting $(LOGCAT_BOOT_WAIT)s for the app to start rendering..." @sleep $(LOGCAT_BOOT_WAIT) + @echo " Dumping logcat (PostIt PID + system buffer):" - @PID=$$(adb -s $(ADB_SERIAL) shell pidof fr.pschneider.PostIt 2>/dev/null | tr -d '\r\n'); \ + @PID=$$(adb -s $(ADB_SERIAL) shell pidof $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ if [ -n "$$PID" ]; then \ - echo " (PID $$PID at dump time)"; \ + echo " ✅ (PID $$PID at dump time)"; \ adb -s $(ADB_SERIAL) logcat -d -v time --pid=$$PID; \ else \ - echo " (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \ + echo " 👿 (PostIt process not running at dump time — dumping last $(LOGCAT_LINES) lines unfiltered)"; \ adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES); \ + exit 1; \ fi qemu: qemu-run qemu-wait-boot qemu-install diff --git a/external/dotnet-android-build-image b/external/dotnet-android-build-image deleted file mode 160000 index 0695a6c1..00000000 --- a/external/dotnet-android-build-image +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0695a6c1fea6508f1a88f7ad0ad9cb93733aa52d diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 59b6b600..69e8a896 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -1,21 +1,37 @@ - - + - - - - - - - - - - - - - - - - + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PostIt/PostIt.Android/Application.cs b/src/PostIt/PostIt.Android/Application.cs index fb6b08d3..f5a7908d 100644 --- a/src/PostIt/PostIt.Android/Application.cs +++ b/src/PostIt/PostIt.Android/Application.cs @@ -2,6 +2,12 @@ using Android.Runtime; using Avalonia; using Avalonia.Android; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Avalonia.Controls; +using Avalonia.Styling; +using Yavsc.Api.Client; namespace PostIt.Android { diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index 1b413a9f..62c29e10 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -1,8 +1,8 @@ -using Android.App; -using Android.Content.PM; +using Android.App; using Android.Content; +using Android.Content.PM; +using Avalonia; using Avalonia.Android; -using AndroidX.Emoji2.Text; namespace PostIt.Android; @@ -12,11 +12,10 @@ namespace PostIt.Android; Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, - LaunchMode = LaunchMode.SingleTask, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] public class MainActivity : AvaloniaMainActivity { - /// + /// /// Strongly-typed handle to the current MainActivity instance, set in /// and consumed by platform services such as /// which need to launch @@ -26,13 +25,11 @@ public class MainActivity : AvaloniaMainActivity protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState) { - EmojiCompat.Init(this); base.OnCreate(savedInstanceState); PlatformBootstrap.EnsureInitialized(); Current = this; } - - /// + /// /// Receives the deep-link Intent fired by the system browser after the /// user completes the OIDC login on https://yavsc.pschneider.fr. The /// Intent URI has the shape android://postit-signin?code=...&state=.... diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 20a27816..a4af1eb2 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -2,35 +2,32 @@ Exe net10.0-android - - android-arm64;android-x64 - 23.0.0 + 23 enable fr.pschneider.PostIt 1 1.0 apk - false - android-arm;android-arm64;android-x86;android-x64 - 1.1.0.0 - 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 - 1.1.0-beta.1 + false + SdkOnly + partial + Resources\drawable\Icon.png + + + + - - - diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 2472d06d..91b61d05 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -1,7 +1,7 @@ - + - + - - - - - - - - \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs index a84b85a3..3e7344a2 100644 --- a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs @@ -72,7 +72,7 @@ public class MainPageButtonsTests { } } - private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null) + private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null) { var api = new ThrowingApi(); var blog = new BlogApiClient(api, "http://localhost/"); @@ -93,7 +93,7 @@ public class MainPageButtonsTests services.AddTransient(); services.AddTransient(); services.AddTransient(); - var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider()); + var vm = new MainViewModel(blog, services: services.BuildServiceProvider()); if (selectedPost is not null) vm.SelectedPost = selectedPost; return vm; } @@ -109,7 +109,7 @@ public class MainPageButtonsTests /// realised and KeyPressQwerty has a real /// to dispatch against. /// - private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm) + private static (MainWindow window, MainPage page) MountMainPage(MainViewModel vm) { var window = new MainWindow(); var page = new MainPage { DataContext = vm }; diff --git a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs index 1eaf3f17..5baae658 100644 --- a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs @@ -40,7 +40,7 @@ public class MainPageSaveTests var recorder = new CallRecorder(); var api = new RecordingYavscApiClient(recorder); var blog = new BlogApiClient(api, "http://localhost/"); - var viewModel = new MainPageViewModel(blog); + var viewModel = new MainViewModel(blog); var page = new MainPage { DataContext = viewModel }; // MainPage is a ContentPage (a Page, not a Control), so it diff --git a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs index 2708ba8c..9bc28888 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Avalonia; using Avalonia.Headless.XUnit; using Microsoft.Extensions.DependencyInjection; +using PostIt.Helpers; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index d8517ba0..433f36c3 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -16,7 +16,9 @@ + + @@ -27,6 +29,5 @@ - diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index 780f674c..d2c5d78e 100644 --- a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs @@ -16,7 +16,7 @@ public class PostItViewModelTests // throws on any call (we never call the API in this test). var fakeApi = new ThrowingYavscApiClient(); var blog = new BlogApiClient(fakeApi, "http://localhost/"); - var viewModel = new MainPageViewModel(blog); + var viewModel = new MainViewModel(blog); viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); diff --git a/src/PostIt/PostIt/App.axaml b/src/PostIt/PostIt/App.axaml index 92497b74..362f398e 100644 --- a/src/PostIt/PostIt/App.axaml +++ b/src/PostIt/PostIt/App.axaml @@ -1,13 +1,16 @@ + x:Class="PostIt.App" + RequestedThemeVariant="Default"> + - + + + - + - - + diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index d2399873..10a0c8a5 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -1,16 +1,16 @@ using System; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Styling; +using Microsoft.Extensions.DependencyInjection; using PostIt.Services; -using Yavsc.Api.Client; using PostIt.ViewModels; using PostIt.Views; +using PostIt.Helpers; namespace PostIt; @@ -27,198 +27,70 @@ public partial class App : Application /// DataValidationErrors.SetErrors. /// public IServiceProvider? ServiceProvider { get; private set; } - private MainWindow window; - public App() - { - } + + public MainWindow? Window { get; private set; } public override void Initialize() { AvaloniaXamlLoader.Load(this); +#if DEBUG + this.AttachDeveloperTools(); +#endif } public override void OnFrameworkInitializationCompleted() { - // Belt-and-braces 2nd-instance guard. The primary check now - // lives in PostIt.Desktop.Program.Main and exits before - // Avalonia boots — preventing a flash of the MainWindow on - // every postit://callback launch. This block is kept for any - // entry point that bypasses Program.Main (PostIt.Browser, - // PostIt.Android's process lifecycle, ad-hoc tests that build - // App directly) and as defence-in-depth in case the Desktop - // build is ever reconfigured to skip the early check. if (TryHandOffCustomSchemeUrl()) return; - this.ServiceProvider = BuildServices(new ServiceCollection()); - AttachServiceProvider(ServiceProvider); + this.ServiceProvider = new ServiceCollection().BuildServices(); var settings = ServiceProvider.GetRequiredService(); - var sessionStatus = ServiceProvider.GetRequiredService(); - var api = ServiceProvider.GetRequiredService(); - - DataTemplates.Clear(); - DataTemplates.Add(new ViewLocator(ServiceProvider)); - - // Wire the Settings singleton onto the SettingsPage singleton - // once, at composition time. The page is registered as a - // singleton (see above) precisely so this binding is stable - // for the lifetime of the app: every push to / pop from the - // navigation stack finds the same ContentPage with the same - // DataContext, and the TwoWay bindings inside the page keep - // mutating the same in-memory Settings instance that the rest - // of the app reads (OidcClientOptions construction, etc.). - ServiceProvider.GetRequiredService().DataContext = settings; - - // Settings.DarkMode was previously a dead field: it round- - // tripped through the settings file and the SettingsPage - // CheckBox, but no consumer ever read it. Wire it here to - // Application.RequestedThemeVariant so the toggle takes - // effect immediately, and seed the initial theme from the - // value Load() just populated (so a dark-mode user lands on - // a dark window on first launch, not on a default-light - // window that flips after the user touches the toggle). - ApplyDarkMode(settings); - settings.PropertyChanged += (_, e) => - { - if (e.PropertyName == nameof(Settings.DarkMode)) - { - ApplyDarkMode(settings); - } - }; if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - var homeVm = ServiceProvider.GetRequiredService(); - - window = new MainWindow(); - window.SessionBanner.DataContext = sessionStatus; - - // Build the navigation stack from scratch: HomePage is the - // root in both cases. App.BootAsync will push MainPage on - // top if the silent refresh succeeds. - desktop.MainWindow = window; - _ = PushPageAsync(homeVm); - - // When the user logs out, route back to HomePage. We - // ReplaceAsync the current top so we don't grow the stack - // on every logout — otherwise repeated login/logout would - // eventually balloon the back history. - sessionStatus.LogoutCompleted += () => - { - var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; - var nav = w.NavRoot; - _ = nav.PopToRootAsync(); - }; - - // When the user signs in interactively (Login button on - // the session banner), push MainPage on top of HomePage. - sessionStatus.LoginSucceeded += () => - { - var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; - _ = PushMainPageAsync(); - }; - - window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api); + desktop.MainWindow = CreateMainWindow(); + ApplyDarkMode(settings); } - else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) + else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) { - singleView.MainView = new MainWindow - { - DataContext = ServiceProvider.GetRequiredService() - }; + singleViewFactoryApplicationLifetime.MainViewFactory = + () => + { + Window = CreateMainWindow(); + ApplyDarkMode(settings); + return Window; + }; } + else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) + { + singleViewPlatform.MainView = CreateMainWindow(); + ApplyDarkMode(settings); + } + base.OnFrameworkInitializationCompleted(); } - /// - /// Build the DI container the app uses. Pulled out of - /// so headless - /// tests can construct the same container at TestApp boot - /// without going through the full Avalonia desktop lifetime - /// (which never runs in a unit test). The container returned is - /// the exact one production uses — no test-only fakes, no - /// trimmed service list — so a test that exercises a VM, page, - /// or service resolves through the same wiring the real app - /// does, and a green test is a green contract for prod. - /// - internal static IServiceProvider BuildServices(ServiceCollection services) + + + private MainWindow CreateMainWindow() { - var settings = new Settings(); - settings.Load(); + Window = new MainWindow(); + var api = ServiceProvider!.GetRequiredService(); + Window.Opened += async (_, _) => await BootAsync(this.ServiceProvider!, api); + var sessionStatus = ServiceProvider!.GetRequiredService(); + sessionStatus.LogoutCompleted += () => + { + Window.NavRoot.PopToRootAsync(); + }; - var tokenStore = new TokenStore(System.IO.Path.Combine( - System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), - "PostIt", "tokens.json")); + sessionStatus.LoginSucceeded += () => + { + PushMainPageAsync().Wait(); + }; - var api = new YavscApiClient(settings, tokenStore); - var client = new BlogApiClient(api, settings.BlogsApiUrl); - var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); - var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); - var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); - var contactService = new ContactService(); - var userDirectory = new UserDirectory(userSearchClient); + var homeVm = ServiceProvider!.GetRequiredService(); - - // Vues - services.AddTransient(); - // SettingsPage is a singleton: there must be one and only one - // instance of the settings UI for the lifetime of the app. - // This guarantees that (a) the bindings always reflect the - // current in-memory Settings state, (b) the page already has - // its DataContext wired up at composition-root time (see - // below), and (c) PushPageAsync's anti-empilement guard sees - // the same instance across pushes, so a second Settings tap - // is a no-op rather than re-pushing the page. Transient would - // let the user accumulate stale SettingsPage instances on - // the navigation stack, each bound to a fresh - // SettingsViewModel and missing any in-flight edits. - services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - // Dialogs (modal-light pages): the ViewLocator resolves - // them when a caller pushes a PostAclDialogViewModel or - // AddCircleMemberDialogViewModel via App.PushPageAsync. - // App.PushPageAsync overwrites the page's DataContext with - // the caller-built VM, so the parameterless ctor is enough - // here — the parametrised ctors stay for direct test wiring. - services.AddTransient(); - services.AddTransient(); - // ViewModels - services.AddSingleton(settings); - services.AddSingleton(api); - services.AddSingleton(api); - services.AddSingleton(client); - services.AddSingleton(circleClient); - services.AddSingleton(blogAclClient); - services.AddSingleton(userSearchClient); - services.AddSingleton(contactService); - services.AddSingleton(userDirectory); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // Persistent session banner: one instance for the lifetime of - // the app so the same VM survives page navigation. - var sessionStatus = new SessionStatusViewModel { Api = api }; - sessionStatus.Refresh(); - services.AddSingleton(sessionStatus); - services.AddTransient(); - - return services.BuildServiceProvider(); - } - - /// - /// Attach a pre-built DI container to this - /// instance. Used by headless tests after - /// ; in production this happens - /// implicitly via . - /// Idempotent w.r.t. : - /// re-binding from a second App boot is a no-op. - /// - internal void AttachServiceProvider(IServiceProvider sp) - { - ServiceProvider = sp; - Settings.BindToServiceProvider(sp); + this.PushPageAsync(homeVm).Wait(); + Window.SessionBanner.DataContext = sessionStatus; + return Window; } /// @@ -229,7 +101,7 @@ public partial class App : Application /// internal void AttachMainWindow(MainWindow mainWindow) { - window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow)); + Window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow)); } private static void ApplyDarkMode(Settings settings) @@ -266,11 +138,11 @@ public partial class App : Application /// (interactive login from the banner). Pulled out as a helper so /// the two callers can't drift apart. /// - public static Task PushMainPageAsync() + public static async Task PushMainPageAsync() { - var app = (App)Current; - var mainVm = app.ServiceProvider.GetRequiredService(); - return app.PushPageAsync(mainVm); + var app = (App)Current!; + var mainVm = app.ServiceProvider!.GetRequiredService(); + await app.PushPageAsync(mainVm); } private bool TryHandOffCustomSchemeUrl() @@ -305,53 +177,8 @@ public partial class App : Application return true; } - internal void PushPage(ViewModelBase vm) - { - _ = PushPageAsync(vm); - } - - internal Task PushPageAsync(ViewModelBase vm) - { - if (window is null) - { - throw new InvalidOperationException("MainWindow is not initialized yet."); - } - - var template = DataTemplates.FirstOrDefault(t => t.Match(vm)); - if (template is null) - { - throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}."); - } - - var view = template.Build(vm); - if (view is null) - { - throw new InvalidOperationException( - $"Template for {vm.GetType().Name} returned ."); - } - - var page = view as Page; - if (page is null) - { - // NavigationPage expects Page instances. Wrap any fallback control - // (e.g. ViewLocator error TextBlock) into a ContentPage so it can render. - page = new ContentPage { Content = view }; - } - - page.DataContext = vm; - - // Avoid stacking the same singleton page twice (e.g. SettingsPage). - var stack = window.NavRoot.NavigationStack; - if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) - { - return Task.CompletedTask; - } - - return window.NavRoot.PushAsync(page); - } - internal async Task GoBackAsync() { - await window.NavRoot.PopAsync(); + await Window!.NavRoot.PopAsync(); } } diff --git a/src/PostIt/PostIt/Assets/avalonia-logo.ico b/src/PostIt/PostIt/Assets/avalonia-logo.ico new file mode 100644 index 00000000..f7da8bb5 Binary files /dev/null and b/src/PostIt/PostIt/Assets/avalonia-logo.ico differ diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs new file mode 100644 index 00000000..9e0382de --- /dev/null +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; +using Yavsc.Api.Client; + +namespace PostIt.Helpers; + +public static class ServiceCollectionHelpers +{ + public static IServiceProvider BuildServices(this ServiceCollection services) + { + var settings = new Settings(); + settings.Load(); + + var tokenStore = new TokenStore(System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), + "PostIt", "tokens.json")); + + var api = new YavscApiClient(settings, tokenStore); + var client = new BlogApiClient(api, settings.BlogsApiUrl); + var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); + var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); + var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); + var contactService = new ContactService(); + var userDirectory = new UserDirectory(userSearchClient); + + // Vues + services.AddTransient(); + // SettingsPage is a singleton: there must be one and only one + // instance of the settings UI for the lifetime of the app. + // This guarantees that (a) the bindings always reflect the + // current in-memory Settings state, (b) the page already has + // its DataContext wired up at composition-root time (see + // below), and (c) PushPageAsync's anti-empilement guard sees + // the same instance across pushes, so a second Settings tap + // is a no-op rather than re-pushing the page. Transient would + // let the user accumulate stale SettingsPage instances on + // the navigation stack, each bound to a fresh + // SettingsViewModel and missing any in-flight edits. + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + // Dialogs (modal-light pages): the ViewLocator resolves + // them when a caller pushes a PostAclDialogViewModel or + // AddCircleMemberDialogViewModel via App.PushPageAsync. + // App.PushPageAsync overwrites the page's DataContext with + // the caller-built VM, so the parameterless ctor is enough + // here — the parametrised ctors stay for direct test wiring. + services.AddTransient(); + services.AddTransient(); + // ViewModels + services.AddSingleton(settings); + services.AddSingleton(api); + services.AddSingleton(client); + services.AddSingleton(circleClient); + services.AddSingleton(blogAclClient); + services.AddSingleton(userSearchClient); + services.AddSingleton(contactService); + services.AddSingleton(userDirectory); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Persistent session banner: one instance for the lifetime of + // the app so the same VM survives page navigation. + var sessionStatus = new SessionStatusViewModel { Api = api }; + sessionStatus.Refresh(); + services.AddSingleton(sessionStatus); + services.AddTransient(); + + return services.BuildServiceProvider(); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs b/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs new file mode 100644 index 00000000..7fc81b3a --- /dev/null +++ b/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Controls; +using PostIt.ViewModels; + +namespace PostIt.Helpers; + +public static class ViewModelBaseHelpers +{ + public static async Task PushPageAsync(this App app, ViewModelBase vm) + { + if (app.Window is null) + { + throw new InvalidOperationException("MainWindow is not initialized yet."); + } + + var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm)); + if (template is null) + { + throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}."); + } + + var view = template.Build(vm); + if (view is null) + { + throw new InvalidOperationException( + $"Template for {vm.GetType().Name} returned ."); + } + + var page = view as Page; + if (page is null) + { + // NavigationPage expects Page instances. Wrap any fallback control + // (e.g. ViewLocator error TextBlock) into a ContentPage so it can render. + page = new ContentPage { Content = view }; + } + + page.DataContext = vm; + + // Avoid stacking the same singleton page twice (e.g. SettingsPage). + var stack = app.Window.NavRoot.NavigationStack; + if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) + { + return; + } + + await app.Window.NavRoot.PushAsync(page); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 1b48c21f..70bd72ca 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -1,32 +1,14 @@ - + net10.0 enable latest - true - 1.1.0.0 - 1.1.0.0 - 1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3 - 1.1.0-beta.1 + - - - - - - - None - All - - - - - - - + PreserveNewest @@ -38,6 +20,21 @@ - + + + + + None + All + + + + + + + + + + diff --git a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs index ad71063b..99358cfb 100644 --- a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs +++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs @@ -21,6 +21,9 @@ public partial class AuthenticationSettings : ObservableObject public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr"; public static string DefaultClientId { get; internal set; } = "postit"; + + public static string[] DefaultScopes { get; set; } = { "blogs"} ; + [ObservableProperty] public partial string Authority { get; set; } diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index fd92c802..03ab98a1 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using Avalonia.Controls; using Avalonia.Controls.Templates; using Microsoft.Extensions.DependencyInjection; @@ -10,17 +11,19 @@ namespace PostIt; /// /// Given a view model, returns the corresponding view if possible. /// - +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] public class ViewLocator : IDataTemplate { - private readonly IServiceProvider _services; + private readonly IServiceProvider _services; public ViewLocator(IServiceProvider services) { _services = services; } - public Control Build(object? data) + public Control Build(object? data) { try { @@ -32,11 +35,12 @@ public class ViewLocator : IDataTemplate } } + private Control BuildCore(object? data) { return data switch { - MainPageViewModel => _services.GetRequiredService(), + MainViewModel => _services.GetRequiredService(), Settings => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), SignaturePageViewModel => _services.GetRequiredService(), @@ -48,5 +52,5 @@ public class ViewLocator : IDataTemplate }; } - public bool Match(object? data) => data is ViewModelBase; + public bool Match(object? data) => data is ViewModelBase; } diff --git a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs index 33a5bd30..9cee3ea8 100644 --- a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs @@ -5,6 +5,7 @@ using Avalonia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; +using PostIt.Helpers; using PostIt.Services; using Yavsc.Api.Client; using Yavsc.Api.Client.Dtos; diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs similarity index 96% rename from src/PostIt/PostIt/ViewModels/MainPageViewModel.cs rename to src/PostIt/PostIt/ViewModels/MainViewModel.cs index d374c9e4..1aa3eee0 100644 --- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -8,10 +8,11 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using Yavsc.Blogspot; using Yavsc.Api.Client; +using PostIt.Helpers; namespace PostIt.ViewModels; -public partial class MainPageViewModel : ViewModelBase +public partial class MainViewModel : ViewModelBase { /// Window/tab title. Cosmetic — bound by /// MainPage.axaml if at all. Not the post title. @@ -120,10 +121,10 @@ public partial class MainPageViewModel : ViewModelBase } - public MainPageViewModel() + public MainViewModel() { - Init(null); SettingsModel = new Settings(); + Init(SettingsModel); BlogClient = null; } @@ -135,6 +136,11 @@ public partial class MainPageViewModel : ViewModelBase SelectedPost = null; IsBusy = false; StatusMessage = "Ready"; + Settings = settings ?? new Settings(); + WindowTitle = "PostIt"; + DraftTitle = string.Empty; + DraftArticle = string.Empty; + DraftIsPublished = false; // Production path: DI injects the canonical Settings singleton // and we use it as-is. Test path: tests call this constructor // without a Settings argument; we fall back to a fresh @@ -145,11 +151,7 @@ public partial class MainPageViewModel : ViewModelBase // sink; that crash is fixed in Settings.OnPropertyChanged // (thread-safe dispatcher marshalling) so the duplicate // instance is now merely wasteful, not dangerous. - Settings = settings ?? new Settings(); - WindowTitle = "PostIt"; - DraftTitle = string.Empty; - DraftArticle = string.Empty; - DraftIsPublished = false; + } /// Save is enabled as soon as the user has typed @@ -169,12 +171,11 @@ public partial class MainPageViewModel : ViewModelBase /// . Production code uses the /// (Settings, BlogApiClient) overload below. /// - public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) + public MainViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) { SettingsModel = new Settings(); BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; Services = services; - Init(settings); } @@ -210,7 +211,7 @@ public partial class MainPageViewModel : ViewModelBase { await ExecuteAsync(async () => { - var posts = await BlogClient.GetPostsAsync(); + var posts = await BlogClient!.GetPostsAsync(); Posts.Clear(); foreach (var post in posts.OrderByDescending(p => p.DateModified)) { @@ -259,7 +260,7 @@ public partial class MainPageViewModel : ViewModelBase DateCreated = DateTime.UtcNow, DateModified = DateTime.UtcNow, }; - var created = await BlogClient.CreatePostAsync(draft); + var created = await BlogClient!.CreatePostAsync(draft); if (created is not null) { SelectedPost = created; @@ -278,7 +279,7 @@ public partial class MainPageViewModel : ViewModelBase DateCreated = SelectedPost.DateCreated, DateModified = DateTime.UtcNow, }; - await BlogClient.UpdatePostAsync(SelectedPost.Id, update); + await BlogClient!.UpdatePostAsync(SelectedPost.Id, update); StatusMessage = $"Saved post {SelectedPost.Id}."; } @@ -297,7 +298,7 @@ public partial class MainPageViewModel : ViewModelBase await ExecuteAsync(async () => { - await BlogClient.DeletePostAsync(SelectedPost.Id); + await BlogClient!.DeletePostAsync(SelectedPost.Id); StatusMessage = $"Deleted post {SelectedPost.Id}."; SelectedPost = null; await RefreshPostsAsync(); @@ -331,7 +332,7 @@ public partial class MainPageViewModel : ViewModelBase await ExecuteAsync(async () => { var desired = !DraftIsPublished; - await BlogClient.SetPublishAsync(SelectedPost.Id, desired); + await BlogClient!.SetPublishAsync(SelectedPost.Id, desired); DraftIsPublished = desired; // Mirror into the selected post so a subsequent // RefreshPostsAsync() doesn't blow away the @@ -372,7 +373,7 @@ public partial class MainPageViewModel : ViewModelBase private async Task RefreshPostsAsync() { - var posts = await BlogClient.GetPostsAsync(); + var posts = await BlogClient!.GetPostsAsync(); Posts.Clear(); foreach (var post in posts.OrderByDescending(p => p.DateModified)) { diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs index a1ad48ce..f2496b85 100644 --- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; +using PostIt.Helpers; using PostIt.Services; namespace PostIt.ViewModels; @@ -139,7 +140,7 @@ public partial class SessionStatusViewModel : ViewModelBase internal async Task OpenSettings() { var app = (App)App.Current!; - await app.PushPageAsync(app.ServiceProvider.GetRequiredService()).ConfigureAwait(true); + await app.PushPageAsync(app.ServiceProvider!.GetRequiredService()).ConfigureAwait(true); } } diff --git a/src/PostIt/PostIt/ViewModels/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs index 890ca15c..2115a0cd 100644 --- a/src/PostIt/PostIt/ViewModels/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -47,40 +47,7 @@ public partial class Settings : ViewModelBase /// private static Settings? s_current; - /// - /// Wire the canonical Settings instance to a DI container. Called - /// exactly once from App.axaml.cs after the singleton has - /// been registered. Subsequent calls are no-ops: the DI container - /// owns the instance lifetime and we don't want a stray - /// BindToServiceProvider in a test fixture to silently - /// rebind the production instance. - /// - public static void BindToServiceProvider(IServiceProvider services) - { - if (services is null) throw new ArgumentNullException(nameof(services)); - Interlocked.CompareExchange(ref s_current, - services.GetService() ?? throw new InvalidOperationException( - "Settings is not registered in the DI container."), - null); - } - - /// - /// Returns the canonical Settings instance previously bound through - /// , or null when called - /// outside a running Avalonia application (tests, CLI tools). - /// - public static Settings? GetCurrent() => Volatile.Read(ref s_current); - - /// - /// Resolve the canonical Settings instance or throw. Use this in - /// production code paths that must not silently fall back to a - /// freshly-constructed (which used to be - /// the root cause of the postit://callback crash: two Settings - /// instances racing on PropertyChanged from different threads). - /// - public static Settings RequireCurrent() => - GetCurrent() ?? throw new InvalidOperationException( - "Settings.Current is not bound. Call App.OnFrameworkInitializationCompleted first."); + [ObservableProperty] public partial AuthenticationSettings Authentication { get; set; } = new(); @@ -99,7 +66,7 @@ public partial class Settings : ViewModelBase /// setters above all funnel through here, and we flip /// in lock-step. Sub-property mutations /// (e.g. Authentication.Authority) are caught by the - /// subscription wired up in + /// subscription wired up in /// below. disables the flag during bulk /// hydration so the disk load itself does not count as a user /// edit. @@ -350,15 +317,14 @@ public partial class Settings : ViewModelBase var settings = JsonSerializer.Deserialize(json); if (settings is null) { - Console.Error.WriteLine($"🩎 Settings payload is invalid (source: {source})."); - return; + UseDefaultSettings(); } // Apply under the gate so concurrent Load() callers cannot // see half the new values / half the old ones. The actual // PropertyChanged fan-out is handled by [ObservableProperty]'s // setters which we route through SetProperty → OnPropertyChanged // → our overridden dispatcher-safe marshaller below. - lock (_mutationGate) + else lock (_mutationGate) { this.Authentication = settings.Authentication; this.DarkMode = settings.DarkMode; @@ -371,6 +337,11 @@ public partial class Settings : ViewModelBase AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId; this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ? AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri; + if (settings.Authentication.Scopes is null || settings.Authentication.Scopes.Length == 0) + { + settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes; + } + else this.Authentication.Scopes = settings.Authentication.Scopes; } } @@ -400,6 +371,18 @@ public partial class Settings : ViewModelBase } } + private void UseDefaultSettings() + { + this.Authentication = new AuthenticationSettings + { + Authority = AuthenticationSettings.DefaultAuthority, + ClientId = AuthenticationSettings.DefaultClientId, + RedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri, + Scopes = AuthenticationSettings.DefaultScopes + }; + this.DarkMode = false; + } + /// /// Persist the current in-memory state to /// ~/.config/PostIt/postit-settings.json (Linux) / diff --git a/src/PostIt/PostIt/ViewModels/ViewModelBase.cs b/src/PostIt/PostIt/ViewModels/ViewModelBase.cs index 5ce17aba..4ca69eea 100644 --- a/src/PostIt/PostIt/ViewModels/ViewModelBase.cs +++ b/src/PostIt/PostIt/ViewModels/ViewModelBase.cs @@ -4,8 +4,7 @@ namespace PostIt.ViewModels; public abstract partial class ViewModelBase : ObservableObject { - - /// + /// /// Gets if the user can navigate to the next page /// public abstract bool CanNavigateNext { get; protected set; } diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml index 7eac39ff..7f1f1196 100644 --- a/src/PostIt/PostIt/Views/MainPage.axaml +++ b/src/PostIt/PostIt/Views/MainPage.axaml @@ -8,11 +8,11 @@ xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" mc:Ignorable="d" x:Class="PostIt.Views.MainPage" - x:DataType="vm:MainPageViewModel" + x:DataType="vm:MainViewModel" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> - + + Icon="/Assets/avalonia-logo.ico" + Title="PostIt" > - - + - diff --git a/src/Yavsc.Abstract/Yavsc.Abstract.csproj b/src/Yavsc.Abstract/Yavsc.Abstract.csproj index 7a4f83ae..f76e42fd 100644 --- a/src/Yavsc.Abstract/Yavsc.Abstract.csproj +++ b/src/Yavsc.Abstract/Yavsc.Abstract.csproj @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj index ade7ca17..00e6e2db 100644 --- a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj +++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj @@ -26,4 +26,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj index 5672ab7a..d9a814f6 100644 --- a/src/Yavsc.Api/Yavsc.Api.csproj +++ b/src/Yavsc.Api/Yavsc.Api.csproj @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj index fc7883ce..83c0dc37 100644 --- a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj +++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj @@ -35,4 +35,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Blogs/Yavsc.Blogs.csproj b/src/Yavsc.Blogs/Yavsc.Blogs.csproj index 5c175cb5..63b3d977 100644 --- a/src/Yavsc.Blogs/Yavsc.Blogs.csproj +++ b/src/Yavsc.Blogs/Yavsc.Blogs.csproj @@ -18,4 +18,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj index 88842cb4..d332d250 100644 --- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj +++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj @@ -89,4 +89,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Org/Yavsc.Org.csproj b/src/Yavsc.Org/Yavsc.Org.csproj index ccdb7f24..0403c7eb 100644 --- a/src/Yavsc.Org/Yavsc.Org.csproj +++ b/src/Yavsc.Org/Yavsc.Org.csproj @@ -54,4 +54,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Server/Yavsc.Server.csproj b/src/Yavsc.Server/Yavsc.Server.csproj index 4eaf6a83..ac9380a4 100644 --- a/src/Yavsc.Server/Yavsc.Server.csproj +++ b/src/Yavsc.Server/Yavsc.Server.csproj @@ -43,4 +43,4 @@ - \ No newline at end of file + diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj index 52effc72..6ccd44c0 100644 --- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -23,4 +23,4 @@ - \ No newline at end of file +