Teams-style chat list, contact name resolution, and fixes
UI:
- Rework the conversation list to a Teams/Outlook-style row: single-line
preview, right-aligned timestamp, unread dot, and a neutral elevated
rounded fill (drawn by a Border that wraps the whole row) for hover/select.
- Smooth the Chats/Calls/Stories rail indicator by suppressing the content
frame's transition, which competed with the indicator animation.
Contact names:
- Fetch + decrypt Signal profile names (ProfileCipher, ProfileService,
ProfileNameStore, GET /v1/profile) when a peer has no synced name.
- Capture each contact's profileKey from the link'n'sync backup and the
contacts sync (un-reserve ContactDetails.profileKey) so names can be
resolved; fall back to the phone number before a raw ACI.
Other:
- Report the device to Signal as "Wingnal: {machine name}".
- Resolve build warnings: target Wingnal.Service at net8.0-windows (CA1416),
initialize zkgroup credential fields (CS8618), hoist a stackalloc (CA2014).
- Skip the live chat-connect diagnostic test by default (it logged in as the
real account and disconnected the app).
- Bump app version 1.0.1.0 -> 1.0.2.0.
This commit is contained in:
@@ -46,13 +46,13 @@ public sealed class ShoSha256
|
||||
{
|
||||
if (_absorbing) throw new InvalidOperationException("ShoSha256: must ratchet before squeezing");
|
||||
var output = new byte[outlen];
|
||||
Span<byte> ctr = stackalloc byte[8]; // hoisted out of the loop; fully rewritten each iteration
|
||||
for (int i = 0; i * HashLen < outlen; i++)
|
||||
{
|
||||
using var h = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
h.AppendData(new byte[BlockLen - 1]); // 63 zero bytes
|
||||
h.AppendData(new byte[] { 0x01 });
|
||||
h.AppendData(_cv);
|
||||
Span<byte> ctr = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteUInt64BigEndian(ctr, (ulong)i);
|
||||
h.AppendData(ctr);
|
||||
byte[] digest = h.GetHashAndReset();
|
||||
|
||||
@@ -16,7 +16,8 @@ public static class CredentialSystem
|
||||
|
||||
public sealed class SystemParams
|
||||
{
|
||||
public Ristretto255 GW, GWprime, GX0, GX1, GV, GZ;
|
||||
// Always populated by Generate()'s object initializer (null! silences the constructor-exit check).
|
||||
public Ristretto255 GW = null!, GWprime = null!, GX0 = null!, GX1 = null!, GV = null!, GZ = null!;
|
||||
public Ristretto255[] GY = new Ristretto255[NumSupportedAttrs];
|
||||
|
||||
public static readonly SystemParams Hardcoded = Generate();
|
||||
@@ -84,7 +85,7 @@ public sealed class Credential
|
||||
public sealed class CredentialPrivateKey
|
||||
{
|
||||
public Scalar25519 W, Wprime, X0, X1;
|
||||
public Ristretto255 BigW;
|
||||
public Ristretto255 BigW = null!; // set by Generate()/FromPrivate() before use
|
||||
public Scalar25519[] Y = new Scalar25519[CredentialSystem.NumSupportedAttrs];
|
||||
|
||||
public static CredentialPrivateKey Generate(byte[] randomness)
|
||||
@@ -120,7 +121,7 @@ public sealed class CredentialPrivateKey
|
||||
/// <summary>The server's public credential key the client uses to receive + present credentials.</summary>
|
||||
public sealed class CredentialPublicKey
|
||||
{
|
||||
public Ristretto255 CW;
|
||||
public Ristretto255 CW = null!; // set by FromPrivate() before use
|
||||
public Ristretto255[] I = new Ristretto255[CredentialSystem.NumSupportedAttrs - 1]; // I_2 .. I_7
|
||||
|
||||
/// <summary>I for a credential with <paramref name="numAttrs"/> attribute points (≥2).</summary>
|
||||
|
||||
@@ -74,6 +74,17 @@ public sealed class ContactsStore
|
||||
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
|
||||
}
|
||||
|
||||
/// <summary>The contact's phone number (E.164), if we synced/imported one — a friendlier last-resort
|
||||
/// label than a raw ACI when no name is available.</summary>
|
||||
public string? NumberFor(string aci)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT number FROM contacts WHERE aci = $aci;";
|
||||
cmd.Parameters.AddWithValue("$aci", aci);
|
||||
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
|
||||
}
|
||||
|
||||
/// <summary>Contacts whose name or number contains <paramref name="query"/> (empty = all named),
|
||||
/// ordered by inbox position then name. Filtered in memory because the columns are encrypted.</summary>
|
||||
public IReadOnlyList<Contact> Search(string? query, int limit = 25)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite store of profile display names we've fetched and decrypted per ACI (via <c>ProfileCipher</c>),
|
||||
/// so the conversation list can name contacts who aren't in the primary's system address book. This is
|
||||
/// distinct from <see cref="ContactsStore"/> (which holds names synced from the primary): profile names
|
||||
/// are resolved on demand from each peer's profile key. Names are encrypted at rest with
|
||||
/// <see cref="LocalCipher"/> (%LOCALAPPDATA%\Wingnal\profile_names.db). Keyed by ACI.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ProfileNameStore
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly LocalCipher _cipher;
|
||||
|
||||
public ProfileNameStore(string? path = null, LocalCipher? cipher = null)
|
||||
{
|
||||
if (path is null)
|
||||
{
|
||||
string dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(dir);
|
||||
path = Path.Combine(dir, "profile_names.db");
|
||||
}
|
||||
_cipher = cipher ?? LocalCipher.Default();
|
||||
_connectionString = $"Data Source={path}";
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE TABLE IF NOT EXISTS profile_names (aci TEXT PRIMARY KEY, name TEXT NOT NULL);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Store(string aci, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return;
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"INSERT INTO profile_names (aci, name) VALUES ($a, $n) ON CONFLICT(aci) DO UPDATE SET name = $n;";
|
||||
cmd.Parameters.AddWithValue("$a", aci);
|
||||
cmd.Parameters.AddWithValue("$n", _cipher.Protect(name));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public string? Get(string aci)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT name FROM profile_names WHERE aci = $a;";
|
||||
cmd.Parameters.AddWithValue("$a", aci);
|
||||
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
|
||||
}
|
||||
|
||||
/// <summary>Removes all resolved profile names (used when unlinking).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM profile_names;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
|
||||
namespace Wingnal.Service.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts Signal profile fields (name, about) that a recipient encrypted with their 32-byte profile
|
||||
/// key. The blob layout matches libsignal's <c>ProfileCipher</c>: <c>nonce(12) || AES-256-GCM ciphertext
|
||||
/// || tag(16)</c>. The decrypted name plaintext is NUL-separated <c>given \0 family</c>, zero-padded to a
|
||||
/// fixed bucket length, so decoding strips the padding and rejoins the parts.
|
||||
/// </summary>
|
||||
public static class ProfileCipher
|
||||
{
|
||||
/// <summary>Decrypts a base64 profile-name blob to a display name. Returns null when the input is
|
||||
/// empty/malformed, the profile key is the wrong length, or GCM authentication fails (a stale or
|
||||
/// mismatched profile key) — callers treat that as "no name available".</summary>
|
||||
public static string? DecryptName(byte[] profileKey, string? base64Name)
|
||||
{
|
||||
if (profileKey.Length != 32 || string.IsNullOrEmpty(base64Name)) return null;
|
||||
|
||||
byte[] blob;
|
||||
try { blob = Convert.FromBase64String(base64Name); }
|
||||
catch (FormatException) { return null; }
|
||||
if (blob.Length < 12 + 16) return null; // must hold at least a nonce and a tag
|
||||
|
||||
var nonce = new byte[12];
|
||||
Array.Copy(blob, 0, nonce, 0, 12);
|
||||
var ciphertextAndTag = new byte[blob.Length - 12];
|
||||
Array.Copy(blob, 12, ciphertextAndTag, 0, ciphertextAndTag.Length);
|
||||
|
||||
byte[] plaintext;
|
||||
try { plaintext = CryptoPrimitives.AesGcmDecrypt(profileKey, nonce, ciphertextAndTag); }
|
||||
catch { return null; } // tag mismatch / bad key
|
||||
|
||||
return DecodePaddedName(plaintext);
|
||||
}
|
||||
|
||||
/// <summary>Plaintext is <c>given \0 family</c> followed by zero padding. Split on the first NUL (the
|
||||
/// family segment may be empty), UTF-8 decode each part, and rejoin. Returns null if the result is
|
||||
/// blank.</summary>
|
||||
private static string? DecodePaddedName(byte[] plaintext)
|
||||
{
|
||||
int split = Array.IndexOf(plaintext, (byte)0);
|
||||
string given, family;
|
||||
if (split < 0)
|
||||
{
|
||||
given = Encoding.UTF8.GetString(plaintext);
|
||||
family = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
given = Encoding.UTF8.GetString(plaintext, 0, split);
|
||||
int rest = split + 1;
|
||||
int end = Array.IndexOf(plaintext, (byte)0, rest);
|
||||
if (end < 0) end = plaintext.Length;
|
||||
family = Encoding.UTF8.GetString(plaintext, rest, end - rest);
|
||||
}
|
||||
|
||||
string name = $"{given} {family}".Trim();
|
||||
return name.Length == 0 ? null : name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wingnal.Service.Account;
|
||||
using Wingnal.Service.Crypto;
|
||||
using Wingnal.Service.Diagnostics;
|
||||
using Wingnal.Service.Net;
|
||||
|
||||
namespace Wingnal.Service.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves display names for peers we can't name from synced contacts, by fetching their Signal profile
|
||||
/// (<c>GET /v1/profile</c>) and decrypting the name with the profile key learned from their inbound
|
||||
/// messages (see <see cref="MessageDecryptor"/> → <see cref="ProfileKeyStore"/>). Resolved names are
|
||||
/// cached in <see cref="ProfileNameStore"/> so a name is fetched at most once per peer.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ProfileService
|
||||
{
|
||||
private readonly SignalRestClient _rest;
|
||||
private readonly ProfileKeyStore _profileKeys;
|
||||
private readonly ProfileNameStore _names;
|
||||
private readonly string _authToken;
|
||||
|
||||
public ProfileService(SignalRestClient rest, ProfileKeyStore profileKeys, ProfileNameStore names, string authToken)
|
||||
{
|
||||
_rest = rest;
|
||||
_profileKeys = profileKeys;
|
||||
_names = names;
|
||||
_authToken = authToken;
|
||||
}
|
||||
|
||||
/// <summary>The cached profile name for a peer, if one has been resolved; otherwise null.</summary>
|
||||
public string? NameFor(string aci) => _names.Get(aci);
|
||||
|
||||
/// <summary>Fetches, decrypts and caches the profile name for a peer. Returns the resolved name, or
|
||||
/// null when we have no profile key for them, their profile isn't accessible, or decryption fails.
|
||||
/// Best-effort and idempotent — a cached name short-circuits the network call.</summary>
|
||||
public async Task<string?> ResolveAsync(string aci, CancellationToken ct)
|
||||
{
|
||||
string? cached = _names.Get(aci);
|
||||
if (!string.IsNullOrEmpty(cached)) return cached;
|
||||
|
||||
byte[]? profileKey = _profileKeys.Get(aci);
|
||||
if (profileKey is null) { FileLog.Write($"profile: {aci} skip — no profile key"); return null; }
|
||||
|
||||
ProfileResponse? profile = await _rest.GetProfileAsync(aci, _authToken, ct).ConfigureAwait(false);
|
||||
if (profile is null) { FileLog.Write($"profile: {aci} — fetch returned null (inaccessible/HTTP error)"); return null; }
|
||||
|
||||
string? name = ProfileCipher.DecryptName(profileKey, profile.Name);
|
||||
if (name is null)
|
||||
{
|
||||
FileLog.Write($"profile: {aci} — decrypt/empty (name field len={profile.Name?.Length ?? 0})");
|
||||
return null;
|
||||
}
|
||||
_names.Store(aci, name);
|
||||
FileLog.Write($"profile: {aci} resolved (len={name.Length})"); // length only — no PII in the log
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,17 @@ namespace Wingnal.Service.Messaging;
|
||||
public sealed class SyncProcessor
|
||||
{
|
||||
private readonly ContactsStore _contacts;
|
||||
private readonly ProfileKeyStore? _profileKeys;
|
||||
private readonly AttachmentDownloader _downloader;
|
||||
|
||||
/// <summary>Raised for each read receipt synced from the primary (senderAci, message timestamp).</summary>
|
||||
public event Action<string, long>? ReadReceiptReceived;
|
||||
|
||||
public SyncProcessor(ContactsStore contacts, AttachmentDownloader? downloader = null)
|
||||
public SyncProcessor(ContactsStore contacts, ProfileKeyStore? profileKeys = null,
|
||||
AttachmentDownloader? downloader = null)
|
||||
{
|
||||
_contacts = contacts;
|
||||
_profileKeys = profileKeys;
|
||||
_downloader = downloader ?? new AttachmentDownloader();
|
||||
}
|
||||
|
||||
@@ -54,7 +57,7 @@ public sealed class SyncProcessor
|
||||
/// (no network) — the offline-testable core of contacts sync.</summary>
|
||||
public int ImportContacts(byte[] blob)
|
||||
{
|
||||
int count = 0;
|
||||
int count = 0, keys = 0;
|
||||
foreach (ContactRecord record in ContactRecordStream.Parse(blob))
|
||||
{
|
||||
string? aci = AciOf(record.Details.Aci, record.Details.AciBinary);
|
||||
@@ -63,8 +66,17 @@ public sealed class SyncProcessor
|
||||
string? name = string.IsNullOrWhiteSpace(record.Details.Name) ? null : record.Details.Name;
|
||||
string? number = string.IsNullOrWhiteSpace(record.Details.Number) ? null : record.Details.Number;
|
||||
_contacts.Upsert(new Contact(aci, number, name, (int)record.Details.InboxPosition));
|
||||
|
||||
// Keep the contact's profile key so we can fetch + decrypt their profile name later, even
|
||||
// when the primary had no system-contact name for them (so the row isn't left as a raw ACI).
|
||||
if (_profileKeys is not null && record.Details.HasProfileKey)
|
||||
{
|
||||
byte[] pk = record.Details.ProfileKey.ToByteArray();
|
||||
if (pk.Length == 32) { _profileKeys.Store(aci, pk); keys++; }
|
||||
}
|
||||
count++;
|
||||
}
|
||||
FileLog.Write($"sync: imported {count} contact(s), {keys} profile key(s)");
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Wingnal.Service.Net;
|
||||
|
||||
/// <summary>The subset of <c>GET /v1/profile/{id}</c> we consume: the encrypted display <c>name</c>
|
||||
/// (base64 of <c>nonce || ciphertext || tag</c>, decryptable with the recipient's profile key). Other
|
||||
/// fields (about, avatar, capabilities, …) are ignored.</summary>
|
||||
public sealed record ProfileResponse
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? About { get; init; }
|
||||
}
|
||||
@@ -74,6 +74,19 @@ public sealed class SignalRestClient : IDisposable
|
||||
?? throw new InvalidOperationException("empty prekey response");
|
||||
}
|
||||
|
||||
/// <summary>Fetches a recipient's profile (GET /v1/profile/{serviceId}). The <c>name</c> field is
|
||||
/// base64 of a ProfileCipher blob encrypted under the recipient's profile key. Returns null when the
|
||||
/// profile isn't accessible (e.g. 401/403/404) so callers can fall back to a placeholder name rather
|
||||
/// than surfacing an error. <paramref name="authToken"/> is Basic {aci.deviceId:password}.</summary>
|
||||
public async Task<ProfileResponse?> GetProfileAsync(string serviceId, string authToken, CancellationToken ct)
|
||||
{
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, $"/v1/profile/{serviceId}");
|
||||
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic", authToken);
|
||||
using HttpResponseMessage response = await _http.SendAsync(msg, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
return await response.Content.ReadFromJsonAsync<ProfileResponse>(Json, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Sends a list of per-device encrypted messages to a destination. Returns the raw body on
|
||||
/// a device-mismatch (409) / stale-devices (410) so the caller can react; throws on other failures.</summary>
|
||||
public async Task<(bool Ok, HttpStatusCode Status, string Body)> SendMessagesAsync(
|
||||
|
||||
@@ -944,7 +944,7 @@ message ContactDetails {
|
||||
optional Avatar avatar = 3;
|
||||
reserved /* color */ 4;
|
||||
reserved /* verified */ 5;
|
||||
reserved /* profileKey */ 6;
|
||||
optional bytes profileKey = 6; // used to fetch + decrypt the contact's profile name
|
||||
reserved /* blocked */ 7;
|
||||
optional uint32 expireTimer = 8;
|
||||
optional uint32 expireTimerVersion = 12;
|
||||
|
||||
@@ -18,12 +18,15 @@ public sealed class BackupImporter
|
||||
{
|
||||
private readonly MessageStore _messages;
|
||||
private readonly ContactsStore _contacts;
|
||||
private readonly ProfileKeyStore? _profileKeys;
|
||||
private readonly string _ownAci;
|
||||
|
||||
public BackupImporter(MessageStore messages, ContactsStore contacts, string ownAci)
|
||||
public BackupImporter(MessageStore messages, ContactsStore contacts, string ownAci,
|
||||
ProfileKeyStore? profileKeys = null)
|
||||
{
|
||||
_messages = messages;
|
||||
_contacts = contacts;
|
||||
_profileKeys = profileKeys;
|
||||
_ownAci = ownAci.ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -56,6 +59,13 @@ public sealed class BackupImporter
|
||||
recipients[r.Id] = new ResolvedPeer(aci, name);
|
||||
string? number = r.Contact.E164 != 0 ? "+" + r.Contact.E164 : null;
|
||||
_contacts.Upsert(new StoreContact(aci, number, name, InboxPosition: 0));
|
||||
// Keep the profile key so an un-named contact's profile name can still be fetched
|
||||
// and decrypted later (the modern contacts sync no longer carries these).
|
||||
if (_profileKeys is not null && r.Contact.HasProfileKey)
|
||||
{
|
||||
byte[] pk = r.Contact.ProfileKey.ToByteArray();
|
||||
if (pk.Length == 32) _profileKeys.Store(aci, pk);
|
||||
}
|
||||
contactCount++;
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -24,13 +24,16 @@ public sealed class MessageHistoryImporter
|
||||
private readonly SignalRestClient _rest;
|
||||
private readonly MessageStore _messages;
|
||||
private readonly ContactsStore _contacts;
|
||||
private readonly ProfileKeyStore? _profileKeys;
|
||||
|
||||
public MessageHistoryImporter(SignalAccount account, SignalRestClient rest, MessageStore messages, ContactsStore contacts)
|
||||
public MessageHistoryImporter(SignalAccount account, SignalRestClient rest, MessageStore messages,
|
||||
ContactsStore contacts, ProfileKeyStore? profileKeys = null)
|
||||
{
|
||||
_account = account;
|
||||
_rest = rest;
|
||||
_messages = messages;
|
||||
_contacts = contacts;
|
||||
_profileKeys = profileKeys;
|
||||
}
|
||||
|
||||
/// <param name="ShouldRetry">True when the failure was transient (timed out / network / parse error)
|
||||
@@ -80,7 +83,7 @@ public sealed class MessageHistoryImporter
|
||||
|
||||
MessageBackupKey key = BackupKey.ForLinkAndSync(ephemeral, aci);
|
||||
BackupContents backup = BackupReader.Read(file, key);
|
||||
BackupImporter.ImportSummary summary = new BackupImporter(_messages, _contacts, _account.Aci).Import(backup);
|
||||
BackupImporter.ImportSummary summary = new BackupImporter(_messages, _contacts, _account.Aci, _profileKeys).Import(backup);
|
||||
|
||||
FileLog.Write($"link'n'sync: imported {summary.Messages} message(s), {summary.Contacts} contact(s)");
|
||||
return new Result(true, summary, $"imported {summary.Messages} messages, {summary.Contacts} contacts");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<!-- Windows-only in practice: the local stores use DPAPI (ProtectedData). Declaring the platform
|
||||
here keeps CA1416 quiet for the store callers, matching the app + test projects. -->
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Wingnal.Service</RootNamespace>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
using Wingnal.Service.Crypto;
|
||||
using Xunit;
|
||||
|
||||
namespace Wingnal.Tests.Crypto;
|
||||
|
||||
public class ProfileCipherTests
|
||||
{
|
||||
// Builds a Signal profile-name blob: nonce(12) || AES-256-GCM(profileKey, nonce, padded) || tag(16),
|
||||
// base64-encoded — the same layout the profile endpoint returns in its "name" field.
|
||||
private static string EncryptName(byte[] profileKey, string given, string family, int bucket = 53)
|
||||
{
|
||||
byte[] givenBytes = Encoding.UTF8.GetBytes(given);
|
||||
byte[] familyBytes = Encoding.UTF8.GetBytes(family);
|
||||
var padded = new byte[bucket]; // zero-filled → trailing padding
|
||||
Array.Copy(givenBytes, 0, padded, 0, givenBytes.Length);
|
||||
int familyStart = givenBytes.Length + 1; // one NUL separates given/family
|
||||
Array.Copy(familyBytes, 0, padded, familyStart, familyBytes.Length);
|
||||
|
||||
byte[] nonce = RandomNumberGenerator.GetBytes(12);
|
||||
byte[] ciphertextAndTag = CryptoPrimitives.AesGcmEncrypt(profileKey, nonce, padded);
|
||||
var blob = new byte[nonce.Length + ciphertextAndTag.Length];
|
||||
Array.Copy(nonce, 0, blob, 0, nonce.Length);
|
||||
Array.Copy(ciphertextAndTag, 0, blob, nonce.Length, ciphertextAndTag.Length);
|
||||
return Convert.ToBase64String(blob);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecryptName_RoundTripsGivenAndFamily()
|
||||
{
|
||||
byte[] key = RandomNumberGenerator.GetBytes(32);
|
||||
string blob = EncryptName(key, "Alice", "Smith");
|
||||
|
||||
Assert.Equal("Alice Smith", ProfileCipher.DecryptName(key, blob));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecryptName_GivenNameOnly_HasNoTrailingSpace()
|
||||
{
|
||||
byte[] key = RandomNumberGenerator.GetBytes(32);
|
||||
string blob = EncryptName(key, "Bob", "");
|
||||
|
||||
Assert.Equal("Bob", ProfileCipher.DecryptName(key, blob));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecryptName_WrongKey_ReturnsNull()
|
||||
{
|
||||
byte[] key = RandomNumberGenerator.GetBytes(32);
|
||||
byte[] otherKey = RandomNumberGenerator.GetBytes(32);
|
||||
string blob = EncryptName(key, "Alice", "Smith");
|
||||
|
||||
Assert.Null(ProfileCipher.DecryptName(otherKey, blob)); // GCM tag mismatch
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
[InlineData("not-valid-base64!!")]
|
||||
[InlineData("QUJD")] // valid base64 but far too short to hold a nonce + tag
|
||||
public void DecryptName_InvalidInput_ReturnsNull(string? input)
|
||||
{
|
||||
byte[] key = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
Assert.Null(ProfileCipher.DecryptName(key, input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecryptName_WrongKeyLength_ReturnsNull()
|
||||
{
|
||||
byte[] shortKey = RandomNumberGenerator.GetBytes(16);
|
||||
byte[] realKey = RandomNumberGenerator.GetBytes(32);
|
||||
string blob = EncryptName(realKey, "Alice", "Smith");
|
||||
|
||||
Assert.Null(ProfileCipher.DecryptName(shortKey, blob));
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,9 @@ namespace Wingnal.Tests.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Live diagnostic: loads the persisted account and connects the authenticated chat socket for a few
|
||||
/// seconds, letting ChatReceiver log connect/frames/close to wingnal.log. Excluded from default runs.
|
||||
/// Run: dotnet test --filter "Category=Live". Requires a linked account on this machine.
|
||||
/// seconds, letting ChatReceiver log connect/frames/close to wingnal.log. Skipped by default because it
|
||||
/// logs in as the real account — which disconnects the running app ("Connected elsewhere") and spams its
|
||||
/// log. To run it manually, remove the Skip below. Requires a linked account on this machine.
|
||||
/// </summary>
|
||||
[Trait("Category", "Live")]
|
||||
public class LiveChatConnectTests
|
||||
@@ -17,7 +18,7 @@ public class LiveChatConnectTests
|
||||
private readonly ITestOutputHelper _output;
|
||||
public LiveChatConnectTests(ITestOutputHelper output) => _output = output;
|
||||
|
||||
[Fact]
|
||||
[Fact(Skip = "Live diagnostic — connects as the real account. Remove this Skip to run manually.")]
|
||||
public async Task ConnectsAuthenticatedSocket_AndLogs()
|
||||
{
|
||||
// The packaged WinUI app redirects LocalApplicationData into its package container.
|
||||
|
||||
+38
-17
@@ -73,7 +73,7 @@
|
||||
<!-- ════ Conversation sidebar ════ -->
|
||||
<Grid x:Name="SidebarPane" Grid.Column="0" Background="{ThemeResource LayerFillColorDefaultBrush}"
|
||||
BorderThickness="0,0,1,0" BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||
Padding="12,8,12,12" RowSpacing="10">
|
||||
Padding="6,8,6,12" RowSpacing="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
@@ -98,28 +98,40 @@
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- Right padding reserves a gutter so the scrollbar doesn't overlap the rows. -->
|
||||
<ListView Grid.Row="1" x:Name="ConversationList" SelectionMode="Single"
|
||||
SelectionChanged="OnConversationSelected" Padding="0,2">
|
||||
<ListView.Resources>
|
||||
<!-- Clean selected row: a subtle accent-tinted rounded fill — no left "pill", no grey box. -->
|
||||
<x:Boolean x:Key="ListViewItemSelectionIndicatorVisualEnabled">False</x:Boolean>
|
||||
<CornerRadius x:Key="ListViewItemCornerRadius">8</CornerRadius>
|
||||
<SolidColorBrush x:Key="ListViewItemBackgroundSelected"
|
||||
Color="{ThemeResource SystemAccentColor}" Opacity="0.16" />
|
||||
<SolidColorBrush x:Key="ListViewItemBackgroundSelectedPointerOver"
|
||||
Color="{ThemeResource SystemAccentColor}" Opacity="0.22" />
|
||||
<SolidColorBrush x:Key="ListViewItemBackgroundSelectedPressed"
|
||||
Color="{ThemeResource SystemAccentColor}" Opacity="0.12" />
|
||||
</ListView.Resources>
|
||||
SelectionChanged="OnConversationSelected" Padding="0,2,8,2">
|
||||
<ListView.ItemContainerStyle>
|
||||
<!-- The container is a bare, transparent content host: the row's own fill (hover/
|
||||
selected) is drawn by the Border in the item template, so it always wraps the
|
||||
whole row. Margin is the gap between rows; the row's inner padding lives on that
|
||||
Border. Stretch so the Border fills the full row width. -->
|
||||
<Style TargetType="ListViewItem">
|
||||
<Setter Property="Padding" Value="8,6" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Margin" Value="0,2" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Margin" Value="0,1" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListViewItem">
|
||||
<ContentPresenter Background="Transparent"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
VerticalContentAlignment="Stretch" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListView.ItemContainerStyle>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="local:ConversationItem">
|
||||
<!-- This Border is the row fill: it wraps every component, so the hover/selected
|
||||
grey always contains the avatar, text, time and unread dot. -->
|
||||
<Border Background="{x:Bind RowBackground, Mode=OneWay}" CornerRadius="8"
|
||||
Padding="12,10"
|
||||
PointerEntered="OnConversationPointerEntered"
|
||||
PointerExited="OnConversationPointerExited">
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ContextFlyout>
|
||||
<MenuFlyout>
|
||||
@@ -142,20 +154,29 @@
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{x:Bind Title, Mode=OneWay}"
|
||||
FontWeight="{x:Bind TitleFontWeight, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<InfoBadge Grid.Column="1" Value="{x:Bind UnreadCount, Mode=OneWay}"
|
||||
Visibility="{x:Bind UnreadVisibility, Mode=OneWay}"
|
||||
<!-- Right-aligned time, then a small accent dot when there are unread messages. -->
|
||||
<TextBlock Grid.Column="1" Text="{x:Bind TimeLabel, Mode=OneWay}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
<Ellipse Grid.Column="2" Width="8" Height="8" VerticalAlignment="Center"
|
||||
Fill="{ThemeResource AccentFillColorDefaultBrush}"
|
||||
Visibility="{x:Bind UnreadVisibility, Mode=OneWay}"
|
||||
ToolTipService.ToolTip="Unread" />
|
||||
</Grid>
|
||||
<TextBlock Text="{x:Bind Preview, Mode=OneWay}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
||||
TextWrapping="NoWrap" MaxLines="1"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
|
||||
@@ -41,6 +41,7 @@ namespace Wingnal
|
||||
private readonly MessageStore _store = new();
|
||||
private readonly ContactsStore _contacts = new();
|
||||
private readonly ProfileKeyStore _profileKeys = new();
|
||||
private readonly ProfileNameStore _profileNames = new(); // names fetched+decrypted from profiles
|
||||
private readonly SqliteSenderKeyStore _senderKeys = new(); // GroupsV2 G1 receive
|
||||
private readonly Wingnal.Service.Groups.GroupStore _groups = new(); // GroupsV2 group state
|
||||
private readonly Wingnal.Service.Attachments.AttachmentService _attachments = new();
|
||||
@@ -49,6 +50,7 @@ namespace Wingnal
|
||||
private CancellationTokenSource? _importCts;
|
||||
private SignalAccount? _account;
|
||||
private SignalRestClient? _rest;
|
||||
private ProfileService? _profiles;
|
||||
private SyncProcessor? _syncProcessor;
|
||||
// One durable per-peer session store shared by BOTH send and receive (sessions keyed by peer
|
||||
// address, so a single store serves the whole conversation without initiator/responder clobber).
|
||||
@@ -129,7 +131,7 @@ namespace Wingnal
|
||||
_account = account;
|
||||
_protocolStore = new SqliteSignalProtocolStore(account, "protocol.db",
|
||||
onChanged: () => new AccountStore().Save(account));
|
||||
_syncProcessor = new SyncProcessor(_contacts);
|
||||
_syncProcessor = new SyncProcessor(_contacts, _profileKeys);
|
||||
|
||||
_store.Deduplicate(); // clean up any duplicate rows left by an earlier double-import
|
||||
|
||||
@@ -151,6 +153,10 @@ namespace Wingnal
|
||||
_ = StartReceiveAsync(account, _protocolStore, _cts.Token);
|
||||
// Ask the primary to push contacts/blocked/configuration so the list can show names.
|
||||
_ = RequestSyncAsync(account, _protocolStore, _cts.Token);
|
||||
// For conversations we still can't name from synced contacts, resolve a name from each
|
||||
// peer's Signal profile (best-effort; refreshes titles as names come back).
|
||||
foreach (ConversationItem item in _conversations)
|
||||
MaybeResolveProfile(item.Peer);
|
||||
// If this device was just re-linked with link+sync, show the import screen + backfill once.
|
||||
if (account.EphemeralBackupKey is { Length: 32 })
|
||||
ImportOverlay.Visibility = Visibility.Visible;
|
||||
@@ -171,10 +177,27 @@ namespace Wingnal
|
||||
|
||||
private void OnConversationSelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
// Keep each row's IsSelected in sync so its Border draws the selected fill.
|
||||
foreach (object removed in e.RemovedItems)
|
||||
if (removed is ConversationItem prev) prev.IsSelected = false;
|
||||
foreach (object added in e.AddedItems)
|
||||
if (added is ConversationItem next) next.IsSelected = true;
|
||||
|
||||
if (ConversationList.SelectedItem is not ConversationItem item) return;
|
||||
SelectPeer(item.Peer, item.Title);
|
||||
}
|
||||
|
||||
// Hover state drives the row's subtle fill (the Border reads IsHovered via RowBackground).
|
||||
private void OnConversationPointerEntered(object sender, PointerRoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: ConversationItem item }) item.IsHovered = true;
|
||||
}
|
||||
|
||||
private void OnConversationPointerExited(object sender, PointerRoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: ConversationItem item }) item.IsHovered = false;
|
||||
}
|
||||
|
||||
private void SelectPeer(string peer, string title)
|
||||
{
|
||||
_selectedPeer = peer;
|
||||
@@ -566,7 +589,7 @@ namespace Wingnal
|
||||
try
|
||||
{
|
||||
_rest ??= new SignalRestClient();
|
||||
var importer = new MessageHistoryImporter(account, _rest, _store, _contacts);
|
||||
var importer = new MessageHistoryImporter(account, _rest, _store, _contacts, _profileKeys);
|
||||
|
||||
MessageHistoryImporter.Result result = await Task.Run(
|
||||
() => importer.ImportAsync(ct: _importCts.Token), _importCts.Token);
|
||||
@@ -714,7 +737,10 @@ namespace Wingnal
|
||||
if (!stored.Outgoing && !IsGroupKey(stored.Peer)
|
||||
&& !string.Equals(stored.Peer, _account?.Aci, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrEmpty(_contacts.NameFor(stored.Peer)))
|
||||
{
|
||||
MaybeResyncContacts();
|
||||
MaybeResolveProfile(stored.Peer); // try their Signal profile name too
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -750,8 +776,14 @@ namespace Wingnal
|
||||
{
|
||||
if (_syncProcessor is null) return;
|
||||
await _syncProcessor.ProcessAsync(sync).ConfigureAwait(false);
|
||||
// Contacts may now have names — refresh conversation titles on the UI thread.
|
||||
_dispatcher.TryEnqueue(RefreshTitles);
|
||||
// Contacts may now have names (and profile keys). Refresh titles, then try to resolve a
|
||||
// profile name for anyone we still can't name — the sync may have just given us their key.
|
||||
_dispatcher.TryEnqueue(() =>
|
||||
{
|
||||
RefreshTitles();
|
||||
foreach (ConversationItem item in _conversations)
|
||||
MaybeResolveProfile(item.Peer);
|
||||
});
|
||||
}
|
||||
|
||||
// ── delivery/read receipts + typing ──
|
||||
@@ -945,10 +977,49 @@ namespace Wingnal
|
||||
string? title = _groups.Load(gid)?.Group.Title;
|
||||
return string.IsNullOrWhiteSpace(title) ? $"Group {gid[..Math.Min(8, gid.Length)]}" : title;
|
||||
}
|
||||
// Prefer the primary's synced/imported name; else a name we fetched+decrypted from the peer's
|
||||
// Signal profile; else their phone number; else a "Note to Self"/shortened-ACI placeholder.
|
||||
string? name = _contacts.NameFor(peer);
|
||||
return string.IsNullOrWhiteSpace(name)
|
||||
? ConversationItem.TitleFor(peer, _account?.Aci ?? "")
|
||||
: name;
|
||||
if (!string.IsNullOrWhiteSpace(name)) return name;
|
||||
string? profileName = _profileNames.Get(peer);
|
||||
if (!string.IsNullOrWhiteSpace(profileName)) return profileName;
|
||||
string? number = _contacts.NumberFor(peer);
|
||||
if (!string.IsNullOrWhiteSpace(number)) return number;
|
||||
return ConversationItem.TitleFor(peer, _account?.Aci ?? "");
|
||||
}
|
||||
|
||||
/// <summary>Lazily builds the profile-name resolver (shares the on-demand REST client).</summary>
|
||||
private ProfileService? EnsureProfiles()
|
||||
{
|
||||
if (_account is null) return null;
|
||||
_rest ??= new SignalRestClient();
|
||||
return _profiles ??= new ProfileService(_rest, _profileKeys, _profileNames, _account.BasicAuthToken());
|
||||
}
|
||||
|
||||
/// <summary>If we can't name <paramref name="peer"/> from synced contacts and haven't already
|
||||
/// resolved a profile name, fetch+decrypt one in the background and refresh titles on success.
|
||||
/// Best-effort: no key / inaccessible profile / decrypt failure just leaves the placeholder.</summary>
|
||||
private void MaybeResolveProfile(string peer)
|
||||
{
|
||||
if (IsGroupKey(peer)) return;
|
||||
if (string.Equals(peer, _account?.Aci, StringComparison.OrdinalIgnoreCase)) return;
|
||||
if (!string.IsNullOrWhiteSpace(_contacts.NameFor(peer))) return; // address-book name wins
|
||||
if (!string.IsNullOrWhiteSpace(_profileNames.Get(peer))) return; // already resolved
|
||||
ProfileService? profiles = EnsureProfiles();
|
||||
CancellationTokenSource? cts = _cts;
|
||||
if (profiles is null || cts is null) return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
string? name = await profiles.ResolveAsync(peer, cts.Token).ConfigureAwait(false);
|
||||
if (name is not null) _dispatcher.TryEnqueue(RefreshTitles);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FileLog.Write($"profile: resolve {peer} failed {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── GroupsV2 conversation helpers ──
|
||||
|
||||
@@ -68,7 +68,22 @@ namespace Wingnal
|
||||
public long LastTimestamp
|
||||
{
|
||||
get => _lastTimestamp;
|
||||
set { _lastTimestamp = value; OnChanged(nameof(Subtitle)); }
|
||||
set { _lastTimestamp = value; OnChanged(nameof(Subtitle)); OnChanged(nameof(TimeLabel)); }
|
||||
}
|
||||
|
||||
/// <summary>Compact right-aligned timestamp for the row, Teams/Outlook-style: a time today, a
|
||||
/// weekday within the last week, else a short date. Empty when there's no activity yet.</summary>
|
||||
public string TimeLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_lastTimestamp == 0) return "";
|
||||
DateTime dt = DateTimeOffset.FromUnixTimeMilliseconds(_lastTimestamp).LocalDateTime;
|
||||
DateTime today = DateTime.Now.Date;
|
||||
if (dt.Date == today) return dt.ToString("t"); // today → 3:45 PM
|
||||
if ((today - dt.Date).TotalDays < 7) return dt.ToString("ddd"); // this week → Mon
|
||||
return dt.ToString("M/d/yyyy"); // else → 8/29/2024
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Initials/avatar glyph — first character of the title.</summary>
|
||||
@@ -77,6 +92,40 @@ namespace Wingnal
|
||||
/// <summary>Stable avatar colour for this peer.</summary>
|
||||
public Brush AvatarBrush => BrushFor(Peer);
|
||||
|
||||
private bool _isSelected;
|
||||
private bool _isHovered;
|
||||
|
||||
/// <summary>True while this row is the selected conversation (kept in sync by the page).</summary>
|
||||
public bool IsSelected
|
||||
{
|
||||
get => _isSelected;
|
||||
set { if (_isSelected == value) return; _isSelected = value; OnChanged(nameof(RowBackground)); }
|
||||
}
|
||||
|
||||
/// <summary>True while the pointer is over this row.</summary>
|
||||
public bool IsHovered
|
||||
{
|
||||
get => _isHovered;
|
||||
set { if (_isHovered == value) return; _isHovered = value; OnChanged(nameof(RowBackground)); }
|
||||
}
|
||||
|
||||
private static readonly SolidColorBrush TransparentBrush = new(Color.FromArgb(0, 0, 0, 0));
|
||||
|
||||
/// <summary>The row's fill brush, wrapping the whole row so it always contains the avatar, text
|
||||
/// and unread dot: a raised neutral surface when selected, a subtle fill on hover, else transparent.
|
||||
/// (Drawn by the row template's Border, not the ListViewItemPresenter, whose fill sizing/rounding
|
||||
/// is unreliable in this Windows App SDK.)</summary>
|
||||
public Brush RowBackground
|
||||
{
|
||||
get
|
||||
{
|
||||
string? key = _isSelected ? "ControlFillColorSecondaryBrush"
|
||||
: _isHovered ? "SubtleFillColorSecondaryBrush"
|
||||
: null;
|
||||
return key is not null && Application.Current.Resources[key] is Brush b ? b : TransparentBrush;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deterministic avatar colour for a peer key (shared by the list rows and the thread
|
||||
/// header so the same contact is always the same colour). Cached per palette slot. In High
|
||||
/// Contrast, custom colours break the system's contrast guarantees, so every avatar uses the
|
||||
|
||||
@@ -41,7 +41,8 @@ namespace Wingnal
|
||||
{
|
||||
var accountStore = new AccountStore();
|
||||
using var rest = new SignalRestClient();
|
||||
var linker = new LinkingManager(accountStore, rest);
|
||||
// Shown in Signal's linked-devices list, e.g. "Wingnal: DESKTOP-1234".
|
||||
var linker = new LinkingManager(accountStore, rest, $"Wingnal: {Environment.MachineName}");
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<Identity
|
||||
Name="cdb9e5d5-57cc-4e6a-954d-85bc9da6892e"
|
||||
Publisher="CN=micro"
|
||||
Version="1.0.1.0" />
|
||||
Version="1.0.2.0" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="cdb9e5d5-57cc-4e6a-954d-85bc9da6892e" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ namespace Wingnal
|
||||
try { new MessageStore().Clear(); } catch { }
|
||||
try { new ContactsStore().Clear(); } catch { }
|
||||
try { new ProfileKeyStore().Clear(); } catch { }
|
||||
try { new ProfileNameStore().Clear(); } catch { } // resolved profile names
|
||||
try { new SqliteSenderKeyStore().Clear(); } catch { } // group sender keys (GroupsV2)
|
||||
try { new Wingnal.Service.Groups.GroupStore().Clear(); } catch { } // group state (GroupsV2)
|
||||
new AccountStore().Delete();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media.Animation;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
|
||||
namespace Wingnal
|
||||
@@ -59,9 +60,11 @@ namespace Wingnal
|
||||
// The title-bar contact search belongs to Chats only.
|
||||
App.SearchVisibilitySink?.Invoke(target == typeof(ChatPage));
|
||||
|
||||
// Avoid re-navigating to the page we're already on.
|
||||
// Avoid re-navigating to the page we're already on. Suppress the frame's content
|
||||
// transition: its entrance animation competes with the rail's selection-indicator
|
||||
// animation on the UI thread, which made the indicator stutter between tabs.
|
||||
if (target is not null && ContentFrame.CurrentSourcePageType != target)
|
||||
ContentFrame.Navigate(target);
|
||||
ContentFrame.Navigate(target, null, new SuppressNavigationTransitionInfo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user