postit: persistent token store + silent refresh on YavscApiClient

Move TokenSource.cs and YavscApiClient.cs into PostIt/Services/ under
the PostIt.Services namespace so they sit next to CustomSchemeBrowser
and SingleInstance.

YavscApiClient:
- Take Settings + TokenStore in the constructor; BaseAddress now comes
  from settings.ApiUrl (defaults to https://blogs.pschneider.fr/api/v1/)
  instead of being hard-coded to yavsc.org.
- Compute access-token expiry from IdentityModel's AccessTokenExpiration
  (TimeSpan / DateTimeOffset / int) with a JWT 'exp' claim fallback,
  then a 23h default. Fixes the original .AccessTokenExpiration.Second
  bug that made tokens expire immediately.
- Use IdentityModel.OidcClient 6.0's RefreshTokenAsync(refreshToken,
  cancellationToken:) — the named parameter is 'cancellationToken',
  not 'ct' as previously written.
- Wrap HttpClient in a BearerTokenHandler that injects the access
  token on every outbound request; serialise the refresh path with a
  SemaphoreSlim so concurrent callers don't all rotate the same
  refresh token (which Auth0 invalidates on first use).
- 401 from the server triggers a single forced refresh + retry.
- RefreshFailedException is permanent when Auth0 rejects the refresh
  token (revoked / rotation-theft detected / expired): TokenStore is
  purged and the caller must re-run LoginInteractiveAsync.
- Expose HasValidSession for warm-start skip of the LoginPage.

TokenStore writes the JSON bundle with 0600 on POSIX. Future hardening
will route it through libsecret / DPAPI.

LoginPageViewModel becomes a thin coordinator: it builds (or reuses)
a YavscApiClient and delegates to LoginInteractiveAsync. The legacy
Password / UserEmail / RememberMe fields stay but are marked
[Obsolete] since the PKCE flow is interactive and IdP-collected.

Test hooks preserved: BrowserFactoryOverride, SettingsLoadOverride,
ApiClientOverride.

Builds clean (0 errors). No Auth0Avalonia package reference needed:
Platform.CreateBrowser + CustomSchemeBrowser is the right shape for
PostIt's per-platform redirection.
This commit is contained in:
Lum 2026-06-23 21:04:08 +01:00
commit d091f2c663
3 changed files with 497 additions and 65 deletions

View file

