diff --git a/.editorconfig b/.editorconfig index 391c4c7..6926598 100644 --- a/.editorconfig +++ b/.editorconfig @@ -83,6 +83,12 @@ dotnet_code_quality_unused_parameters = all:warning # Suppression preferences dotnet_remove_unnecessary_suppression_exclusions = none +dotnet_diagnostic.IDE0079.severity = warning # flag suppressions that are no longer needed - complements the line above + +# Unused code +dotnet_diagnostic.IDE0005.severity = warning # unused using directives - the actual ask; needs EnforceCodeStyleInBuild=true in csproj to fire during `dotnet build`, not just in the IDE +dotnet_diagnostic.IDE0051.severity = warning # private member is never used +dotnet_diagnostic.IDE0052.severity = warning # private member's value is never read (assigned but unused) # New line preferences dotnet_style_allow_multiple_blank_lines_experimental = false:warning @@ -144,8 +150,8 @@ csharp_style_prefer_range_operator = true:warning csharp_style_prefer_tuple_swap = true:warning csharp_style_prefer_utf8_string_literals = true csharp_style_throw_expression = true:warning -csharp_style_unused_value_assignment_preference = discard_variable -csharp_style_unused_value_expression_statement_preference = discard_variable +csharp_style_unused_value_assignment_preference = discard_variable:warning +csharp_style_unused_value_expression_statement_preference = discard_variable:warning # 'using' directive preferences csharp_using_directive_placement = outside_namespace:warning @@ -288,5 +294,8 @@ dotnet_naming_style.begin_s_.required_suffix = dotnet_naming_style.begin_s_.word_separator = dotnet_naming_style.begin_s_.capitalization = camel_case +# I don't want to comment every shit +dotnet_diagnostic.CS1591.severity = none + [*.{cs,vb}] dotnet_diagnostic.CA1873.severity = none diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..e14d7e9 --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,37 @@ +name: build-test +on: + pull_request: + branches: [ main ] + +env: + DOTNET_VERSION: '11.0.x' + MAIN_PROJECT: 'Starlight.Launcher/Starlight.Launcher.csproj' + LOADER_PROJECT: 'Robust.Loader/Robust.Loader.csproj' + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rid: win-x64 + - os: ubuntu-latest + rid: linux-x64 + - os: macos-latest + rid: osx-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-dotnet@v4 + with: + dotnet-quality: preview + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Build launcher + shell: bash + run: dotnet build ${{ env.MAIN_PROJECT }} -c Release -r ${{ matrix.rid }} + - name: Build loader + shell: bash + run: dotnet build ${{ env.LOADER_PROJECT }} -c Release -r ${{ matrix.rid }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77850c8..9d3583a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,10 @@ name: build-and-release - on: push: tags: ['v*'] workflow_dispatch: - permissions: contents: write - env: DOTNET_VERSION: '11.0.x' MAIN_PROJECT: 'Starlight.Launcher/Starlight.Launcher.csproj' @@ -22,48 +19,29 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - - uses: actions/setup-dotnet@v4 with: dotnet-quality: preview dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Restore Workloads - shell: pwsh - run: | - dotnet workload restore ${{ env.MAIN_PROJECT }} - dotnet workload restore ${{ env.LOADER_PROJECT }} - - name: Resolve version id: ver shell: pwsh run: | $v = "${{ github.ref_name }}" -replace '^v','' echo "version=$v" >> $env:GITHUB_OUTPUT - - name: Publish (win-x64, self-contained) shell: pwsh run: | - dotnet publish ${{ env.MAIN_PROJECT }} -c Release -f net11.0-windows10.0.19041.0 -r win-x64 --self-contained true -p:WindowsAppSDKSelfContained=true -p:FullRelease=true -p:Version=${{ steps.ver.outputs.version }} -o publish/win-x64 + dotnet publish ${{ env.MAIN_PROJECT }} -c Release -r win-x64 --self-contained true -p:FullRelease=true -p:Version=${{ steps.ver.outputs.version }} -o publish/win-x64 dotnet publish ${{ env.LOADER_PROJECT }} -c Release -r win-x64 --self-contained true -p:Version=${{ steps.ver.outputs.version }} -o publish/win-x64/loader - - - name: Verify launcher output - shell: pwsh - run: | - Get-ChildItem Starlight.Launcher/bin/Release -Recurse | Select-Object FullName - - name: Inspect output shell: pwsh - run: | - Get-ChildItem publish/win-x64 -Recurse - + run: Get-ChildItem publish/win-x64 -Recurse - name: Download WebView2 bootstrapper shell: pwsh run: Invoke-WebRequest "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile installer/MicrosoftEdgeWebview2Setup.exe - - name: Install Inno Setup run: choco install innosetup --no-progress -y - - name: Build installer shell: pwsh run: > @@ -73,14 +51,208 @@ jobs: /DExeName=${{ env.EXE_NAME }} "/DSourceDir=${{ github.workspace }}\publish\win-x64" installer\Starlight.iss - - uses: actions/upload-artifact@v4 with: name: windows-installer path: installer/Output/*.exe + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-dotnet@v4 + with: + dotnet-quality: preview + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Resolve version + id: ver + run: | + v="${GITHUB_REF_NAME#v}" + echo "version=$v" >> "$GITHUB_OUTPUT" + - name: Publish (linux-x64, self-contained) + run: | + dotnet publish ${{ env.MAIN_PROJECT }} -c Release -r linux-x64 --self-contained true -p:FullRelease=true -p:Version=${{ steps.ver.outputs.version }} -o publish/linux-x64 + dotnet publish ${{ env.LOADER_PROJECT }} -c Release -r linux-x64 --self-contained true -p:Version=${{ steps.ver.outputs.version }} -o publish/linux-x64/loader + - name: Mark executables +x + run: | + chmod +x publish/linux-x64/Starlight.Launcher + chmod +x publish/linux-x64/loader/Robust.Loader + + - name: Package as tar.gz + run: tar -czf Starlight.Launcher-linux-x64-${{ steps.ver.outputs.version }}.tar.gz -C publish/linux-x64 . + - uses: actions/upload-artifact@v4 + with: + name: linux-tarball + path: Starlight.Launcher-linux-x64-*.tar.gz + + - name: Assemble .deb package tree + run: | + set -e + PKG_VERSION="${{ steps.ver.outputs.version }}" + PKG_ROOT="deb/starlight-launcher_${PKG_VERSION}_amd64" + + mkdir -p "$PKG_ROOT/DEBIAN" + mkdir -p "$PKG_ROOT/opt/starlight-launcher" + mkdir -p "$PKG_ROOT/usr/bin" + mkdir -p "$PKG_ROOT/usr/share/applications" + mkdir -p "$PKG_ROOT/usr/share/icons/hicolor/256x256/apps" + + cp -R publish/linux-x64/. "$PKG_ROOT/opt/starlight-launcher/" + chmod +x "$PKG_ROOT/opt/starlight-launcher/Starlight.Launcher" + chmod +x "$PKG_ROOT/opt/starlight-launcher/loader/Robust.Loader" + + ln -s /opt/starlight-launcher/Starlight.Launcher "$PKG_ROOT/usr/bin/starlight-launcher" + + cp PublishFiles/icon.png "$PKG_ROOT/usr/share/icons/hicolor/256x256/apps/starlight-launcher.png" || true + + cat > "$PKG_ROOT/usr/share/applications/starlight-launcher.desktop" << DESKTOP + [Desktop Entry] + Type=Application + Name=Starlight Launcher + Comment=Starlight Launcher for Space Station 14 + Exec=/opt/starlight-launcher/Starlight.Launcher %u + Icon=starlight-launcher + Terminal=false + Categories=Game; + MimeType=x-scheme-handler/starlight; + DESKTOP + + cat > "$PKG_ROOT/DEBIAN/control" << CONTROL + Package: starlight-launcher + Version: ${PKG_VERSION} + Section: games + Priority: optional + Architecture: amd64 + Maintainer: Starlight Team + Description: Starlight Launcher for Space Station 14 + A modern, cross-platform launcher built on Avalonia and Blazor. + CONTROL + + cat > "$PKG_ROOT/DEBIAN/postinst" << 'POSTINST' + #!/bin/sh + set -e + # Refreshes the desktop-file and MIME caches so the new app/association + # actually shows up without the user having to log out and back in. + update-desktop-database /usr/share/applications >/dev/null 2>&1 || true + gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true + exit 0 + POSTINST + chmod 0755 "$PKG_ROOT/DEBIAN/postinst" + + - name: Build .deb + run: | + dpkg-deb --build --root-owner-group \ + "deb/starlight-launcher_${{ steps.ver.outputs.version }}_amd64" \ + "Starlight.Launcher-linux-x64-${{ steps.ver.outputs.version }}.deb" + + - uses: actions/upload-artifact@v4 + with: + name: linux-deb + path: Starlight.Launcher-*.deb + + - name: Build AppImage + run: | + set -e + APPDIR=AppDir + mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/applications" "$APPDIR/usr/share/icons/hicolor/256x256/apps" + + cp -R publish/linux-x64/. "$APPDIR/usr/bin/" + chmod +x "$APPDIR/usr/bin/Starlight.Launcher" + chmod +x "$APPDIR/usr/bin/loader/Robust.Loader" + + cp PublishFiles/icon.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/starlight-launcher.png" || true + cp PublishFiles/icon.png "$APPDIR/starlight-launcher.png" || true + + cat > "$APPDIR/starlight-launcher.desktop" << DESKTOP + [Desktop Entry] + Type=Application + Name=Starlight Launcher + Exec=Starlight.Launcher %u + Icon=starlight-launcher + Categories=Game; + MimeType=x-scheme-handler/starlight; + DESKTOP + + cat > "$APPDIR/AppRun" << 'APPRUN' + #!/bin/sh + HERE="$(dirname "$(readlink -f "$0")")" + exec "$HERE/usr/bin/Starlight.Launcher" "$@" + APPRUN + chmod +x "$APPDIR/AppRun" + + wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O appimagetool + chmod +x appimagetool + + ./appimagetool --appimage-extract-and-run "$APPDIR" "Starlight.Launcher-linux-x64-${{ steps.ver.outputs.version }}.AppImage" + + - uses: actions/upload-artifact@v4 + with: + name: linux-appimage + path: Starlight.Launcher-*.AppImage + + macos: + runs-on: macos-latest + strategy: + matrix: + rid: [osx-x64, osx-arm64] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-dotnet@v4 + with: + dotnet-quality: preview + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Resolve version + id: ver + run: | + v="${GITHUB_REF_NAME#v}" + echo "version=$v" >> "$GITHUB_OUTPUT" + - name: Publish (${{ matrix.rid }}, self-contained) + run: | + dotnet publish ${{ env.MAIN_PROJECT }} -c Release -r ${{ matrix.rid }} --self-contained true -p:FullRelease=true -p:Version=${{ steps.ver.outputs.version }} -o publish/${{ matrix.rid }} + dotnet publish ${{ env.LOADER_PROJECT }} -c Release -r ${{ matrix.rid }} --self-contained true -p:Version=${{ steps.ver.outputs.version }} -o publish/${{ matrix.rid }}/loader + - name: Assemble .app bundle + run: | + APP="Starlight Launcher.app" + mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + cp -R publish/${{ matrix.rid }}/. "$APP/Contents/MacOS/" + chmod +x "$APP/Contents/MacOS/Starlight.Launcher" + chmod +x "$APP/Contents/MacOS/loader/Robust.Loader" + cp Starlight.Launcher/Resources/AppIcon/icon.icns "$APP/Contents/Resources/icon.icns" || true + cat > "$APP/Contents/Info.plist" < + + + + CFBundleExecutableStarlight.Launcher + CFBundleIdentifiercom.starlight.launcher + CFBundleNameStarlight Launcher + CFBundleShortVersionString${{ steps.ver.outputs.version }} + CFBundleIconFileicon.icns + CFBundlePackageTypeAPPL + LSMinimumSystemVersion12.0 + CFBundleURLTypes + + + CFBundleURLNamecom.starlight.launcher.protocol + CFBundleURLSchemesstarlight + + + + + PLIST + - name: Package + run: ditto -c -k --sequesterRsrc --keepParent "Starlight Launcher.app" Starlight.Launcher-${{ matrix.rid }}-${{ steps.ver.outputs.version }}.zip + - uses: actions/upload-artifact@v4 + with: + name: macos-${{ matrix.rid }}-build + path: Starlight.Launcher-${{ matrix.rid }}-*.zip + release: - needs: [windows] + needs: [windows, linux, macos] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore index d74b31c..e2f67fe 100644 --- a/.gitignore +++ b/.gitignore @@ -68,8 +68,4 @@ x86/ *.nuget.targets # Publish Web Output -*.[Pp]ublish.xml - - -# Custom manifest for localization -**/[Rr]esources/[Rr]aw/[Ll]ocale/[Mm]anifest.json \ No newline at end of file +*.[Pp]ublish.xml \ No newline at end of file diff --git a/LocalizationIndexer/LocalizationIndexer.csproj b/LocalizationIndexer/LocalizationIndexer.csproj deleted file mode 100644 index c9a7479..0000000 --- a/LocalizationIndexer/LocalizationIndexer.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - Exe - net11.0 - enable - enable - - \ No newline at end of file diff --git a/LocalizationIndexer/Program.cs b/LocalizationIndexer/Program.cs deleted file mode 100644 index c9a9950..0000000 --- a/LocalizationIndexer/Program.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Text.Json; - -var localeRoot = args[0]; -var outputFile = args[1]; - -var result = new Dictionary(); - -foreach (var dir in Directory.GetDirectories(localeRoot)) -{ - var localeId = Path.GetFileName(dir); - - var files = Directory.GetFiles(dir, "*.ftl", SearchOption.AllDirectories) - .Select(f => Path.GetRelativePath(dir, f).Replace('\\', '/')) - .OrderBy(x => x) - .ToArray(); - - if (files.Length > 0) - result[localeId] = files; -} - -await File.WriteAllTextAsync( - outputFile, - JsonSerializer.Serialize(result, new JsonSerializerOptions - { - WriteIndented = true - })); \ No newline at end of file diff --git a/PublishFiles/icon.png b/PublishFiles/icon.png new file mode 100644 index 0000000..4d28fdc Binary files /dev/null and b/PublishFiles/icon.png differ diff --git a/Robust.Launcher.Api/Api/AuthApi.cs b/Robust.Launcher.Api/Api/AuthApi.cs index 7cc45c4..99b7c10 100644 --- a/Robust.Launcher.Api/Api/AuthApi.cs +++ b/Robust.Launcher.Api/Api/AuthApi.cs @@ -14,17 +14,26 @@ namespace Robust.Launcher.Api.Api; +/// +/// Provides methods for interacting with the authentication API. +/// public sealed class AuthApi { private readonly HttpClient _httpClient; private readonly ILogger _logger; + /// + /// Initializes a new instance of the class. + /// public AuthApi(HttpClient http, ILogger logger) { _httpClient = http; _logger = logger; } + /// + /// Authenticates a user. + /// public async Task AuthenticateAsync(AuthenticateRequest request, UrlFallbackSet authSet) { try @@ -79,6 +88,9 @@ public async Task AuthenticateAsync(AuthenticateRequest requ } } + /// + /// Registers a new user account. + /// public async Task RegisterAsync(string username, string email, string password, UrlFallbackSet authSet) { try @@ -122,7 +134,9 @@ public async Task RegisterAsync(string username, string email, s } } - /// Any errors that occured + /// + /// Requests a password reset email. + /// public async Task ForgotPasswordAsync(string email, UrlFallbackSet authSet) { try @@ -152,6 +166,9 @@ public async Task RegisterAsync(string username, string email, s } } + /// + /// Requests a new account confirmation email. + /// public async Task ResendConfirmationAsync(string email, UrlFallbackSet authSet) { try @@ -182,10 +199,9 @@ public async Task RegisterAsync(string username, string email, s } } - /// Null if the server refused to refresh the token (it expired). - /// - /// Thrown if an unexpected error occured. - /// + /// + /// Refreshes an authentication token. + /// public async Task RefreshTokenAsync(string token, UrlFallbackSet authSet) { try @@ -231,6 +247,9 @@ public async Task RegisterAsync(string username, string email, s } } + /// + /// Logs out the specified authentication token. + /// public async Task LogoutTokenAsync(string token, UrlFallbackSet authSet) { try @@ -261,12 +280,8 @@ public async Task LogoutTokenAsync(string token, UrlFallbackSet authSet) } /// - /// Check if a token is still valid. + /// Checks whether an authentication token is still valid. /// - /// True if the token is still valid. - /// - /// Thrown if an unexpected error occured. - /// public async Task CheckTokenAsync(string token, UrlFallbackSet authSet) { try @@ -304,62 +319,130 @@ public async Task CheckTokenAsync(string token, UrlFallbackSet authSet) } } + /// + /// Represents an authentication request. + /// public sealed record AuthenticateRequest(string? Username, Guid? UserId, string Password, string? TfaCode = null) { + /// + /// Initializes an authentication request using a username. + /// public AuthenticateRequest(string username, string password) : this(username, null, password) { } + /// + /// Initializes an authentication request using a user ID. + /// public AuthenticateRequest(Guid userId, string password) : this(null, userId, password) { } } + /// + /// Represents a successful authentication response. + /// public sealed record AuthenticateResponse(string Token, string Username, Guid UserId, DateTimeOffset ExpireTime); + /// + /// Represents a failed authentication response. + /// public sealed record AuthenticateDenyResponse(string[] Errors, AuthenticateDenyResponseCode Code); + /// + /// Specifies the reason an authentication request was denied. + /// [JsonConverter(typeof(JsonStringEnumConverter))] public enum AuthenticateDenyResponseCode { - // @formatter:off + /// + /// No response + /// None = 0, + /// + /// Invalid credentials + /// InvalidCredentials = 1, + /// + /// Account needs confirmation via email + /// AccountUnconfirmed = 2, + /// + /// Account needs 2fa + /// TfaRequired = 3, + /// + /// Invalid 2fa + /// TfaInvalid = 4, + /// + /// Account banned + /// AccountLocked = 5, - - // Not actually from the API, but used internally. + /// + /// Unknown + /// UnknownError = -1, - // @formatter:on } + /// + /// Represents a user registration request. + /// public sealed record RegisterRequest(string Username, string Email, string Password); + /// + /// Represents a successful registration response. + /// public sealed record RegisterResponse(RegisterResponseStatus Status); + /// + /// Represents a failed registration response. + /// public sealed record RegisterResponseError(string[] Errors); + /// + /// Represents a password reset request. + /// public sealed record ResetPasswordRequest(string Email); + /// + /// Represents an account confirmation request. + /// public sealed record ResendConfirmationRequest(string Email); + /// + /// Represents a logout request. + /// public sealed record LogoutRequest(string Token); + /// + /// Represents a token refresh request. + /// public sealed record RefreshRequest(string Token); + /// + /// Represents a token refresh response. + /// public sealed record RefreshResponse(DateTimeOffset ExpireTime, string NewToken); } +/// +/// Represents the result of an authentication request. +/// public readonly struct AuthenticateResult { private readonly LoginInfo? _loginInfo; + /// + /// Gets the authentication failure code. + /// public AuthApi.AuthenticateDenyResponseCode Code { get; } + /// + /// Initializes a successful authentication result. + /// public AuthenticateResult(LoginInfo loginInfo) { _loginInfo = loginInfo; @@ -367,6 +450,9 @@ public AuthenticateResult(LoginInfo loginInfo) Code = default; } + /// + /// Initializes a failed authentication result. + /// public AuthenticateResult(string[] errors, AuthApi.AuthenticateDenyResponseCode code) { _loginInfo = null; @@ -374,55 +460,103 @@ public AuthenticateResult(string[] errors, AuthApi.AuthenticateDenyResponseCode Code = code; } + /// + /// Gets a value indicating whether the authentication succeeded. + /// public bool IsSuccess => _loginInfo != null; + /// + /// Gets the authenticated login information. + /// public LoginInfo LoginInfo => _loginInfo ?? throw new InvalidOperationException("This AuthenticateResult is not a success."); + /// + /// Gets the authentication errors. + /// [AllowNull] public string[] Errors => field ?? throw new InvalidOperationException("This AuthenticateResult is not a failure."); } +/// +/// Represents the result of a registration request. +/// public readonly struct RegisterResult { private readonly RegisterResponseStatus? _status; + /// + /// Initializes a successful registration result. + /// public RegisterResult(RegisterResponseStatus status) { _status = status; Errors = null; } + /// + /// Initializes a failed registration result. + /// public RegisterResult(string[] errors) { _status = null; Errors = errors; } + /// + /// Gets a value indicating whether the registration succeeded. + /// public bool IsSuccess => _status != null; + /// + /// Gets the registration status. + /// public RegisterResponseStatus Status => _status ?? throw new InvalidOperationException("This RegisterResult is not a success."); + /// + /// Gets the registration errors. + /// [AllowNull] public string[] Errors => field ?? throw new InvalidOperationException("This RegisterResult is not a failure."); } +/// +/// Specifies the result of a registration request. +/// public enum RegisterResponseStatus { + /// + /// Success + /// Registered, + /// + /// Needs confirmation via email + /// RegisteredNeedConfirmation } +/// +/// Represents an error that occurs while communicating with the authentication API. +/// [Serializable] public class AuthApiException : Exception { + /// + /// Initializes a new instance of the class. + /// public AuthApiException() { } + /// + /// Initializes a new instance of the class with the specified error message. + /// public AuthApiException(string message) : base(message) { } + /// + /// Initializes a new instance of the class with the specified error message and inner exception. + /// public AuthApiException(string message, Exception inner) : base(message, inner) { } diff --git a/Robust.Launcher.Api/Api/HubApi.cs b/Robust.Launcher.Api/Api/HubApi.cs index 89e5af3..a67a757 100644 --- a/Robust.Launcher.Api/Api/HubApi.cs +++ b/Robust.Launcher.Api/Api/HubApi.cs @@ -2,7 +2,6 @@ using Robust.Launcher.Api.Utility; using Microsoft.Extensions.Logging; using System; -using System.Linq; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; @@ -11,17 +10,26 @@ namespace Robust.Launcher.Api.Api; +/// +/// Provides methods for interacting with the hub API. +/// public sealed class HubApi { private readonly HttpClient _http; private readonly ILogger _logger; + /// + /// Initializes a new instance of the class. + /// public HubApi(HttpClient http, ILogger logger) { _http = http; _logger = logger; } + /// + /// Retrieves the list of available servers from the hub. + /// public async Task GetServers(UrlFallbackSet hubUri, CancellationToken cancel) { HubApiException? lastError = null; @@ -55,6 +63,9 @@ public async Task GetServers(UrlFallbackSet hubUri, Cancellat throw lastError ?? new HubApiException("No URLs in fallback set"); } + /// + /// Retrieves information about a server from the hub. + /// public async Task GetServerInfo( string serverAddress, string hubAddress, @@ -69,6 +80,9 @@ public async Task GetServerInfo( return await RequestJsonAsync(url, cancel).ConfigureAwait(false); } + /// + /// Sends a request and deserializes the JSON response. + /// private async Task RequestJsonAsync(string url, CancellationToken cancel) { HttpResponseMessage response; @@ -137,5 +151,8 @@ private async Task RequestJsonAsync(string url, CancellationToken cancel) } } + /// + /// Represents a server entry returned by the hub. + /// public sealed record ServerListEntry(string Address, ServerApi.ServerStatus StatusData); } diff --git a/Robust.Launcher.Api/Api/HubApiException.cs b/Robust.Launcher.Api/Api/HubApiException.cs index 2ed286b..1f4b498 100644 --- a/Robust.Launcher.Api/Api/HubApiException.cs +++ b/Robust.Launcher.Api/Api/HubApiException.cs @@ -3,14 +3,39 @@ namespace Robust.Launcher.Api.Api; +/// +/// Represents an error that occurs while communicating with the hub API. +/// public sealed class HubApiException : Exception { + /// + /// Gets the HTTP status code returned by the hub, if available. + /// public HttpStatusCode? StatusCode { get; } + + /// + /// Gets the suggested delay before retrying the request, if provided by the hub. + /// public TimeSpan? RetryAfter { get; } + + /// + /// Gets the URL of the request that caused the exception. + /// public string? RequestUrl { get; } + + /// + /// Gets a value indicating whether the request was rejected due to rate limiting. + /// public bool IsRateLimited => StatusCode == HttpStatusCode.TooManyRequests; + + /// + /// Gets a value indicating whether the request timed out. + /// public bool IsTimeout { get; } + /// + /// Initializes a new instance of the class. + /// public HubApiException( string message, HttpStatusCode? statusCode = null, @@ -25,4 +50,4 @@ public HubApiException( RequestUrl = requestUrl; IsTimeout = isTimeout; } -} \ No newline at end of file +} diff --git a/Robust.Launcher.Api/Api/ServerApi.cs b/Robust.Launcher.Api/Api/ServerApi.cs index 137d7cf..6506c83 100644 --- a/Robust.Launcher.Api/Api/ServerApi.cs +++ b/Robust.Launcher.Api/Api/ServerApi.cs @@ -4,9 +4,14 @@ namespace Robust.Launcher.Api.Api; +/// +/// Provides types and helpers for the game server API. +/// public static class ServerApi { - // https://docs.spacestation14.io/en/engine/http-api + /// + /// Represents the current status of a game server. + /// public sealed record ServerStatus( [property: JsonPropertyName("name")] string? Name, [property: JsonPropertyName("players")] @@ -17,6 +22,9 @@ public sealed record ServerStatus( [property: JsonPropertyName("run_level")] GameRunLevel? RunLevel, [property: JsonPropertyName("tags")] string[]? Tags); + /// + /// Represents the current state of the game round. + /// public enum GameRunLevel { PreRoundLobby = 0, @@ -25,20 +33,16 @@ public enum GameRunLevel } /// - /// Contains definitions for standard tags returned by game servers. + /// Provides constants and helper methods for standard server tags. /// public static class Tags { - // @formatter:off - - // Base tag definitions. public const string TagEighteenPlus = "18+"; public const string TagRegion = "region:"; public const string TagLanguage = "lang:"; public const string TagRolePlay = "rp:"; public const string TagNoTagInfer = "no_tag_infer"; - // Region tags. public const string RegionAfricaCentral = "af_c"; public const string RegionAfricaNorth = "af_n"; public const string RegionAfricaSouth = "af_s"; @@ -61,19 +65,29 @@ public static class Tags public const string RegionSouthAmericaSouth = "am_s_s"; public const string RegionSouthAmericaWest = "am_s_w"; - // RolePlay level tags. public const string RolePlayNone = "none"; public const string RolePlayLow = "low"; public const string RolePlayMedium = "med"; public const string RolePlayHigh = "high"; - // @formatter:on + /// + /// Attempts to extract the region value from a server tag. + /// public static bool TryRegion(string tag, [NotNullWhen(true)] out string? region) => TryTagPrefix(tag, TagRegion, out region); + /// + /// Attempts to extract the language value from a server tag. + /// public static bool TryLanguage(string tag, [NotNullWhen(true)] out string? language) => TryTagPrefix(tag, TagLanguage, out language); + /// + /// Attempts to extract the role-play level from a server tag. + /// public static bool TryRolePlay(string tag, [NotNullWhen(true)] out string? rolePlay) => TryTagPrefix(tag, TagRolePlay, out rolePlay); + /// + /// Attempts to extract the value of a tag with the specified prefix. + /// public static bool TryTagPrefix(string tag, string prefix, [NotNullWhen(true)] out string? value) { if (!tag.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) diff --git a/Robust.Launcher.Api/Models/ServerStatus/ServerStatusCache.cs b/Robust.Launcher.Api/Models/ServerStatus/ServerStatusCache.cs index 31ce8a1..33912fc 100644 --- a/Robust.Launcher.Api/Models/ServerStatus/ServerStatusCache.cs +++ b/Robust.Launcher.Api/Models/ServerStatus/ServerStatusCache.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -36,6 +35,7 @@ public ServerStatusCache(HttpClient http, ILogger logger) /// This does NOT start fetching the data. /// /// The address of the server to fetch data for. + /// The address of the hub to fetch data for. public ServerStatusData GetStatusFor(string serverAddress, string? hubAddress = null) => _cachedData.GetOrAdd( serverAddress, @@ -68,7 +68,7 @@ private async void UpdateStatusFor(CacheReg reg) } finally { - reg.Semaphore.Release(); + _ = reg.Semaphore.Release(); } } @@ -134,7 +134,7 @@ public async Task UpdateStatusFor(ServerStatusData data, HttpClient http, Cancel HttpCompletionOption.ResponseHeadersRead, linkedToken.Token); - response.EnsureSuccessStatusCode(); + _ = response.EnsureSuccessStatusCode(); status = await response.Content .ReadFromJsonAsync(linkedToken.Token) @@ -222,19 +222,30 @@ public async Task UpdateInfoForCore(ServerStatusData data, Func _ = UpdateInfoForCore(statusData, async cancel => + { + var uriBuilder = new UriBuilder( + UriHelper.GetServerInfoAddress(statusData.Address)) { - var uriBuilder = new UriBuilder( - UriHelper.GetServerInfoAddress(statusData.Address)) - { - Query = "can_skip_build=1" - }; + Query = "can_skip_build=1" + }; + + var url = uriBuilder.ToString(); + + try + { + _logger.LogDebug( + "Updating server info. Address: {Address}, Url: {Url}", + statusData.Address, + url); - var url = uriBuilder.ToString(); + var result = await _http.GetFromJsonAsync( + url, + cancel); - try + if (result is null) { - _logger.LogDebug( - "Updating server info. Address: {Address}, Url: {Url}", - statusData.Address, - url); - - var result = await _http.GetFromJsonAsync( - url, - cancel); - - if (result is null) - { - _logger.LogWarning( - "Server info response was null. Address: {Address}", - statusData.Address); - } - else - { - _logger.LogDebug( - "Server info updated successfully. Address: {Address}", - statusData.Address); - } - - return result; + _logger.LogWarning( + "Server info response was null. Address: {Address}", + statusData.Address); } - catch (OperationCanceledException) + else { - _logger.LogInformation( - "Server info update cancelled. Address: {Address}", + _logger.LogDebug( + "Server info updated successfully. Address: {Address}", statusData.Address); - - throw; } - catch (HttpRequestException ex) - { - _logger.LogError( - ex, - "HTTP error while updating server info. Address: {Address}, Url: {Url}", - statusData.Address, - url); - throw; - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Unexpected error while updating server info. Address: {Address}", - statusData.Address); + return result; + } + catch (OperationCanceledException) + { + _logger.LogInformation( + "Server info update cancelled. Address: {Address}", + statusData.Address); - throw; - } - }); + throw; + } + catch (HttpRequestException ex) + { + _logger.LogError( + ex, + "HTTP error while updating server info. Address: {Address}, Url: {Url}", + statusData.Address, + url); + + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Unexpected error while updating server info. Address: {Address}", + statusData.Address); + + throw; + } + }); private sealed class CacheReg { diff --git a/Robust.Launcher.Api/Robust.Launcher.Api.csproj b/Robust.Launcher.Api/Robust.Launcher.Api.csproj index 88d1448..2d2e2d3 100644 --- a/Robust.Launcher.Api/Robust.Launcher.Api.csproj +++ b/Robust.Launcher.Api/Robust.Launcher.Api.csproj @@ -4,6 +4,7 @@ 1.0.0 enable true + true diff --git a/Robust.Launcher.Api/Utility/Blake2B.cs b/Robust.Launcher.Api/Utility/Blake2B.cs index a0e5ca8..9845971 100644 --- a/Robust.Launcher.Api/Utility/Blake2B.cs +++ b/Robust.Launcher.Api/Utility/Blake2B.cs @@ -12,7 +12,7 @@ public static byte[] HashStream(Stream stream, int outputLength) CryptoGenericHashBlake2B.State state; var pool = ArrayPool.Shared.Rent(65536); - CryptoGenericHashBlake2B.Init(ref state, ReadOnlySpan.Empty, outputLength); + _ = CryptoGenericHashBlake2B.Init(ref state, ReadOnlySpan.Empty, outputLength); while (true) { @@ -21,14 +21,14 @@ public static byte[] HashStream(Stream stream, int outputLength) break; var readData = pool.AsSpan(0, read); - CryptoGenericHashBlake2B.Update(ref state, readData); + _ = CryptoGenericHashBlake2B.Update(ref state, readData); } ArrayPool.Shared.Return(pool); var result = new byte[outputLength]; - CryptoGenericHashBlake2B.Final(ref state, result); + _ = CryptoGenericHashBlake2B.Final(ref state, result); return result; } -} \ No newline at end of file +} diff --git a/Robust.Launcher.Api/Utility/Blake2BHasherStream.cs b/Robust.Launcher.Api/Utility/Blake2BHasherStream.cs index 8358789..0a43d00 100644 --- a/Robust.Launcher.Api/Utility/Blake2BHasherStream.cs +++ b/Robust.Launcher.Api/Utility/Blake2BHasherStream.cs @@ -21,14 +21,14 @@ private Blake2BHasherStream(Stream wrapping, bool reader, ReadOnlySpan key WrappingStream = wrapping; _reader = reader; - CryptoGenericHashBlake2B.Init(ref State, key, outputLength); + _ = CryptoGenericHashBlake2B.Init(ref State, key, outputLength); } public byte[] Finish() { var result = new byte[OutputLength]; - CryptoGenericHashBlake2B.Final(ref State, result); + _ = CryptoGenericHashBlake2B.Final(ref State, result); return result; } @@ -65,7 +65,7 @@ public override int Read(byte[] buffer, int offset, int count) var read = WrappingStream.Read(buffer, offset, count); if (read > 0) - CryptoGenericHashBlake2B.Update(ref State, buffer.AsSpan(offset, read)); + _ = CryptoGenericHashBlake2B.Update(ref State, buffer.AsSpan(offset, read)); return read; } @@ -78,7 +78,7 @@ public override int Read(Span buffer) var read = WrappingStream.Read(buffer); if (read > 0) - CryptoGenericHashBlake2B.Update(ref State, buffer[..read]); + _ = CryptoGenericHashBlake2B.Update(ref State, buffer[..read]); return read; } @@ -91,7 +91,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation var read = await WrappingStream.ReadAsync(buffer, cancellationToken); if (read > 0) - CryptoGenericHashBlake2B.Update(ref State, buffer[..read].Span); + _ = CryptoGenericHashBlake2B.Update(ref State, buffer[..read].Span); return read; } @@ -106,7 +106,7 @@ public override void Write(byte[] buffer, int offset, int count) throw new InvalidOperationException(); WrappingStream.Write(buffer, offset, count); - CryptoGenericHashBlake2B.Update(ref State, buffer.AsSpan(offset, count)); + _ = CryptoGenericHashBlake2B.Update(ref State, buffer.AsSpan(offset, count)); } public override bool CanRead => _reader; @@ -119,4 +119,4 @@ public override long Position get => throw new NotSupportedException(); set => throw new NotSupportedException(); } -} \ No newline at end of file +} diff --git a/Robust.Launcher.Api/Utility/HappyEyeballsHttp.cs b/Robust.Launcher.Api/Utility/HappyEyeballsHttp.cs index c942217..202b912 100644 --- a/Robust.Launcher.Api/Utility/HappyEyeballsHttp.cs +++ b/Robust.Launcher.Api/Utility/HappyEyeballsHttp.cs @@ -238,7 +238,7 @@ private static IPAddress[] SortInterleaved(IPAddress[] addresses) else { // Faulted. Remove it. - tasks.Remove(completedTask); + _ = tasks.Remove(completedTask); } } diff --git a/Robust.Launcher.Api/Utility/Helpers.cs b/Robust.Launcher.Api/Utility/Helpers.cs index 21d9146..9370412 100644 --- a/Robust.Launcher.Api/Utility/Helpers.cs +++ b/Robust.Launcher.Api/Utility/Helpers.cs @@ -1,6 +1,5 @@ using System; using System.Buffers; -using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Net.Http; @@ -67,7 +66,7 @@ public static unsafe void MarkDirectoryCompress(string path) var lpBytesReturned = 0u; var lpInBuffer = (short)Win.COMPRESSION_FORMAT_DEFAULT; - Win.DeviceIoControl( + _ = Win.DeviceIoControl( handle, FSCTL.FSCTL_SET_COMPRESSION, &lpInBuffer, @@ -77,14 +76,14 @@ public static unsafe void MarkDirectoryCompress(string path) &lpBytesReturned, null); - Win.CloseHandle(handle); + _ = Win.CloseHandle(handle); } } public static void EnsureDirectoryExists(string dir) { if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); + _ = Directory.CreateDirectory(dir); } public static async Task DownloadToStream(this HttpClient client, string uri, Stream stream, @@ -92,7 +91,7 @@ public static async Task DownloadToStream(this HttpClient client, string uri, St await Task.Run(async () => { using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancel); - response.EnsureSuccessStatusCode(); + _ = response.EnsureSuccessStatusCode(); await using var contentStream = await response.Content.ReadAsStreamAsync(cancel); // await using var fileStream = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite, 4096, useAsync: true); diff --git a/Robust.Launcher.Api/Utility/HttpSelfTest.cs b/Robust.Launcher.Api/Utility/HttpSelfTest.cs index b25b0ac..f9c54c9 100644 --- a/Robust.Launcher.Api/Utility/HttpSelfTest.cs +++ b/Robust.Launcher.Api/Utility/HttpSelfTest.cs @@ -230,13 +230,11 @@ private void Log(Direction direction, ReadOnlySpan data) var subData = data.Slice(chunkOffset, remainingSize); foreach (var b in subData) - { - sb.Append($"{b:X2} "); - } + _ = sb.Append($"{b:X2} "); _log($"{chunkOffset:X16} {sb}"); - sb.Clear(); + _ = sb.Clear(); } } diff --git a/Robust.Launcher.Api/Utility/HttpUtility.cs b/Robust.Launcher.Api/Utility/HttpUtility.cs index 62cb9bc..2afeaac 100644 --- a/Robust.Launcher.Api/Utility/HttpUtility.cs +++ b/Robust.Launcher.Api/Utility/HttpUtility.cs @@ -1,10 +1,8 @@ using System; using System.Diagnostics; using System.IO; -using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; @@ -116,4 +114,4 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } -} \ No newline at end of file +} diff --git a/Robust.Launcher.Api/Utility/UrlFallbackSet.cs b/Robust.Launcher.Api/Utility/UrlFallbackSet.cs index 99d469e..9241e49 100644 --- a/Robust.Launcher.Api/Utility/UrlFallbackSet.cs +++ b/Robust.Launcher.Api/Utility/UrlFallbackSet.cs @@ -56,7 +56,7 @@ public UrlFallbackSet(string url, UrlFallbackSetStats? stats = null) public async Task GetFromJsonAsync(HttpClient client, CancellationToken cancel = default) where T : notnull { var msg = await GetAsync(client, cancel).ConfigureAwait(false); - msg.EnsureSuccessStatusCode(); + _ = msg.EnsureSuccessStatusCode(); return await msg.Content.ReadFromJsonAsync(cancel).ConfigureAwait(false); } @@ -64,7 +64,7 @@ public UrlFallbackSet(string url, UrlFallbackSetStats? stats = null) public async Task GetByteArrayAsync(HttpClient client, CancellationToken cancel = default) { var msg = await GetAsync(client, cancel).ConfigureAwait(false); - msg.EnsureSuccessStatusCode(); + _ = msg.EnsureSuccessStatusCode(); return await msg.Content.ReadAsByteArrayAsync(cancel).ConfigureAwait(false); } @@ -72,7 +72,7 @@ public async Task GetByteArrayAsync(HttpClient client, CancellationToken public async Task GetStringAsync(HttpClient client, CancellationToken cancel = default) { var msg = await GetAsync(client, cancel).ConfigureAwait(false); - msg.EnsureSuccessStatusCode(); + _ = msg.EnsureSuccessStatusCode(); return await msg.Content.ReadAsStringAsync(cancel).ConfigureAwait(false); } diff --git a/Robust.Loader/ContentDbFileApi.cs b/Robust.Loader/ContentDbFileApi.cs index 762c4cb..da3d231 100644 --- a/Robust.Loader/ContentDbFileApi.cs +++ b/Robust.Loader/ContentDbFileApi.cs @@ -41,7 +41,7 @@ public unsafe ContentDbFileApi(string contentDbPath, long version) // Make sure to have a read transaction on every database connection // so that the launcher can't delete anything from underneath us if the user does anything. - sqlite3_exec(db, "BEGIN"); + _ = sqlite3_exec(db, "BEGIN"); CheckThrowSqliteErr(db, err); @@ -61,7 +61,7 @@ public unsafe ContentDbFileApi(string contentDbPath, long version) null); CheckThrowSqliteErr(db, err); - sqlite3_exec(db, "BEGIN"); + _ = sqlite3_exec(db, "BEGIN"); _dbConnections.Add(new ConPoolEntry(db, ZSTD_createDCtx(), InitBlob())); } @@ -86,11 +86,11 @@ INSERT OR REPLACE INTO """, out var stmt); CheckThrowSqliteErr(db, err); - sqlite3_bind_int(stmt, 1, Environment.ProcessId); - sqlite3_bind_text(stmt, 2, Process.GetCurrentProcess().MainModule?.FileName ?? ""); - sqlite3_bind_int64(stmt, 3, contentVersion); + _ = sqlite3_bind_int(stmt, 1, Environment.ProcessId); + _ = sqlite3_bind_text(stmt, 2, Process.GetCurrentProcess().MainModule?.FileName ?? ""); + _ = sqlite3_bind_int64(stmt, 3, contentVersion); - sqlite3_step(stmt); + _ = sqlite3_step(stmt); stmt.Dispose(); } @@ -98,7 +98,7 @@ INSERT OR REPLACE INTO private static void ClearRunningClient(sqlite3 db) { // Ensure we are not in a transaction anymore. - sqlite3_exec(db, "ROLLBACK"); + _ = sqlite3_exec(db, "ROLLBACK"); var err = sqlite3_prepare_v2( db, @@ -108,9 +108,9 @@ DELETE FROM RunningClient """, out var stmt); CheckThrowSqliteErr(db, err); - sqlite3_bind_int(stmt, 1, Environment.ProcessId); + _ = sqlite3_bind_int(stmt, 1, Environment.ProcessId); - sqlite3_step(stmt); + _ = sqlite3_step(stmt); stmt.Dispose(); } @@ -128,7 +128,7 @@ private void LoadManifest(long version, sqlite3 db, out long initBlob) out var stmt); CheckThrowSqliteErr(db, err); - sqlite3_bind_int64(stmt, 1, version); + _ = sqlite3_bind_int64(stmt, 1, version); while ((err = sqlite3_step(stmt)) == SQLITE_ROW) { @@ -257,7 +257,7 @@ public bool TryOpen(string path, [NotNullWhen(true)] out Stream? stream) if (entry != null) _dbConnections.Add(entry); - _dbConnectionsSemaphore.Release(); + _ = _dbConnectionsSemaphore.Release(); } } @@ -302,7 +302,7 @@ private static unsafe void ReadBlobZStd(Span into, sqlite3_blob blob, sqli } finally { - ZSTD_DCtx_reset(context, ZSTD_ResetDirective.ZSTD_reset_session_only); + _ = ZSTD_DCtx_reset(context, ZSTD_ResetDirective.ZSTD_reset_session_only); ArrayPool.Shared.Return(buffer); } } @@ -323,4 +323,4 @@ public ConPoolEntry(sqlite3 connection, ZSTD_DCtx* decompressionContext, sqlite3 } public IEnumerable AllFiles => _files.Keys; -} \ No newline at end of file +} diff --git a/Robust.Loader/Program.cs b/Robust.Loader/Program.cs index 36a61c7..4ab5ce8 100644 --- a/Robust.Loader/Program.cs +++ b/Robust.Loader/Program.cs @@ -145,7 +145,7 @@ private bool TryOpenAssemblyStream(string name, [NotNullWhen(true)] out Stream? if (!_fileApi.TryOpen($"{name}.dll", out asm)) return false; - _fileApi.TryOpen($"{name}.pdb", out pdb); + _ = _fileApi.TryOpen($"{name}.pdb", out pdb); return true; } diff --git a/Robust.Loader/RedialApi.cs b/Robust.Loader/RedialApi.cs index b2d155a..16d7376 100644 --- a/Robust.Loader/RedialApi.cs +++ b/Robust.Loader/RedialApi.cs @@ -61,6 +61,6 @@ public void Redial(Uri uri, string text = "") startInfo.EnvironmentVariables.Remove(envVar); } - Process.Start(startInfo); + _ = Process.Start(startInfo); } } diff --git a/Robust.Loader/Robust.Loader.csproj b/Robust.Loader/Robust.Loader.csproj index d2042cb..e86ea72 100644 --- a/Robust.Loader/Robust.Loader.csproj +++ b/Robust.Loader/Robust.Loader.csproj @@ -4,6 +4,7 @@ WinExe enable true + true @@ -20,7 +21,7 @@ - + diff --git a/Starlight.Launcher.Analyzer/InjectInitStyleAnalyzer.cs b/Starlight.Launcher.Analyzer/InjectInitStyleAnalyzer.cs index b3c858c..705ea60 100644 --- a/Starlight.Launcher.Analyzer/InjectInitStyleAnalyzer.cs +++ b/Starlight.Launcher.Analyzer/InjectInitStyleAnalyzer.cs @@ -20,7 +20,7 @@ public sealed class InjectStyleAnalyzer : DiagnosticAnalyzer DiagnosticId, title: "[Inject] property style", #pragma warning disable RS1032 - messageFormat: "The [Inject] property '{0}' must be 'private ... = default!;'", + messageFormat: "The [Inject] property '{0}' must be private or protected and should have 'default!' as value!", #pragma warning restore RS1032 category: "Style", defaultSeverity: DiagnosticSeverity.Warning, diff --git a/Starlight.Launcher.Analyzer/InjectStyle.cs b/Starlight.Launcher.Analyzer/InjectStyle.cs index f5b765a..fc95c11 100644 --- a/Starlight.Launcher.Analyzer/InjectStyle.cs +++ b/Starlight.Launcher.Analyzer/InjectStyle.cs @@ -10,18 +10,16 @@ internal static class InjectStyle { public static bool IsCanonical(PropertyDeclarationSyntax p) { - var onlyPrivate = - p.Modifiers.Any(SyntaxKind.PrivateKeyword) && - !p.Modifiers.Any(SyntaxKind.PublicKeyword) && - !p.Modifiers.Any(SyntaxKind.ProtectedKeyword) && - !p.Modifiers.Any(SyntaxKind.InternalKeyword); + var onlyPrivateOrInternal = + (p.Modifiers.Any(SyntaxKind.PrivateKeyword) || p.Modifiers.Any(SyntaxKind.ProtectedKeyword)) + && !p.Modifiers.Any(SyntaxKind.PublicKeyword) && !p.Modifiers.Any(SyntaxKind.InternalKeyword); var defaultBang = p.Initializer?.Value is PostfixUnaryExpressionSyntax s && s.IsKind(SyntaxKind.SuppressNullableWarningExpression) && s.Operand.IsKind(SyntaxKind.DefaultLiteralExpression); - return onlyPrivate && defaultBang; + return onlyPrivateOrInternal && defaultBang; } public static PropertyDeclarationSyntax Canonicalize(PropertyDeclarationSyntax p) diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.Commands.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.Commands.cs new file mode 100644 index 0000000..593a814 --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.Commands.cs @@ -0,0 +1,6 @@ +namespace Starlight.Launcher.WebUI.Bridge; + +public partial interface IBridge +{ + event Func? ConnectRequested; +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.Connector.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.Connector.cs new file mode 100644 index 0000000..4467f0b --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.Connector.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using Robust.Launcher.Api.Models; +using Starlight.Launcher.WebUI.Models.Connector; +using Starlight.Launcher.WebUI.Models.Helpers; + +namespace Starlight.Launcher.WebUI.Bridge; + +/// +/// Bridged parts from Connector.cs +/// +public partial interface IBridge +{ + ConnectionStatus GetConnectionStatus(); + + ServerPrivacyPolicyInfo? GetPrivacyPolicyInfo(); + + bool GetPrivacyPolicyDifferentVersion(); + + bool IsClientExitedBadly(); + + void LaunchContentBundle(IFileResult file, CancellationToken cancel = default); + + void Connect(string address, CancellationToken cancel = default); + + void ConfirmPrivacyPolicy(PrivacyPolicyAcceptResult result); + + event PropertyChangedEventHandler? ConnectionPropertyChanged; +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.DiscordAuthService.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.DiscordAuthService.cs new file mode 100644 index 0000000..7ed4a88 --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.DiscordAuthService.cs @@ -0,0 +1,10 @@ +using Starlight.Launcher.WebUI.Models.Auth; + +namespace Starlight.Launcher.WebUI.Bridge; + +public partial interface IBridge +{ + Task LoginAsync(CancellationToken cancel = default); + + Task AttachToAccountAsync(LoggedInAccount account, CancellationToken cancel = default); +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.DiscordRichPresence.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.DiscordRichPresence.cs new file mode 100644 index 0000000..9a10ab0 --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.DiscordRichPresence.cs @@ -0,0 +1,8 @@ +using Starlight.Launcher.WebUI.Models.DiscordRichPresence; + +namespace Starlight.Launcher.WebUI.Bridge; + +public partial interface IBridge +{ + void UpdatePresence(PresenceState state, string? serverName = null); +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.HubServerFetcher.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.HubServerFetcher.cs new file mode 100644 index 0000000..f40e8b6 --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.HubServerFetcher.cs @@ -0,0 +1,18 @@ +using Robust.Launcher.Api.Models.ServerStatus; +using Starlight.Launcher.WebUI.Models.HubServerFetcher; + +namespace Starlight.Launcher.WebUI.Bridge; + +public partial interface IBridge +{ + event Action? ServersChanged; + event Action? StatusChanged; + + RefreshListStatus GetFetchStatus(); + + void UpdateInfoFor(ServerStatusData statusData); + + void RequestRefresh(); + + IReadOnlyList GetAllServers(); +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.LauncherUpdater.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.LauncherUpdater.cs new file mode 100644 index 0000000..e99fb3f --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.LauncherUpdater.cs @@ -0,0 +1,27 @@ +using Starlight.Launcher.WebUI.Models.LauncherUpdater; + +namespace Starlight.Launcher.WebUI.Bridge; + +/// +/// Bridged parts from LauncherUpdater.cs +/// +public partial interface IBridge +{ + event Action<(long downloaded, long total)>? DownloadProgress; + + Task IsUpdateAvailable(); + + Task DownloadAsset(ReleaseAsset asset, CancellationToken ct = default); + + void RunInstallerAndExit(string downloadedPath); + + void CleanupOldInstallers(); + + bool ShouldShowChangelog(); + + Task> GetChangelogsToShow(); + + void MarkChangelogSeen(); + + string GetVersion(); +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.LoginManager.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.LoginManager.cs new file mode 100644 index 0000000..251d1a3 --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.LoginManager.cs @@ -0,0 +1,26 @@ +using System.Collections.ObjectModel; +using Robust.Launcher.Api.Models.Data; +using Starlight.Launcher.WebUI.Models.Auth; + +namespace Starlight.Launcher.WebUI.Bridge; + +public partial interface IBridge +{ + event Action? LoginEntriesChanged; + + Guid? GetActiveAccountId(); + + void SetActiveAccountId(Guid? activeAccountId); + + LoggedInAccount? GetActiveAccount(); + + ReadOnlyObservableCollection GetLoginEntries(); + + Task UpdateSingleAccountStatus(LoggedInAccount account); + + void RemoveLogin(Guid userId); + + void LinkAuthToken(Guid oldUserID, Guid newUserId, LoginInfo authLogin); + + void AddFreshLogin(LoginInfo info); +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.ServerInfoLoader.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.ServerInfoLoader.cs new file mode 100644 index 0000000..0f523fa --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.ServerInfoLoader.cs @@ -0,0 +1,8 @@ +using Robust.Launcher.Api.Models.ServerStatus; + +namespace Starlight.Launcher.WebUI.Bridge; + +public partial interface IBridge +{ + void Request(ServerStatusData? data); +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.Settings.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.Settings.cs new file mode 100644 index 0000000..5f830f4 --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.Settings.cs @@ -0,0 +1,37 @@ +using Robust.Launcher.Api.Models.Data; +using Starlight.Launcher.WebUI.Models.Data; +using Starlight.Launcher.WebUI.Models.ServerStatus; +using Starlight.Launcher.WebUI.Models.Settings; + +namespace Starlight.Launcher.WebUI.Bridge; + +public partial interface IBridge +{ + event Action? FavoritesChanged; + + event Action? LoginsUnrecoverable; + + IReadOnlySet GetFavoriteAddressesSnapshot(); + + AppSettings GetSettings(); + + Task GetSettingsAsync(); + + List GetFavorites(); + + Task> GetFavoritesAsync(); + + Dictionary GetLogins(); + + Task> GetLoginsAsync(); + + void WriteSettings(AppSettings settings); + + Task WriteSettingsAsync(AppSettings settings); + + Task WriteFavoritesAsync(List favorites); + + void WriteLogins(Dictionary logins); + + Task CacheFilters(ServerListFilters filters); +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.Tray.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.Tray.cs new file mode 100644 index 0000000..2f6b9fe --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.Tray.cs @@ -0,0 +1,6 @@ +namespace Starlight.Launcher.WebUI.Bridge; + +public partial interface IBridge +{ + void HideWindow(); +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.Updater.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.Updater.cs new file mode 100644 index 0000000..ebf9e85 --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.Updater.cs @@ -0,0 +1,14 @@ +using Starlight.Launcher.WebUI.Models.Updater; + +namespace Starlight.Launcher.WebUI.Bridge; + +/// +/// Bridged parts from Updater.cs +/// +public partial interface IBridge +{ + /// Returns value from Updater.Progress/> + (long downloaded, long total, ProgressUnit unit)? GetUpdateProgress(); + + UpdateStatus GetUpdateStatus(); +} diff --git a/Starlight.Launcher.WebUI/Bridge/IBridge.cs b/Starlight.Launcher.WebUI/Bridge/IBridge.cs new file mode 100644 index 0000000..4ecd8ec --- /dev/null +++ b/Starlight.Launcher.WebUI/Bridge/IBridge.cs @@ -0,0 +1,12 @@ +using Starlight.Launcher.WebUI.Models.Helpers; + +namespace Starlight.Launcher.WebUI.Bridge; + +public partial interface IBridge +{ + void OpenBrowser(string url); + + Task PickFileAsync( + string filter = "Content bundles / replays\0*.zip;*.rt\0All Files\0*.*\0\0", + CancellationToken cancel = default); +} diff --git a/Starlight.Launcher.WebUI/Components/App.razor b/Starlight.Launcher.WebUI/Components/App.razor new file mode 100644 index 0000000..586c5e5 --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/App.razor @@ -0,0 +1,49 @@ + + + + + + + Starlight.Launcher + + + + + + + + + + + + + + + + +
+ +
+
+
Starlight
+
+
Loading…
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + + diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/AccountListView.razor b/Starlight.Launcher.WebUI/Components/Atoms/Auth/AccountListView.razor new file mode 100644 index 0000000..d919d0e --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/AccountListView.razor @@ -0,0 +1,136 @@ +@using Robust.Launcher.Api.Models +@using Starlight.Launcher.WebUI.Models.Auth; +@using Starlight.Launcher.WebUI.Localization; + +@inherits LocalizedComponentBase + + @L["auth-menu-accounts-mode"] + +@if (_bridge.GetLoginEntries().Count == 0) +{ + + + @L["auth-menu-no-accounts"] + + @L["auth-menu-no-accounts-hint"] + + +} +else +{ + + @foreach (var account in _bridge.GetLoginEntries()) + { + var acc = account; + var isActive = _bridge.GetActiveAccountId() == acc.UserId; + + + + + + + + + + @acc.LoginInfo.Username + + @if (isActive) + { + + @L["auth-menu-active-chip"] + + } + @if (acc.LoginInfo.Token != null) + { + + + + } + else + { + + + + } + @if (acc.LoginInfo.DiscordToken != null) + { + + + + } + + + + + + @StatusLabel(acc.Status) + + + + + @if (acc.Status == AccountLoginStatus.Expired) + { + + @L["auth-menu-login-again-button"] + + } + else + { + + @L["auth-menu-select-button"] + + } + + + @if (acc.Status != AccountLoginStatus.Expired + && acc.LoginInfo.Token != null + && acc.LoginInfo.DiscordToken == null) + { + + @L["auth-menu-link-discord-button"] + + } + @if (acc.Status != AccountLoginStatus.Expired && acc.LoginInfo.Token == null && acc.LoginInfo.DiscordToken != null) + { + + @L["auth-menu-link-account-button"] + + } + + @L["auth-menu-delete-account-button"] + + + + + + } + +} + + + @L["auth-menu-add-account-button"] + diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/AccountListView.razor.cs b/Starlight.Launcher.WebUI/Components/Atoms/Auth/AccountListView.razor.cs new file mode 100644 index 0000000..c6d6af5 --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/AccountListView.razor.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Components; +using MudBlazor; +using Robust.Launcher.Api.Api; +using Robust.Launcher.Api.Models; +using Starlight.Launcher.WebUI.Bridge; +using Starlight.Launcher.WebUI.Localization; +using Starlight.Launcher.WebUI.Models.Auth; + +namespace Starlight.Launcher.WebUI.Components.Atoms.Auth; + +public partial class AccountListView : LocalizedComponentBase +{ + [Parameter, EditorRequired] public Action? OnAccountRelogin { get; set; } + [Parameter, EditorRequired] public Action? OnDiscordLink { get; set; } + [Parameter, EditorRequired] public Action? OnLink { get; set; } + [Parameter, EditorRequired] public Action? OnSignIn { get; set; } + [Inject] private IBridge _bridge { get; set; } = default!; + [Inject] private ISnackbar _snackbar { get; set; } = default!; + + private bool Busy; + + private static string StatusCssVar(AccountLoginStatus s) => s switch + { + AccountLoginStatus.Available => "success", + AccountLoginStatus.Expired => "warning", + AccountLoginStatus.Unsure => "info", + _ => "surface" + }; + + private string StatusLabel(AccountLoginStatus s) => s switch + { + AccountLoginStatus.Available => L["auth-menu-online-status"], + AccountLoginStatus.Expired => L["auth-menu-expired-status"], + AccountLoginStatus.Unsure => L["auth-menu-unsure-status"], + _ => s.ToString() + }; + + private void RemoveAccount(LoggedInAccount account) + { + _bridge.RemoveLogin(account.UserId); + _ = _snackbar.Add(L.GetString("auth-menu-account-deleted", ("account", account.LoginInfo.Username)), Severity.Info); + } + + private async Task SelectAccount(LoggedInAccount account) + { + Busy = true; + await InvokeAsync(StateHasChanged); + try + { + try + { + await _bridge.UpdateSingleAccountStatus(account); + } + catch (AuthApiException ex) + { + _ = _snackbar.Add(L.GetString("auth-menu-token-verify-warning", ("ex", ex.Message)), Severity.Warning); + } + + if (account.Status == AccountLoginStatus.Expired) + { + _ = _snackbar.Add(L["auth-menu-session-expired-warning"], Severity.Warning); + OnAccountRelogin?.Invoke(account); + return; + } + + _bridge.SetActiveAccountId(account.UserId); + } + finally + { + Busy = false; + await InvokeAsync(StateHasChanged); + } + } +} diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/AccountListView.razor.css b/Starlight.Launcher.WebUI/Components/Atoms/Auth/AccountListView.razor.css new file mode 100644 index 0000000..e067b2d --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/AccountListView.razor.css @@ -0,0 +1,89 @@ +.empty-state { + position: relative; + text-align: center; +} + + .empty-state::before { + content: ""; + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); + width: 140px; + height: 140px; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--starlight-accent-1) 28%, transparent), color-mix(in srgb, var(--starlight-accent-2) 12%, transparent) 55%, transparent 75%); + filter: blur(4px); + z-index: 0; + } + +.empty-icon { + position: relative; + z-index: 1; + font-size: 4rem !important; + opacity: .65; + filter: drop-shadow(0 0 14px color-mix(in srgb, var(--starlight-accent-1) 35%, transparent)); +} + +.account-row { + position: relative; + padding: 14px !important; + margin-bottom: 10px !important; + border-radius: 16px; + border: 1px solid var(--mud-palette-lines-inputs, rgba(255,255,255,.07)); + background: linear-gradient(180deg, color-mix(in srgb, var(--mud-palette-text-primary) 4.5%, transparent), color-mix(in srgb, var(--mud-palette-text-primary) 1.5%, transparent)); + overflow: hidden; + transition: background .18s ease, border-color .18s ease, transform .18s ease, box-shadow .18s ease; +} + + .account-row:hover { + background: linear-gradient(180deg, color-mix(in srgb, var(--mud-palette-text-primary) 7.5%, transparent), color-mix(in srgb, var(--mud-palette-text-primary) 2.5%, transparent)); + border-color: color-mix(in srgb, var(--starlight-accent-1) 35%, transparent); + transform: translateY(-2px); + box-shadow: 0 10px 26px -12px color-mix(in srgb, var(--starlight-accent-1) 35%, transparent), 0 0 0 1px color-mix(in srgb, var(--mud-palette-text-primary) 5%, transparent) inset; + } + + .account-row:last-child { + margin-bottom: 0 !important; + } + +.account-row-active { + border-color: color-mix(in srgb, var(--starlight-accent-1) 50%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--starlight-accent-1) 10%, transparent), color-mix(in srgb, var(--starlight-accent-2) 4%, transparent)); +} + + .account-row-active::before { + content: ""; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 3px; + border-radius: 3px; + background: linear-gradient(180deg, var(--starlight-accent-1), var(--starlight-accent-2)); + box-shadow: 0 0 10px 1px color-mix(in srgb, var(--starlight-accent-1) 60%, transparent); + } + +.status-dot { + width: 9px; + height: 9px; + border-radius: 50%; + display: inline-block; + flex: 0 0 auto; + transition: box-shadow .2s ease; +} + +.account-avatar { + background: linear-gradient(135deg, var(--starlight-accent-1), var(--starlight-accent-2)) !important; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--mud-palette-text-primary) 8%, transparent), 0 4px 14px -4px color-mix(in srgb, var(--starlight-accent-1) 55%, transparent); + font-weight: 600; +} + +.row-menu-btn { + border-radius: 10px; + transition: background .15s ease; +} + + .row-menu-btn:hover { + background: color-mix(in srgb, var(--mud-palette-text-primary) 7%, transparent); + } diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/ForgotPasswordView.razor b/Starlight.Launcher.WebUI/Components/Atoms/Auth/ForgotPasswordView.razor new file mode 100644 index 0000000..42489ca --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/ForgotPasswordView.razor @@ -0,0 +1,57 @@ +@using Starlight.Launcher.WebUI.Components.Pages +@using Starlight.Launcher.WebUI.Localization; + +@inherits LocalizedComponentBase + + + + @L["auth-menu-forgotpassword"] + + +@if (Success) +{ + + @L["auth-menu-forgotpassword-alert"] + + + @L["auth-menu-signin"] + + return; +} + +@L["auth-menu-enter-email-label"] + + + +@if (!string.IsNullOrEmpty(Error)) +{ + @Error +} + + + @if (Busy) + { + + @L["auth-menu-forgotpassword-status-busy"] + } + else + { + @L["auth-menu-forgotpassword-status-wait"] + } + diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/ForgotPasswordView.razor.cs b/Starlight.Launcher.WebUI/Components/Atoms/Auth/ForgotPasswordView.razor.cs new file mode 100644 index 0000000..70e688a --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/ForgotPasswordView.razor.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Components; +using Robust.Launcher.Api.Api; +using Robust.Launcher.Api.Utility; +using Starlight.Launcher.WebUI.Bridge; +using Starlight.Launcher.WebUI.Components.Pages; +using Starlight.Launcher.WebUI.Localization; + +namespace Starlight.Launcher.WebUI.Components.Atoms.Auth; + +public partial class ForgotPasswordView : LocalizedComponentBase +{ + [Parameter, EditorRequired] public Action? OnModeSwitch { get; set; } + + [Inject] private AuthApi _authApi { get; set; } = default!; + [Inject] private IBridge _bridge { get; set; } = default!; + + private bool Busy; + private string Email = ""; + private string? Error; + private bool Success; + + private async Task DoForgotPassword() + { + Error = null; + + if ((await _bridge.GetSettingsAsync()).SelectedAuthServer is not { } authServer) + { + Error = L["auth-menu-no-server-error"]; + return; + } + + if (string.IsNullOrWhiteSpace(Email) || !Email.Contains('@')) + { + Error = L["auth-menu-forgot-notvalid-email-error"]; + return; + } + + Busy = true; + await InvokeAsync(StateHasChanged); + try + { + var errors = await _authApi.ForgotPasswordAsync(Email, new UrlFallbackSet(authServer)); + if (errors == null) + Success = true; + else + Error = string.Join("\n", errors); + } + finally + { + Busy = false; + await InvokeAsync(StateHasChanged); + } + } +} diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/ForgotPasswordView.razor.css b/Starlight.Launcher.WebUI/Components/Atoms/Auth/ForgotPasswordView.razor.css new file mode 100644 index 0000000..db2f91b --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/ForgotPasswordView.razor.css @@ -0,0 +1,15 @@ +.success-alert { + animation: success-pop .3s ease; +} + +@keyframes success-pop { + from { + opacity: 0; + transform: translateY(6px) scale(.98); + } + + to { + opacity: 1; + transform: none; + } +} diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/LinkAccountView.razor b/Starlight.Launcher.WebUI/Components/Atoms/Auth/LinkAccountView.razor new file mode 100644 index 0000000..1941282 --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/LinkAccountView.razor @@ -0,0 +1,50 @@ +@using Starlight.Launcher.WebUI.Components.Pages +@using Starlight.Launcher.WebUI.Localization; + +@inherits LocalizedComponentBase + + + + @L["auth-menu-link-account-title"] + + + + @L["auth-menu-link-account-hint"] + + + + + + +@if (TfaRequired) +{ + +} + +@if (!string.IsNullOrEmpty(Error)) +{ + @Error +} + + + @if (Busy) + { + + @L["auth-menu-link-account-busy"] + } + else + { + @L["auth-menu-link-account-confirm"] + } + diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/LinkAccountView.razor.cs b/Starlight.Launcher.WebUI/Components/Atoms/Auth/LinkAccountView.razor.cs new file mode 100644 index 0000000..b35c0e8 --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/LinkAccountView.razor.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using MudBlazor; +using Robust.Launcher.Api.Api; +using Robust.Launcher.Api.Utility; +using Starlight.Launcher.WebUI.Bridge; +using Starlight.Launcher.WebUI.Components.Pages; +using Starlight.Launcher.WebUI.Localization; + +namespace Starlight.Launcher.WebUI.Components.Atoms.Auth; + +public partial class LinkAccountView : LocalizedComponentBase +{ + [Parameter, EditorRequired] public Action? OnModeSwitch { get; set; } + [Parameter, EditorRequired] public string Username { get; set; } + [Parameter, EditorRequired] public Guid? UserId { get; set; } + + [Inject] private IBridge _bridge { get; set; } = default!; + [Inject] private AuthApi _authApi { get; set; } = default!; + [Inject] private ISnackbar _snackbar { get; set; } = default!; + + private bool Busy; + private string Password = ""; + private string TfaCode = ""; + private bool TfaRequired; + + private string? Error; + + private async Task OnLinkKey(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !Busy) + await DoLinkAccount(); + } + + private async Task DoLinkAccount() + { + Error = null; + + if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrEmpty(Password)) + { + Error = L["auth-menu-enter-info-error"]; + return; + } + + if ((await _bridge.GetSettingsAsync()).SelectedAuthServer is not { } authServer) + { + Error = L["auth-menu-no-server-error"]; + return; + } + + Busy = true; + await InvokeAsync(StateHasChanged); + try + { + var request = new AuthApi.AuthenticateRequest( + Username, null, Password, + TfaRequired ? TfaCode : null); + + var result = await _authApi.AuthenticateAsync(request, new UrlFallbackSet(authServer)); + + if (result.IsSuccess && UserId != null) + { + _bridge.LinkAuthToken(UserId.Value, result.LoginInfo.UserId, result.LoginInfo); + + _ = _snackbar.Add(L.GetString("auth-menu-account-linked", ("account", result.LoginInfo.Username)), Severity.Success); + + OnModeSwitch?.Invoke(Mode.AccountList); + return; + } + + switch (result.Code) + { + case AuthApi.AuthenticateDenyResponseCode.InvalidCredentials: + Error = L["auth-menu-incorrect-info-error"]; + break; + case AuthApi.AuthenticateDenyResponseCode.TfaRequired: + TfaRequired = true; + Error = L["auth-menu-tfa-required-error"]; + break; + case AuthApi.AuthenticateDenyResponseCode.TfaInvalid: + TfaRequired = true; + Error = L["auth-menu-tfa-invalid-error"]; + break; + case AuthApi.AuthenticateDenyResponseCode.AccountLocked: + Error = L["auth-menu-account-blocked-error"]; + break; + default: + Error = string.Join("\n", result.Errors); + break; + } + } + finally + { + Busy = false; + await InvokeAsync(StateHasChanged); + } + } +} diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/SignInView.razor b/Starlight.Launcher.WebUI/Components/Atoms/Auth/SignInView.razor new file mode 100644 index 0000000..cfaa138 --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/SignInView.razor @@ -0,0 +1,105 @@ +@using Starlight.Launcher.WebUI.Components.Pages +@using Starlight.Launcher.WebUI.Localization; + +@inherits LocalizedComponentBase + + + @if (_bridge.GetLoginEntries().Count > 0) + { + + } + + + @(RelogUserId.HasValue ? L["auth-menu-signin-login-again"] : L["auth-menu-signin-login"]) + + + + + + + +@if (TfaRequired) +{ + +} + +@if (!string.IsNullOrEmpty(Error)) +{ + @Error +} + +@if (ShowResend) +{ + + @L["auth-menu-resend-confirmation"] + +} + + + @if (Busy) + { + + @L["auth-menu-signin-status-busy"] + } + else + { + @L["auth-menu-signin-status-wait"] + } + + + + + @L["auth-menu-signin-method-discord"] + + + + + @L["auth-menu-signin-forgot-password"] + + + @L["auth-menu-registration"] + + diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/SignInView.razor.cs b/Starlight.Launcher.WebUI/Components/Atoms/Auth/SignInView.razor.cs new file mode 100644 index 0000000..addbd3a --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/SignInView.razor.cs @@ -0,0 +1,183 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using MudBlazor; +using Robust.Launcher.Api.Api; +using Robust.Launcher.Api.Utility; +using Serilog; +using Starlight.Launcher.WebUI.Bridge; +using Starlight.Launcher.WebUI.Components.Pages; +using Starlight.Launcher.WebUI.Localization; +using Starlight.Launcher.WebUI.Models.DiscordAuthService; + +namespace Starlight.Launcher.WebUI.Components.Atoms.Auth; + +public partial class SignInView : LocalizedComponentBase +{ + [Parameter, EditorRequired] public Action? OnModeSwitch { get; set; } + [Parameter] public Guid? RelogUserId { get; set; } + [Parameter] public string Username { get; set; } = ""; + + [Inject] private AuthApi _authApi { get; set; } = default!; + [Inject] private ISnackbar _snackbar { get; set; } = default!; + + private bool Busy; + + private string Password = ""; + private string TfaCode = ""; + private bool TfaRequired; + private string? Error; + private bool ShowResend; + private bool Pwd; + [Inject] private IBridge _bridge { get; set; } = default!; + + private async Task OnSignInKey(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !Busy) + await DoSignIn(); + } + + private async Task DoSignIn() + { + Error = null; + ShowResend = false; + + if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrEmpty(Password)) + { + Error = L["auth-menu-enter-info-error"]; + return; + } + + if ((await _bridge.GetSettingsAsync()).SelectedAuthServer is not { } authServer) + { + Error = L["auth-menu-no-server-error"]; + return; + } + + Busy = true; + await InvokeAsync(StateHasChanged); + try + { + AuthApi.AuthenticateRequest request; + if (RelogUserId.HasValue) + { + request = new AuthApi.AuthenticateRequest( + null, RelogUserId.Value, Password, + TfaRequired ? TfaCode : null); + } + else + { + request = new AuthApi.AuthenticateRequest( + Username, null, Password, + TfaRequired ? TfaCode : null); + } + + var result = await _authApi.AuthenticateAsync(request, new UrlFallbackSet(authServer)); + + if (result.IsSuccess) + { + _bridge.AddFreshLogin(result.LoginInfo); + _bridge.SetActiveAccountId(result.LoginInfo.UserId); + _ = _snackbar.Add(L.GetString("auth-menu-welcome-message", ("username", result.LoginInfo.Username)), Severity.Success); + OnModeSwitch?.Invoke(Mode.AccountList); + return; + } + + switch (result.Code) + { + case AuthApi.AuthenticateDenyResponseCode.InvalidCredentials: + Error = L["auth-menu-incorrect-info-error"]; + break; + + case AuthApi.AuthenticateDenyResponseCode.AccountUnconfirmed: + Error = L["auth-menu-unconfirmed-info-error"]; + ShowResend = true; + break; + + case AuthApi.AuthenticateDenyResponseCode.TfaRequired: + TfaRequired = true; + Error = L["auth-menu-tfa-required-error"]; + break; + + case AuthApi.AuthenticateDenyResponseCode.TfaInvalid: + TfaRequired = true; + Error = L["auth-menu-tfa-invalid-error"]; + break; + + case AuthApi.AuthenticateDenyResponseCode.AccountLocked: + Error = L["auth-menu-account-blocked-error"]; + break; + + default: + Error = string.Join("\n", result.Errors); + break; + } + } + finally + { + Busy = false; + await InvokeAsync(StateHasChanged); + } + } + + private async Task LoginWithDiscord() + { + Busy = true; + Error = null; + await InvokeAsync(StateHasChanged); + try + { + _ = await _bridge.LoginAsync(); + OnModeSwitch?.Invoke(Mode.AccountList); + } + catch (OperationCanceledException) + { + Error = L["auth-menu-discord-login-error"]; + } + catch (DiscordAuthException ex) + { + Error = ex.Message; + } + catch (Exception ex) + { + Log.Warning(ex, "Discord login failed"); + Error = L["auth-menu-discord-connect-fail"]; + } + finally + { + Busy = false; + await InvokeAsync(StateHasChanged); + } + } + + private async Task ResendConfirmation() + { + if ((await _bridge.GetSettingsAsync()).SelectedAuthServer is not { } authServer) + { + _ = _snackbar.Add(L["auth-menu-no-server-error"], Severity.Error); + return; + } + + Busy = true; + await InvokeAsync(StateHasChanged); + try + { + if (Username.Contains('@')) + { + var errors = await _authApi.ResendConfirmationAsync(Username, new UrlFallbackSet(authServer)); + if (errors == null) + _ = _snackbar.Add(L.GetString("auth-menu-email-resent"), Severity.Success); + else + _ = _snackbar.Add(string.Join("\n", errors), Severity.Error); + } + else + { + _ = _snackbar.Add(L["auth-menu-email-resent-info"], Severity.Warning); + } + } + finally + { + Busy = false; + await InvokeAsync(StateHasChanged); + } + } +} diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Auth/SignInView.razor.css b/Starlight.Launcher.WebUI/Components/Atoms/Auth/SignInView.razor.css new file mode 100644 index 0000000..6f42e82 --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Auth/SignInView.razor.css @@ -0,0 +1,13 @@ +.discord-btn { + border-color: rgba(88,101,242,.35) !important; + color: #C7C9FB !important; + background: rgba(88,101,242,.06); + transition: background .18s ease, border-color .18s ease, box-shadow .18s ease, transform .15s ease; +} + + .discord-btn:hover:not(.mud-disabled) { + background: rgba(88,101,242,.16); + border-color: rgba(88,101,242,.65); + box-shadow: 0 8px 20px -10px rgba(88,101,242,.5); + transform: translateY(-1px); + } diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ChangelogDialog.razor b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ChangelogDialog.razor new file mode 100644 index 0000000..f6ee4ec --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ChangelogDialog.razor @@ -0,0 +1,82 @@ +@using Starlight.Launcher.WebUI.Localization +@inherits LocalizedComponentBase + + + + + + @L["changelog-title"] + + + + @if (Entries.Count == 0) + { + @L["changelog-empty"] + } + else + { + + + @for (var i = 0; i < Entries.Count; i++) + { + var entry = Entries[i]; + var isLatest = i == 0; + + + + + + v@(entry.Version) + + @if (isLatest) + { + + @L["changelog-latest"] + + } + + + + @if (string.IsNullOrWhiteSpace(entry.Notes)) + { + @L["changelog-empty"] + } + else + { +
+ +
+ } +
+
+ } +
+
+ } +
+ + + @L["changelog-close"] + + +
+ + diff --git a/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ChangelogDialog.razor.cs b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ChangelogDialog.razor.cs new file mode 100644 index 0000000..5494e24 --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ChangelogDialog.razor.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Components; +using MudBlazor; +using Starlight.Launcher.WebUI.Models.LauncherUpdater; + +namespace Starlight.Launcher.WebUI.Components.Atoms.Dialogs; + +public partial class ChangelogDialog +{ + [CascadingParameter] private IMudDialogInstance _mudDialog { get; set; } = default!; + + [Parameter] public IReadOnlyList Entries { get; set; } = Array.Empty(); + + private void Close() => _mudDialog.Close(); +} diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialog.razor b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialog.razor similarity index 85% rename from Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialog.razor rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialog.razor index a652b28..6d44e84 100644 --- a/Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialog.razor +++ b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialog.razor @@ -1,5 +1,7 @@ -@using Starlight.Launcher.Services -@using static Starlight.Launcher.Services.Connector +@using Starlight.Launcher.WebUI.Models.Connector +@using Starlight.Launcher.WebUI.Localization + +@inherits LocalizedComponentBase @@ -10,11 +12,11 @@
- @switch (_connector.Status) + @switch (_bridge.GetConnectionStatus()) { case ConnectionStatus.AwaitingPrivacyPolicyAcceptance: This server requires you to accept its privacy policy before connecting. - @if (_connector.PrivacyPolicyInfo is { } pp) + @if (_bridge.GetPrivacyPolicyInfo() is { } pp) {
@@ -31,7 +33,7 @@ } } - @if (_connector.PrivacyPolicyDifferentVersion) + @if (_bridge.GetPrivacyPolicyDifferentVersion()) { The policy has changed since you last accepted it. @@ -45,7 +47,7 @@ @_statusText break; - case ConnectionStatus.ClientExited when _connector.ClientExitedBadly: + case ConnectionStatus.ClientExited when _bridge.IsClientExitedBadly(): The game closed unexpectedly. break; @@ -76,7 +78,7 @@ }
- @if (_isByteProgress && _updater.Progress is { total: > 0 } p) + @if (_isByteProgress && _bridge.GetUpdateProgress() is { total: > 0 } p) {
@Format(p.downloaded) / @Format(p.total) @@ -94,7 +96,7 @@
- @if (_connector.Status == ConnectionStatus.AwaitingPrivacyPolicyAcceptance) + @if (_bridge.GetConnectionStatus() == ConnectionStatus.AwaitingPrivacyPolicyAcceptance) { Decline @_localization["general-close"] + @L["general-close"] } else { - @_localization["general-cancel"] + @L["general-cancel"] } diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialog.razor.cs b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialog.razor.cs similarity index 73% rename from Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialog.razor.cs rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialog.razor.cs index c1d2a67..0e63981 100644 --- a/Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialog.razor.cs +++ b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialog.razor.cs @@ -2,23 +2,22 @@ using System.Globalization; using Microsoft.AspNetCore.Components; using MudBlazor; -using Starlight.Launcher.Services; -using Starlight.Launcher.Services.Localization; -using static Starlight.Launcher.Services.Connector; -using static Starlight.Launcher.Services.Updater; +using Starlight.Launcher.WebUI.Bridge; +using Starlight.Launcher.WebUI.Localization; +using Starlight.Launcher.WebUI.Models.Connector; +using Starlight.Launcher.WebUI.Models.Helpers; +using Starlight.Launcher.WebUI.Models.Updater; -namespace Starlight.Launcher.Components.Atoms.Dialogs; +namespace Starlight.Launcher.WebUI.Components.Atoms.Dialogs; -public sealed partial class ConnectingDialog : ComponentBase, IDisposable +public sealed partial class ConnectingDialog : LocalizedComponentBase, IDisposable { - [Inject] private LocalizationManager _localization { get; set; } = default!; - [Inject] private Connector _connector { get; set; } = default!; - [Inject] private Updater _updater { get; set; } = default!; + [Inject] private IBridge _bridge { get; set; } = default!; [CascadingParameter] private IMudDialogInstance _mudDialog { get; set; } = default!; [Parameter] public string Address { get; set; } = ""; - [Parameter] public FileResult? ContentBundle { get; set; } + [Parameter] public IFileResult? ContentBundle { get; set; } [Parameter] public string? Title { get; set; } = null; @@ -32,25 +31,21 @@ public sealed partial class ConnectingDialog : ComponentBase, IDisposable private bool _haveSpeed; private UpdateStatus _lastUpdateStatus; - private string _title => ContentBundle is { } f - ? $"Launching {f.FileName}" - : $"Connecting to {Title ?? Address}"; - private string _titleIcon => ContentBundle is not null ? Icons.Material.Filled.Inventory2 : Icons.Material.Filled.Dns; private double _percent => - _updater.Progress is { total: > 0 } p ? (double)p.downloaded / p.total * 100.0 : 0; + _bridge.GetUpdateProgress() is { total: > 0 } p ? (double)p.downloaded / p.total * 100.0 : 0; private string _percentCss => _percent.ToString("0.##", CultureInfo.InvariantCulture); private bool _hasDeterminateProgress => - _connector.Status == ConnectionStatus.Updating && _updater.Progress is { total: > 0 }; + _bridge.GetConnectionStatus() == ConnectionStatus.Updating && _bridge.GetUpdateProgress() is { total: > 0 }; // Only these phases report progress as real bytes — gate byte/speed display on them. private bool _isByteProgress => - _connector.Status == ConnectionStatus.Updating && _updater.Status is + _bridge.GetConnectionStatus() == ConnectionStatus.Updating && _bridge.GetUpdateStatus() is UpdateStatus.DownloadingEngineVersion or UpdateStatus.DownloadingEngineModules or UpdateStatus.DownloadingClientUpdate; @@ -60,7 +55,7 @@ private string? _eta get { if (!_haveSpeed || _smoothedBytesPerSec <= 1) return null; - if (_updater.Progress is not { total: > 0 } p) return null; + if (_bridge.GetUpdateProgress() is not { total: > 0 } p) return null; var remaining = p.total - p.downloaded; if (remaining <= 0) return null; @@ -75,17 +70,17 @@ private string? _eta } } - private bool _isTerminal => _connector.Status is + private bool _isTerminal => _bridge.GetConnectionStatus() is ConnectionStatus.ConnectionFailed or ConnectionStatus.UpdateError or ConnectionStatus.NotAContentBundle or ConnectionStatus.Cancelled or ConnectionStatus.ClientExited; - private string _statusText => _connector.Status switch + private string _statusText => _bridge.GetConnectionStatus() switch { ConnectionStatus.Connecting => "Contacting server…", - ConnectionStatus.Updating => _updater.Status switch + ConnectionStatus.Updating => _bridge.GetUpdateStatus() switch { UpdateStatus.DownloadingEngineVersion => "Downloading engine…", UpdateStatus.DownloadingEngineModules => "Downloading engine modules…", @@ -107,25 +102,25 @@ or ConnectionStatus.Cancelled protected override void OnInitialized() { - _connector.PropertyChanged += OnConnectorChanged; + _bridge.ConnectionPropertyChanged += OnConnectorChanged; _pollTimer = new Timer(_ => { SampleSpeed(); - InvokeAsync(StateHasChanged); + _ = InvokeAsync(StateHasChanged); }, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(250)); // Fire-and-forget: Status drives the UI from here on. if (ContentBundle is { } bundle) - _connector.LaunchContentBundle(bundle, _cts.Token); + _bridge.LaunchContentBundle(bundle, _cts.Token); else - _connector.Connect(Address, _cts.Token); + _bridge.Connect(Address, _cts.Token); } // Recompute speed from the displayed counter so the two can never disagree. private void SampleSpeed() { - if (!_isByteProgress || _updater.Progress is not { total: > 0 } p) + if (!_isByteProgress || _bridge.GetUpdateProgress() is not { total: > 0 } p) { _haveSpeed = false; _smoothedBytesPerSec = 0; @@ -136,9 +131,9 @@ private void SampleSpeed() var now = DateTime.UtcNow; // Reset on phase change or when the counter restarts (new download). - if (_updater.Status != _lastUpdateStatus || p.downloaded < _lastDownloaded) + if (_bridge.GetUpdateStatus() != _lastUpdateStatus || p.downloaded < _lastDownloaded) { - _lastUpdateStatus = _updater.Status; + _lastUpdateStatus = _bridge.GetUpdateStatus(); _lastDownloaded = p.downloaded; _lastSampleUtc = now; _haveSpeed = false; @@ -164,13 +159,13 @@ private void SampleSpeed() private void OnConnectorChanged(object? sender, PropertyChangedEventArgs e) => InvokeAsync(() => { - if (_connector.Status == ConnectionStatus.ClientRunning) + if (_bridge.GetConnectionStatus() == ConnectionStatus.ClientRunning) _mudDialog.Close(); StateHasChanged(); }); - private void Decide(PrivacyPolicyAcceptResult result) => _connector.ConfirmPrivacyPolicy(result); + private void Decide(PrivacyPolicyAcceptResult result) => _bridge.ConfirmPrivacyPolicy(result); private void Cancel() { @@ -193,12 +188,14 @@ private static string Format(long bytes) return $"{v:0.#} {units[i]}"; } - public void Dispose() + public override void Dispose() { - _connector.PropertyChanged -= OnConnectorChanged; + base.Dispose(); + + _bridge.ConnectionPropertyChanged -= OnConnectorChanged; _pollTimer?.Dispose(); - if (_connector.Status != ConnectionStatus.ClientRunning) + if (_bridge.GetConnectionStatus() != ConnectionStatus.ClientRunning) { if (!_cts.IsCancellationRequested) _cts.Cancel(); diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialog.razor.css b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialog.razor.css similarity index 100% rename from Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialog.razor.css rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialog.razor.css diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialogHost.razor b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialogHost.razor similarity index 77% rename from Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialogHost.razor rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialogHost.razor index 44b3fa9..2bbc1f5 100644 --- a/Starlight.Launcher/Components/Atoms/Dialogs/ConnectingDialogHost.razor +++ b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/ConnectingDialogHost.razor @@ -1,11 +1,12 @@ +@using Starlight.Launcher.WebUI.Bridge; + @implements IDisposable -@using Starlight.Launcher.Services @inject IDialogService DialogService -@inject LauncherCommands Commands +@inject IBridge Bridge @code { protected override void OnInitialized() - => Commands.ConnectRequested += OnConnectRequested; + => Bridge.ConnectRequested += OnConnectRequested; private Task OnConnectRequested(string address) => InvokeAsync(async () => { @@ -25,5 +26,5 @@ await DialogService.ShowAsync("Connecting", parameters, options); }); - public void Dispose() => Commands.ConnectRequested -= OnConnectRequested; + public void Dispose() => Bridge.ConnectRequested -= OnConnectRequested; } diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/DirectConnectDialog.razor b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/DirectConnectDialog.razor similarity index 100% rename from Starlight.Launcher/Components/Atoms/Dialogs/DirectConnectDialog.razor rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/DirectConnectDialog.razor diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/DirectConnectDialog.razor.cs b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/DirectConnectDialog.razor.cs similarity index 92% rename from Starlight.Launcher/Components/Atoms/Dialogs/DirectConnectDialog.razor.cs rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/DirectConnectDialog.razor.cs index d77a1cc..d446a2e 100644 --- a/Starlight.Launcher/Components/Atoms/Dialogs/DirectConnectDialog.razor.cs +++ b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/DirectConnectDialog.razor.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Components.Web; using MudBlazor; -namespace Starlight.Launcher.Components.Atoms.Dialogs; +namespace Starlight.Launcher.WebUI.Components.Atoms.Dialogs; public partial class DirectConnectDialog : ComponentBase { diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/LauncherUpdateDialog.razor b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LauncherUpdateDialog.razor similarity index 98% rename from Starlight.Launcher/Components/Atoms/Dialogs/LauncherUpdateDialog.razor rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LauncherUpdateDialog.razor index e95cbb5..f95f911 100644 --- a/Starlight.Launcher/Components/Atoms/Dialogs/LauncherUpdateDialog.razor +++ b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LauncherUpdateDialog.razor @@ -1,5 +1,3 @@ -@using Starlight.Launcher.Services -
diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/LauncherUpdateDialog.razor.cs b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LauncherUpdateDialog.razor.cs similarity index 88% rename from Starlight.Launcher/Components/Atoms/Dialogs/LauncherUpdateDialog.razor.cs rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LauncherUpdateDialog.razor.cs index b818588..a7e747d 100644 --- a/Starlight.Launcher/Components/Atoms/Dialogs/LauncherUpdateDialog.razor.cs +++ b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LauncherUpdateDialog.razor.cs @@ -1,16 +1,17 @@ using System.Globalization; using Microsoft.AspNetCore.Components; using MudBlazor; -using Starlight.Launcher.Services; +using Starlight.Launcher.WebUI.Bridge; +using Starlight.Launcher.WebUI.Models.LauncherUpdater; -namespace Starlight.Launcher.Components.Atoms.Dialogs; +namespace Starlight.Launcher.WebUI.Components.Atoms.Dialogs; public sealed partial class LauncherUpdateDialog : ComponentBase, IDisposable { - [Inject] private LauncherUpdater _launcherUpdater { get; set; } = default!; + [Inject] private IBridge _bridge { get; set; } = default!; [CascadingParameter] private IMudDialogInstance _mudDialog { get; set; } = default!; - [Parameter] public LauncherUpdater.ReleaseAsset Asset { get; set; } = default!; + [Parameter] public ReleaseAsset Asset { get; set; } = default!; private enum Phase { Downloading, Done, Failed } private Phase _phase = Phase.Downloading; @@ -53,12 +54,12 @@ private string? _eta protected override void OnInitialized() { - _launcherUpdater.DownloadProgress += OnProgress; + _bridge.DownloadProgress += OnProgress; _pollTimer = new Timer(_ => { SampleSpeed(); - InvokeAsync(StateHasChanged); + _ = InvokeAsync(StateHasChanged); }, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(250)); _ = RunAsync(); @@ -68,7 +69,7 @@ private async Task RunAsync() { try { - var path = await _launcherUpdater.DownloadAsset(Asset, _cts.Token); + var path = await _bridge.DownloadAsset(Asset, _cts.Token); _phase = Phase.Done; await InvokeAsync(StateHasChanged); @@ -76,7 +77,7 @@ private async Task RunAsync() // Small pause so the user sees "complete" before we vanish. await Task.Delay(800, _cts.Token); - LauncherUpdater.RunInstallerAndExit(path); + _bridge.RunInstallerAndExit(path); } catch (OperationCanceledException) { @@ -164,7 +165,7 @@ private static string Format(long bytes) public void Dispose() { - _launcherUpdater.DownloadProgress -= OnProgress; + _bridge.DownloadProgress -= OnProgress; _pollTimer?.Dispose(); if (!_cts.IsCancellationRequested) diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/LauncherUpdateDialog.razor.css b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LauncherUpdateDialog.razor.css similarity index 100% rename from Starlight.Launcher/Components/Atoms/Dialogs/LauncherUpdateDialog.razor.css rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LauncherUpdateDialog.razor.css diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/LoginsUnrecoverableDialog.razor b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LoginsUnrecoverableDialog.razor similarity index 73% rename from Starlight.Launcher/Components/Atoms/Dialogs/LoginsUnrecoverableDialog.razor rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LoginsUnrecoverableDialog.razor index 7c3e3b3..9d54846 100644 --- a/Starlight.Launcher/Components/Atoms/Dialogs/LoginsUnrecoverableDialog.razor +++ b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/LoginsUnrecoverableDialog.razor @@ -1,10 +1,12 @@ -@using Starlight.Launcher.Services.Localization +@using Starlight.Launcher.WebUI.Localization; + +@inherits LocalizedComponentBase - @_localization["logins-unrecoverable-dialog-title"] + @L["logins-unrecoverable-dialog-title"] @@ -20,15 +22,13 @@ - @_localization["logins-unrecoverable-dialog-confirm"] + @L["logins-unrecoverable-dialog-confirm"] @code { - [Inject] private LocalizationManager _localization { get; set; } = default!; - [Parameter] public IEnumerable Logins { get; set; } = []; diff --git a/Starlight.Launcher/Components/Atoms/Dialogs/NoAccountDialog.razor b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/NoAccountDialog.razor similarity index 87% rename from Starlight.Launcher/Components/Atoms/Dialogs/NoAccountDialog.razor rename to Starlight.Launcher.WebUI/Components/Atoms/Dialogs/NoAccountDialog.razor index 996bfb7..c79a6f2 100644 --- a/Starlight.Launcher/Components/Atoms/Dialogs/NoAccountDialog.razor +++ b/Starlight.Launcher.WebUI/Components/Atoms/Dialogs/NoAccountDialog.razor @@ -1,5 +1,6 @@ -@using Starlight.Launcher.Services.Localization -@inject LocalizationManager Localization +@using Starlight.Launcher.WebUI.Localization; + +@inherits LocalizedComponentBase @@ -8,7 +9,7 @@ Color="Color.Primary" Size="Size.Large" /> - @Localization["no-account-dialog-title"] + @L["no-account-dialog-title"]
@@ -20,7 +21,7 @@ Color="Color.Info" Size="Size.Medium" /> - @Localization["no-account-dialog-description"] + @L["no-account-dialog-description"]
@@ -29,13 +30,13 @@ - @Localization["general-cancel"] + @L["general-cancel"] - @Localization["general-ok"] + @L["general-ok"]
diff --git a/Starlight.Launcher/Components/Atoms/HorizontalNavBar.razor b/Starlight.Launcher.WebUI/Components/Atoms/HorizontalNavBar.razor similarity index 81% rename from Starlight.Launcher/Components/Atoms/HorizontalNavBar.razor rename to Starlight.Launcher.WebUI/Components/Atoms/HorizontalNavBar.razor index 2076afb..f630734 100644 --- a/Starlight.Launcher/Components/Atoms/HorizontalNavBar.razor +++ b/Starlight.Launcher.WebUI/Components/Atoms/HorizontalNavBar.razor @@ -1,26 +1,30 @@ +@using Starlight.Launcher.WebUI.Localization; + +@inherits LocalizedComponentBase +
diff --git a/Starlight.Launcher/Components/Atoms/HorizontalNavBar.razor.cs b/Starlight.Launcher.WebUI/Components/Atoms/HorizontalNavBar.razor.cs similarity index 73% rename from Starlight.Launcher/Components/Atoms/HorizontalNavBar.razor.cs rename to Starlight.Launcher.WebUI/Components/Atoms/HorizontalNavBar.razor.cs index 04838f3..7f6a599 100644 --- a/Starlight.Launcher/Components/Atoms/HorizontalNavBar.razor.cs +++ b/Starlight.Launcher.WebUI/Components/Atoms/HorizontalNavBar.razor.cs @@ -1,12 +1,11 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; -using Starlight.Launcher.Services.Localization; +using Starlight.Launcher.WebUI.Localization; -namespace Starlight.Launcher.Components.Atoms; +namespace Starlight.Launcher.WebUI.Components.Atoms; -public sealed partial class HorizontalNavBar : ComponentBase, IDisposable +public sealed partial class HorizontalNavBar : LocalizedComponentBase, IDisposable { - [Inject] private LocalizationManager _localization { get; set; } = default!; [Inject] private NavigationManager _navigation { get; set; } = default!; private bool IsActive(string href, NavLinkMatch match = NavLinkMatch.Prefix) @@ -27,5 +26,10 @@ private void OnLocationChanged(object? sender, LocationChangedEventArgs e) protected override void OnInitialized() => _navigation.LocationChanged += OnLocationChanged; - public void Dispose() => _navigation.LocationChanged -= OnLocationChanged; + public override void Dispose() + { + base.Dispose(); + + _navigation.LocationChanged -= OnLocationChanged; + } } diff --git a/Starlight.Launcher.WebUI/Components/Atoms/HorizontalNavBar.razor.css b/Starlight.Launcher.WebUI/Components/Atoms/HorizontalNavBar.razor.css new file mode 100644 index 0000000..669ab35 --- /dev/null +++ b/Starlight.Launcher.WebUI/Components/Atoms/HorizontalNavBar.razor.css @@ -0,0 +1,55 @@ +.bottom-nav { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + max-width: 520px; + height: 100%; + margin: 0 auto; + padding: 0 8px; + box-sizing: border-box; +} + +.bottom-nav-item { + flex: 0 0 auto; + height: auto; + padding: 8px 18px; + border-radius: 999px; + text-decoration: none; + color: var(--mud-palette-text-secondary); + transition: color 150ms ease, background-color 150ms ease; +} + + .bottom-nav-item:hover { + color: var(--mud-palette-text-primary); + background: var(--mud-palette-action-default-hover); + } + + .bottom-nav-item:hover span { + color: var(--mud-palette-text-primary); + } + + .bottom-nav-item.active { + background: color-mix(in srgb, var(--mud-palette-primary) 12%, transparent); + } + + .bottom-nav-item.active span { + color: var(--mud-palette-primary); + } + +.nav-inner { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + height: 100%; + gap: 8px; +} + +.bottom-nav-item span { + font-size: 0.9rem; + font-weight: 500; + line-height: 1; + color: var(--mud-palette-text-secondary); + transition: color 150ms ease; +} diff --git a/Starlight.Launcher/Components/Atoms/ServerList/SearchBar.razor b/Starlight.Launcher.WebUI/Components/Atoms/ServerList/SearchBar.razor similarity index 82% rename from Starlight.Launcher/Components/Atoms/ServerList/SearchBar.razor rename to Starlight.Launcher.WebUI/Components/Atoms/ServerList/SearchBar.razor index d058d3e..a04df15 100644 --- a/Starlight.Launcher/Components/Atoms/ServerList/SearchBar.razor +++ b/Starlight.Launcher.WebUI/Components/Atoms/ServerList/SearchBar.razor @@ -1,4 +1,7 @@ -@using Starlight.Launcher.Models.ServerStatus +@using Starlight.Launcher.WebUI.Models.ServerStatus; +@using Starlight.Launcher.WebUI.Localization; + +@inherits LocalizedComponentBase