diff --git a/.vscode/launch.json b/.vscode/launch.json index 42be176a..efdaa6ea 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,21 +4,6 @@ // 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", @@ -48,10 +33,12 @@ "name": "Test PostIt.Android launch (Xamarin.UITest)", "type": "coreclr", "request": "launch", - "program": "${workspaceFolder}/src/PostIt/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests.dll", + "program": "${workspaceFolder}/src/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/PostIt.Tests", + "cwd": "${workspaceFolder}/src/PostIt.Tests", "console": "integratedTerminal", "stopAtEntry": false } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c900fa6a..e45a9921 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,29 +1,6 @@ { "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 d6d69196..830c48f2 100644 --- a/Makefile +++ b/Makefile @@ -161,14 +161,13 @@ ADB_SERIAL ?= emulator-5554 ANDROID_HOME ?= /opt/android-sdk POSTIT_RID ?= android-x64 EMU_HEADLESS ?= 0 -LOGCAT_LINES ?= 600 +LOGCAT_LINES ?= 200 LOGCAT_FOLLOW ?= 0 -LOGCAT_BOOT_WAIT ?= 20 +LOGCAT_BOOT_WAIT ?= 30 -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)/$(ANDROID_PACKAGE_NAME)-Signed.apk +POSTIT_APK := $(POSTIT_APK_DIR)/fr.pschneider.PostIt-Signed.apk qemu-run: @echo " Starting AVD $(AVD_NAME) on $(ADB_SERIAL)..." @@ -215,25 +214,24 @@ 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)" -r + adb -s $(ADB_SERIAL) install -r "$(POSTIT_APK)" qemu-uninstall: - adb -s $(ADB_SERIAL) uninstall $(ANDROID_PACKAGE_NAME) + adb -s $(ADB_SERIAL) uninstall fr.pschneider.PostIt # 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 $(ANDROID_PACKAGE_NAME)), not by tag, +# Filtering is by PID (pidof fr.pschneider.PostIt), 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. @@ -241,17 +239,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 $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ + @PID=$$(adb -s $(ADB_SERIAL) shell pidof fr.pschneider.PostIt 2>/dev/null | tr -d '\r\n'); \ if [ -z "$$PID" ]; then \ - echo " $(ANDROID_PACKAGE_NAME) is not running on $(ADB_SERIAL)."; \ - echo " Start the app first (am start -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity)"; \ + echo " fr.pschneider.PostIt is not running on $(ADB_SERIAL)."; \ + echo " Start the app first (am start -n fr.pschneider.PostIt/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 $(ANDROID_PACKAGE_NAME); \ + adb -s $(ADB_SERIAL) logcat -v time --pid=$$PID fr.pschneider.PostIt:F; \ else \ - adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID $(ANDROID_PACKAGE_NAME); \ + adb -s $(ADB_SERIAL) logcat -d -v time -t $(LOGCAT_LINES) --pid=$$PID fr.pschneider.PostIt:F; \ fi # Clear logcat, launch PostIt.Android, then dump everything that was @@ -265,21 +263,19 @@ LOGCAT_BOOT_WAIT ?= 15 qemu-logcat-boot: @echo " Clearing logcat buffer..." adb -s $(ADB_SERIAL) logcat -c - @echo " Launching $(ANDROID_PACKAGE_NAME)..." + @echo " Launching fr.pschneider.PostIt..." adb -s $(ADB_SERIAL) shell am start \ - -n $(ANDROID_PACKAGE_NAME)/PostIt.Android.PostItMainActivity + -n fr.pschneider.PostIt/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 $(ANDROID_PACKAGE_NAME) 2>/dev/null | tr -d '\r\n'); \ + @PID=$$(adb -s $(ADB_SERIAL) shell pidof fr.pschneider.PostIt 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 new file mode 160000 index 00000000..0695a6c1 --- /dev/null +++ b/external/dotnet-android-build-image @@ -0,0 +1 @@ +Subproject commit 0695a6c1fea6508f1a88f7ad0ad9cb93733aa52d diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 69e8a896..59b6b600 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -1,37 +1,21 @@ - + + - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/src/PostIt/PostIt.Android/Application.cs b/src/PostIt/PostIt.Android/Application.cs index f5a7908d..fb6b08d3 100644 --- a/src/PostIt/PostIt.Android/Application.cs +++ b/src/PostIt/PostIt.Android/Application.cs @@ -2,12 +2,6 @@ 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 62c29e10..1b413a9f 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; +using Android.App; using Android.Content.PM; -using Avalonia; +using Android.Content; using Avalonia.Android; +using AndroidX.Emoji2.Text; namespace PostIt.Android; @@ -12,10 +12,11 @@ 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 @@ -25,11 +26,13 @@ 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 a4af1eb2..20a27816 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -2,32 +2,35 @@ Exe net10.0-android - 23 + + android-arm64;android-x64 + 23.0.0 enable fr.pschneider.PostIt 1 1.0 apk - false - SdkOnly - partial + 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 - Resources\drawable\Icon.png - - - - + + + diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 91b61d05..2472d06d 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 3e7344a2..a84b85a3 100644 --- a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs @@ -72,7 +72,7 @@ public class MainPageButtonsTests { } } - private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null) + private static MainPageViewModel 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 MainViewModel(blog, services: services.BuildServiceProvider()); + var vm = new MainPageViewModel(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(MainViewModel vm) + private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel 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 5baae658..1eaf3f17 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 MainViewModel(blog); + var viewModel = new MainPageViewModel(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 9bc28888..2708ba8c 100644 --- a/src/PostIt/PostIt.Tests/PostAclDialogTests.cs +++ b/src/PostIt/PostIt.Tests/PostAclDialogTests.cs @@ -4,7 +4,6 @@ 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 433f36c3..d8517ba0 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -16,9 +16,7 @@ - - @@ -29,5 +27,6 @@ + diff --git a/src/PostIt/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt/PostIt.Tests/PostItViewModelTests.cs index d2c5d78e..780f674c 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 MainViewModel(blog); + var viewModel = new MainPageViewModel(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 362f398e..92497b74 100644 --- a/src/PostIt/PostIt/App.axaml +++ b/src/PostIt/PostIt/App.axaml @@ -1,16 +1,13 @@ - + x:Class="PostIt.App"> - - - + - + - + + diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 10a0c8a5..d2399873 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,70 +27,198 @@ public partial class App : Application /// DataValidationErrors.SetErrors. /// public IServiceProvider? ServiceProvider { get; private set; } - - public MainWindow? Window { get; private set; } + private MainWindow window; + public App() + { + } 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 = new ServiceCollection().BuildServices(); + this.ServiceProvider = BuildServices(new ServiceCollection()); + AttachServiceProvider(ServiceProvider); 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) { - desktop.MainWindow = CreateMainWindow(); - ApplyDarkMode(settings); + 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); } - else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) + else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) { - singleViewFactoryApplicationLifetime.MainViewFactory = - () => - { - Window = CreateMainWindow(); - ApplyDarkMode(settings); - return Window; - }; + singleView.MainView = new MainWindow + { + DataContext = ServiceProvider.GetRequiredService() + }; } - else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) - { - singleViewPlatform.MainView = CreateMainWindow(); - ApplyDarkMode(settings); - } - base.OnFrameworkInitializationCompleted(); } - - - private MainWindow CreateMainWindow() + /// + /// 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) { - 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 settings = new Settings(); + settings.Load(); - sessionStatus.LoginSucceeded += () => - { - PushMainPageAsync().Wait(); - }; + var tokenStore = new TokenStore(System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), + "PostIt", "tokens.json")); - var homeVm = ServiceProvider!.GetRequiredService(); + 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); - this.PushPageAsync(homeVm).Wait(); - Window.SessionBanner.DataContext = sessionStatus; - return Window; + + // 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); } /// @@ -101,7 +229,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) @@ -138,11 +266,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 async Task PushMainPageAsync() + public static Task PushMainPageAsync() { - var app = (App)Current!; - var mainVm = app.ServiceProvider!.GetRequiredService(); - await app.PushPageAsync(mainVm); + var app = (App)Current; + var mainVm = app.ServiceProvider.GetRequiredService(); + return app.PushPageAsync(mainVm); } private bool TryHandOffCustomSchemeUrl() @@ -177,8 +305,53 @@ 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 deleted file mode 100644 index f7da8bb5..00000000 Binary files a/src/PostIt/PostIt/Assets/avalonia-logo.ico and /dev/null differ diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs deleted file mode 100644 index 9e0382de..00000000 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 7fc81b3a..00000000 --- a/src/PostIt/PostIt/Helpers/ViewModelBaseHelpers.cs +++ /dev/null @@ -1,50 +0,0 @@ -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 70bd72ca..1b48c21f 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -1,14 +1,32 @@ - + 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 @@ -20,21 +38,6 @@ - - - - - None - All - - - - - - - - - - + diff --git a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs index 99358cfb..ad71063b 100644 --- a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs +++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs @@ -21,9 +21,6 @@ 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 03ab98a1..fd92c802 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using Avalonia.Controls; using Avalonia.Controls.Templates; using Microsoft.Extensions.DependencyInjection; @@ -11,19 +10,17 @@ 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 { @@ -35,12 +32,11 @@ public class ViewLocator : IDataTemplate } } - private Control BuildCore(object? data) { return data switch { - MainViewModel => _services.GetRequiredService(), + MainPageViewModel => _services.GetRequiredService(), Settings => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), SignaturePageViewModel => _services.GetRequiredService(), @@ -52,5 +48,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 9cee3ea8..33a5bd30 100644 --- a/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/CirclesPageViewModel.cs @@ -5,7 +5,6 @@ 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/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs similarity index 96% rename from src/PostIt/PostIt/ViewModels/MainViewModel.cs rename to src/PostIt/PostIt/ViewModels/MainPageViewModel.cs index 1aa3eee0..d374c9e4 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs @@ -8,11 +8,10 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using Yavsc.Blogspot; using Yavsc.Api.Client; -using PostIt.Helpers; namespace PostIt.ViewModels; -public partial class MainViewModel : ViewModelBase +public partial class MainPageViewModel : ViewModelBase { /// Window/tab title. Cosmetic — bound by /// MainPage.axaml if at all. Not the post title. @@ -121,10 +120,10 @@ public partial class MainViewModel : ViewModelBase } - public MainViewModel() + public MainPageViewModel() { + Init(null); SettingsModel = new Settings(); - Init(SettingsModel); BlogClient = null; } @@ -136,11 +135,6 @@ public partial class MainViewModel : 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 @@ -151,7 +145,11 @@ public partial class MainViewModel : 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 @@ -171,11 +169,12 @@ public partial class MainViewModel : ViewModelBase /// . Production code uses the /// (Settings, BlogApiClient) overload below. /// - public MainViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) + public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) { SettingsModel = new Settings(); BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; Services = services; + Init(settings); } @@ -211,7 +210,7 @@ public partial class MainViewModel : 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)) { @@ -260,7 +259,7 @@ public partial class MainViewModel : 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; @@ -279,7 +278,7 @@ public partial class MainViewModel : ViewModelBase DateCreated = SelectedPost.DateCreated, DateModified = DateTime.UtcNow, }; - await BlogClient!.UpdatePostAsync(SelectedPost.Id, update); + await BlogClient.UpdatePostAsync(SelectedPost.Id, update); StatusMessage = $"Saved post {SelectedPost.Id}."; } @@ -298,7 +297,7 @@ public partial class MainViewModel : ViewModelBase await ExecuteAsync(async () => { - await BlogClient!.DeletePostAsync(SelectedPost.Id); + await BlogClient.DeletePostAsync(SelectedPost.Id); StatusMessage = $"Deleted post {SelectedPost.Id}."; SelectedPost = null; await RefreshPostsAsync(); @@ -332,7 +331,7 @@ public partial class MainViewModel : 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 @@ -373,7 +372,7 @@ public partial class MainViewModel : 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 f2496b85..a1ad48ce 100644 --- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -3,7 +3,6 @@ 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; @@ -140,7 +139,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 2115a0cd..890ca15c 100644 --- a/src/PostIt/PostIt/ViewModels/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -47,7 +47,40 @@ 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(); @@ -66,7 +99,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. @@ -317,14 +350,15 @@ public partial class Settings : ViewModelBase var settings = JsonSerializer.Deserialize(json); if (settings is null) { - UseDefaultSettings(); + Console.Error.WriteLine($"🩎 Settings payload is invalid (source: {source})."); + return; } // 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. - else lock (_mutationGate) + lock (_mutationGate) { this.Authentication = settings.Authentication; this.DarkMode = settings.DarkMode; @@ -337,11 +371,6 @@ 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; } } @@ -371,18 +400,6 @@ 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 4ca69eea..5ce17aba 100644 --- a/src/PostIt/PostIt/ViewModels/ViewModelBase.cs +++ b/src/PostIt/PostIt/ViewModels/ViewModelBase.cs @@ -4,7 +4,8 @@ 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 7f1f1196..7eac39ff 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:MainViewModel" + x:DataType="vm:MainPageViewModel" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> - + + > - + + + diff --git a/src/Yavsc.Abstract/Yavsc.Abstract.csproj b/src/Yavsc.Abstract/Yavsc.Abstract.csproj index f76e42fd..7a4f83ae 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 00e6e2db..ade7ca17 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 d9a814f6..5672ab7a 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 83c0dc37..fc7883ce 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 63b3d977..5c175cb5 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 d332d250..88842cb4 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 0403c7eb..ccdb7f24 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 ac9380a4..4eaf6a83 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 6ccd44c0..52effc72 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