@ -0,0 +1,70 @@
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PostIt.Services;
/// <summary>
/// On-disk persistence for the OIDC token bundle (access + refresh + id).
///
/// Layout: a single JSON file. The file is written with 0600 on POSIX
/// systems; on Windows the OS-level DACL inherits from the user's
/// profile. Future hardening: route writes through libsecret / DPAPI
/// so the file itself is never readable in cleartext on disk.
/// </summary>
public sealed class TokenStore
{
private readonly string _path;
private readonly object _gate = new();
public TokenStore(string path) => _path = path;
public RefreshTokenRecord? Load()
{
lock (_gate)
{
if (!File.Exists(_path)) return null;
var json = File.ReadAllText(_path);
return JsonSerializer.Deserialize<RefreshTokenRecord>(json);
}
}
public void Save(RefreshTokenRecord record)
{
lock (_gate)
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var json = JsonSerializer.Serialize(record, new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
File.WriteAllText(_path, json);
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
File.SetUnixFileMode(_path,
UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
}
public void Clear()
{
lock (_gate)
{
if (File.Exists(_path)) File.Delete(_path);
}
}
}
/// <summary>
/// Snapshot of the tokens the API client needs to keep a session alive
/// across process restarts. <see cref="AccessTokenExpiresAt"/> is the
/// absolute UTC time the access token is no longer valid; the API
/// client compares it to <see cref="DateTimeOffset.UtcNow"/> before
/// every call to decide whether to refresh.
/// </summary>
public sealed record RefreshTokenRecord(
string AccessToken,
string RefreshToken,
DateTimeOffset AccessTokenExpiresAt,
string? IdToken = null);

View file

@ -0,0 +1,299 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using IdentityModel.OidcClient;
namespace PostIt.Services;
/// <summary>
/// Thin HTTP client for the Yavsc API with a transparent, silent
/// refresh-on-401 strategy. Callers just call <c>CallAsync</c>; the
/// client takes care of (a) attaching the Bearer access token, (b)
/// refreshing it silently via the OidcClient when it's about to
/// expire or when the server rejects it, and (c) persisting the new
/// token bundle so a relaunch of PostIt picks up where it left off.
///
/// Threading: the refresh path is serialised by a semaphore. The
/// <see cref="BearerTokenHandler"/> only refreshes once even if many
/// concurrent requests are in flight.
/// </summary>
public sealed class YavscApiClient : IAsyncDisposable
{
// 60s of slack before the access_token's nominal expiry. Covers
// network latency + JWT validation on the server side.
private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60);
private readonly Settings _settings;
private readonly OidcClient _oidc;
private readonly TokenStore _store;
private readonly HttpClient _http;
private readonly BearerTokenHandler _bearer;
private readonly SemaphoreSlim _refreshGate = new(1, 1);
private RefreshTokenRecord? _tokens;
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{
_settings = settings;
_store = store;
_oidc = oidc ?? new OidcClient(settings.GetOidcClientOptions());
_bearer = new BearerTokenHandler(this);
_http = new HttpClient(_bearer, disposeHandler: true)
{
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
BaseAddress = new Uri(settings.ApiUrl)
};
_tokens = store.Load();
}
/// <summary>
/// True if a non-expired access token (or a refreshable bundle) is
/// already in memory. UI uses this to skip the LoginPage on warm
/// starts.
/// </summary>
public bool HasValidSession
{
get
{
if (_tokens is null) return false;
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > TimeSpan.Zero)
return true;
// Access token expired but a refresh token is still around.
return !string.IsNullOrEmpty(_tokens.RefreshToken);
}
}
/// <summary>Force a new interactive login (PKCE). Throws on failure.</summary>
public async Task LoginInteractiveAsync(CancellationToken ct = default)
{
var browser = Platform.CreateBrowser?.Invoke();
if (browser is null)
throw new InvalidOperationException("No browser is available on this platform.");
var client = new OidcClient(_settings.GetOidcClientOptions(browser));
var result = await client.LoginAsync(new LoginRequest(), ct);
if (result.IsError)
throw new InvalidOperationException($"OIDC login failed: {result.Error}");
if (string.IsNullOrEmpty(result.RefreshToken))
throw new InvalidOperationException(
"Missing refresh_token — vérifie le scope 'offline_access'.");
_tokens = new RefreshTokenRecord(
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
IdToken: result.IdentityToken);
_store.Save(_tokens);
}
/// <summary>Call a JSON endpoint, transparently refreshing the token if needed.</summary>
public async Task<T> CallAsync<T>(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default)
{
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var dto = await JsonSerializer.DeserializeAsync<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
return dto!;
}
/// <summary>Call an endpoint that returns no useful body (DELETE, etc.).</summary>
public async Task CallAsync(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default)
{
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
private async Task<HttpResponseMessage> SendAsync(
HttpMethod method, string path, object? body, CancellationToken ct)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
using var req = new HttpRequestMessage(method, path);
if (body is not null)
req.Content = JsonContent.Create(body);
var response = await _http.SendAsync(req, ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
// Server rejected: could be revocation, clock skew, audience mismatch.
// Force a refresh and retry exactly once.
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
using var retry = new HttpRequestMessage(method, path);
if (body is not null)
retry.Content = JsonContent.Create(body);
response = await _http.SendAsync(retry, ct).ConfigureAwait(false);
}
return response;
}
/// <summary>
/// Lock the refresh path so concurrent callers don't each rotate
/// the refresh token (which Auth0 invalidates on first use).
/// </summary>
private async Task EnsureFreshTokenAsync(CancellationToken ct)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
return;
await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
return;
await ForceRefreshAsync(ct).ConfigureAwait(false);
}
finally
{
_refreshGate.Release();
}
}
private async Task ForceRefreshAsync(CancellationToken ct)
{
if (_tokens is null || string.IsNullOrEmpty(_tokens.RefreshToken))
throw new InvalidOperationException("No refresh token available.");
var result = await _oidc.RefreshTokenAsync(_tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
if (result.IsError)
{
// Refresh token dead: revoked, expired, or rotation-theft
// detected. Purge and force an interactive re-login.
_store.Clear();
_tokens = null;
throw new RefreshFailedException(result.Error ?? "refresh failed", isPermanent: true);
}
_tokens = new RefreshTokenRecord(
AccessToken: result.AccessToken!,
RefreshToken: result.RefreshToken ?? _tokens.RefreshToken,
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
IdToken: result.IdentityToken ?? _tokens.IdToken);
_store.Save(_tokens);
}
public async Task LogoutAsync()
{
_store.Clear();
_tokens = null;
// Optional: clear the IdP session in the browser too.
// await _oidc.LogoutAsync(new LogoutRequest());
await Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
_http.Dispose();
_refreshGate.Dispose();
return ValueTask.CompletedTask;
}
/// <summary>
/// Compute the absolute expiry of an access token. Prefer the
/// IdP-provided expiration when present (TimeSpan or DateTimeOffset
/// depending on IdentityModel version), fall back to the JWT 'exp'
/// claim, finally to a conservative 23h default.
/// </summary>
private static DateTimeOffset ComputeExpiry(object? provided, string? jwt)
{
// IdentityModel.OidcClient 5.x: AccessTokenExpiration is a TimeSpan.
if (provided is TimeSpan ts) return DateTimeOffset.UtcNow.Add(ts);
if (provided is DateTimeOffset dto) return dto;
if (provided is int seconds) return DateTimeOffset.UtcNow.AddSeconds(seconds);
var fromJwt = ParseJwtExpiry(jwt);
return fromJwt ?? DateTimeOffset.UtcNow.AddHours(23);
}
/// <summary>
/// Decode the 'exp' claim of a JWT without verifying the signature
/// (the server verifies). Read-only fallback.
/// </summary>
private static DateTimeOffset? ParseJwtExpiry(string? jwt)
{
if (string.IsNullOrEmpty(jwt)) return null;
var parts = jwt.Split('.');
if (parts.Length != 3) return null;
var payload = parts[1].Replace('-', '+').Replace('_', '/');
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
try
{
using var doc = JsonDocument.Parse(Convert.FromBase64String(payload));
if (!doc.RootElement.TryGetProperty("exp", out var expEl)) return null;
return DateTimeOffset.FromUnixTimeSeconds(expEl.GetInt64());
}
catch
{
return null;
}
}
/// <summary>
/// Internal <see cref="HttpMessageHandler"/> that injects the
/// current Bearer token on every outbound request. Delegating
/// handlers can't easily retry, so the 401 handling lives in
/// <see cref="SendAsync"/> above; this handler only attaches the
/// header.
/// </summary>
private sealed class BearerTokenHandler : DelegatingHandler
{
private readonly YavscApiClient _owner;
public BearerTokenHandler(YavscApiClient owner) : base(new HttpClientHandler())
{
_owner = owner;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_owner._tokens is not null)
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", _owner._tokens.AccessToken);
return base.SendAsync(request, cancellationToken);
}
}
}
public sealed class RefreshFailedException : Exception
{
public bool IsPermanent { get; }
public RefreshFailedException(string message, bool isPermanent)
: base(message) => IsPermanent = isPermanent;
}

View file

@ -1,16 +1,24 @@
using System;
using System.IO;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser; using IdentityModel.OidcClient.Browser;
using PostIt.Services; using PostIt.Services;
using System;
using System.Threading.Tasks;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public partial class LoginPageViewModel : ViewModelBase public partial class LoginPageViewModel : ViewModelBase
{ {
public string UserEmail { get; set; } private const string SettingsFileName = "postit-settings.json";
public string Password { get; set; }
[Obsolete("Password grant is not used; IdentityModel.OidcClient performs PKCE.")]
public string Password { get; set; } = string.Empty;
[Obsolete("User-entered email is not used; the IdP login UI collects it.")]
public string UserEmail { get; set; } = string.Empty;
[Obsolete("No local credential persistence in the current build.")]
public bool RememberMe { get; set; }
/// <summary> /// <summary>
/// URL of the Yavsc.Org account-registration page. /// URL of the Yavsc.Org account-registration page.
@ -72,20 +80,36 @@ public partial class LoginPageViewModel : ViewModelBase
: authority + path; : authority + path;
} }
private string _AccessToken; /// <summary>
public string AccessToken { get => _AccessToken; private set => this.SetProperty(ref _AccessToken, value); } /// The access token of the most recent successful login, or null.
/// Kept on the VM so views can show "logged in as …" feedback; the
/// authoritative copy lives in the <see cref="TokenStore"/>.
/// </summary>
private string? _accessToken;
public string? AccessToken
{
get => _accessToken;
private set => this.SetProperty(ref _accessToken, value);
}
public bool RememberMe { get; set; } public override bool CanNavigateNext { get => false; protected set => throw new NotImplementedException(); }
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => true; protected set => throw new NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
public Settings Settings { get; } public Settings Settings { get; }
private string _StatusMessage; private string _statusMessage = "Ready";
public string StatusMessage { get => _StatusMessage; private set => this.SetProperty(ref _StatusMessage, value); } public string StatusMessage
{
get => _statusMessage;
private set => this.SetProperty(ref _statusMessage, value);
}
private bool _IsBusy; private bool _isBusy;
public bool IsBusy { get=> _IsBusy; private set=> this.SetProperty(ref _IsBusy, value); } public bool IsBusy
{
get => _isBusy;
private set => this.SetProperty(ref _isBusy, value);
}
/// <summary> /// <summary>
/// Optional override used by tests. When set, this factory is called /// Optional override used by tests. When set, this factory is called
@ -102,7 +126,16 @@ public partial class LoginPageViewModel : ViewModelBase
/// </summary> /// </summary>
public Func<Task>? SettingsLoadOverride { get; set; } public Func<Task>? SettingsLoadOverride { get; set; }
public LoginPageViewModel() : this(new Settings(), browserFactoryOverride: null) /// <summary>
/// Optional override used by tests. When set, the VM hands this
/// pre-built <see cref="YavscApiClient"/> to itself instead of
/// constructing a fresh one.
/// </summary>
public YavscApiClient? ApiClientOverride { get; set; }
private YavscApiClient? _api;
public LoginPageViewModel() : this(new Settings(), apiClient: null, browserFactoryOverride: null)
{ {
// Load settings eagerly so RegisterUrl / ForgotPasswordUrl are // Load settings eagerly so RegisterUrl / ForgotPasswordUrl are
// populated as soon as the page renders (XAML bindings fire // populated as soon as the page renders (XAML bindings fire
@ -112,13 +145,19 @@ public partial class LoginPageViewModel : ViewModelBase
} }
/// <summary> /// <summary>
/// Test-friendly constructor: caller supplies pre-loaded <paramref name="settings"/> /// Test-friendly constructor: caller supplies pre-loaded
/// and (optionally) a <paramref name="browserFactoryOverride"/> that bypasses /// <paramref name="settings"/>, an optional pre-built
/// the static <see cref="Platform"/> indirection. /// <paramref name="apiClient"/>, and an optional
/// <paramref name="browserFactoryOverride"/> that bypasses the
/// static <see cref="Platform"/> indirection.
/// </summary> /// </summary>
public LoginPageViewModel(Settings settings, Func<IBrowser?>? browserFactoryOverride = null) public LoginPageViewModel(
Settings settings,
YavscApiClient? apiClient = null,
Func<IBrowser?>? browserFactoryOverride = null)
{ {
Settings = settings; Settings = settings;
ApiClientOverride = apiClient;
BrowserFactoryOverride = browserFactoryOverride; BrowserFactoryOverride = browserFactoryOverride;
StatusMessage = "Ready"; StatusMessage = "Ready";
} }
@ -128,76 +167,87 @@ public partial class LoginPageViewModel : ViewModelBase
{ {
try try
{ {
this.IsBusy = true; IsBusy = true;
(SettingsLoadOverride ?? Settings.Load)().Wait();
// Guard: if the authority is empty (no user settings file and if (SettingsLoadOverride is not null)
// the embedded default couldn't be loaded for any reason), await SettingsLoadOverride().ConfigureAwait(false);
// refuse to call OidcClient. IdentityModel would otherwise else
// build a bogus authorize URL like "http://127.0.0.1:1/" await Settings.Load().ConfigureAwait(false);
// from an empty Authority, which the browser then refuses to
// open with a confusing "Cette adresse est interdite" // Guard: refuse to call OidcClient when the authority is
// (or equivalent) message. Tell the operator exactly what // empty. IdentityModel would otherwise build a bogus
// to fix instead. // authorize URL like "http://127.0.0.1:1/" from an empty
// Authority, which the browser refuses with a confusing
// "Cette adresse est interdite"-style message. Tell the
// operator exactly what to fix instead.
if (string.IsNullOrWhiteSpace(Settings.Authentication?.Authority)) if (string.IsNullOrWhiteSpace(Settings.Authentication?.Authority))
{ {
this.IsBusy = false; IsBusy = false;
StatusMessage = $"Configuration manquante — édite {SettingsFileHint()} et renseigne Authentication.Authority"; StatusMessage =
$"Configuration manquante — édite {SettingsFileHint()} et renseigne Authentication.Authority";
return; return;
} }
// The platform project picks the right redirect URI and browser // The platform project picks the right redirect URI and
// implementation; we don't reference any UI toolkit from here. // browser implementation; we don't reference any UI
// toolkit from here.
Settings.RedirectUri = string.IsNullOrWhiteSpace(Settings.RedirectUri) Settings.RedirectUri = string.IsNullOrWhiteSpace(Settings.RedirectUri)
? Platform.DefaultRedirectUri ? Platform.DefaultRedirectUri
: Settings.RedirectUri; : Settings.RedirectUri;
// Surface the discovery URL the client is about to call, so a // Surface the discovery URL the client is about to call,
// failure (DNS, TLS, 404) can be diagnosed by pasting the URL // so a failure (DNS, TLS, 404) can be diagnosed by
// straight into a browser. OidcClient computes the discovery // pasting the URL straight into a browser. OidcClient
// URL as `Authority + /.well-known/openid-configuration`; we // computes the discovery URL as
// `Authority + /.well-known/openid-configuration`; we
// normalise the trailing slash here so the printed URL is // normalise the trailing slash here so the printed URL is
// exactly what IdentityModel will fetch. // exactly what IdentityModel will fetch.
if (!string.IsNullOrEmpty(DiscoveryUrl)) if (!string.IsNullOrEmpty(DiscoveryUrl))
StatusMessage = $"Discovering {DiscoveryUrl}"; StatusMessage = $"Discovering {DiscoveryUrl}";
var browser = BrowserFactoryOverride is not null // Build (or reuse) the API client. The browser override
? BrowserFactoryOverride.Invoke() // takes precedence: tests want to inject a fake browser
: Platform.CreateBrowser?.Invoke(); // and the production path uses Platform.CreateBrowser.
if (browser is null) _api ??= ApiClientOverride ?? new YavscApiClient(Settings, BuildTokenStore());
{
StatusMessage = $"No browser is available on this platform. (discovery: {DiscoveryUrl})";
return;
}
var client = new OidcClient(Settings.GetOidcClientOptions(browser)); // Platform.CreateBrowser may still want to be customised
var loginResult = await client.LoginAsync(new LoginRequest()); // per-call (e.g. between desktop and android), so route
// the interactive login through a callback that reuses
if (loginResult.IsError) // BrowserFactoryOverride when present.
{ await LoginInteractiveCoreAsync(_api).ConfigureAwait(false);
StatusMessage = $"{loginResult.Error} (discovery: {DiscoveryUrl})";
return;
}
IsBusy = false;
StatusMessage = "Interactive token acquired."; StatusMessage = "Interactive token acquired.";
this.IsBusy = false;
AccessToken = loginResult.AccessToken;
/* TODO save or not user log and password
await Task.Run(() =>
{
Settings.Save().Wait();
});*/
} }
catch (Exception ex) catch (Exception ex)
{ {
this.IsBusy = false; IsBusy = false;
var suffix = !string.IsNullOrEmpty(DiscoveryUrl) ? $" (discovery: {DiscoveryUrl})" : string.Empty; var suffix = !string.IsNullOrEmpty(DiscoveryUrl) ? $" (discovery: {DiscoveryUrl})" : string.Empty;
StatusMessage = $"Error: {ex.Message}{suffix}"; StatusMessage = $"Error: {ex.Message}{suffix}";
} }
} }
/// <summary>
/// Single entry point for the OIDC login: YavscApiClient owns the
/// browser choice, the OidcClient instance, the token persistence
/// and the refresh path. The VM is just a thin coordinator.
/// </summary>
private async Task LoginInteractiveCoreAsync(YavscApiClient api)
{
var original = Platform.CreateBrowser;
try
{
if (BrowserFactoryOverride is not null)
Platform.CreateBrowser = BrowserFactoryOverride;
await api.LoginInteractiveAsync().ConfigureAwait(false);
}
finally
{
Platform.CreateBrowser = original;
}
}
/// <summary> /// <summary>
/// XDG-compliant path to the user settings file. Surfaced in the /// XDG-compliant path to the user settings file. Surfaced in the
/// "Configuration manquante" message so the operator knows exactly /// "Configuration manquante" message so the operator knows exactly
@ -206,6 +256,19 @@ public partial class LoginPageViewModel : ViewModelBase
private static string SettingsFileHint() private static string SettingsFileHint()
{ {
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
return System.IO.Path.Combine(appData, "PostIt", "postit-settings.json"); return Path.Combine(appData, "PostIt", "postit-settings.json");
}
/// <summary>
/// Build the on-disk <see cref="TokenStore"/> used by
/// <see cref="YavscApiClient"/>. The token bundle lives in
/// <c>~/.config/PostIt/tokens.json</c> on Linux; the same path
/// layout is used on every platform for predictability.
/// </summary>
private static TokenStore BuildTokenStore()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var path = Path.Combine(appData, "PostIt", "tokens.json");
return new TokenStore(path);
} }
} }