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");
|
if (_absorbing) throw new InvalidOperationException("ShoSha256: must ratchet before squeezing");
|
||||||
var output = new byte[outlen];
|
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++)
|
for (int i = 0; i * HashLen < outlen; i++)
|
||||||
{
|
{
|
||||||
using var h = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
using var h = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||||
h.AppendData(new byte[BlockLen - 1]); // 63 zero bytes
|
h.AppendData(new byte[BlockLen - 1]); // 63 zero bytes
|
||||||
h.AppendData(new byte[] { 0x01 });
|
h.AppendData(new byte[] { 0x01 });
|
||||||
h.AppendData(_cv);
|
h.AppendData(_cv);
|
||||||
Span<byte> ctr = stackalloc byte[8];
|
|
||||||
BinaryPrimitives.WriteUInt64BigEndian(ctr, (ulong)i);
|
BinaryPrimitives.WriteUInt64BigEndian(ctr, (ulong)i);
|
||||||
h.AppendData(ctr);
|
h.AppendData(ctr);
|
||||||
byte[] digest = h.GetHashAndReset();
|
byte[] digest = h.GetHashAndReset();
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public static class CredentialSystem
|
|||||||
|
|
||||||
public sealed class SystemParams
|
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 Ristretto255[] GY = new Ristretto255[NumSupportedAttrs];
|
||||||
|
|
||||||
public static readonly SystemParams Hardcoded = Generate();
|
public static readonly SystemParams Hardcoded = Generate();
|
||||||
@@ -84,7 +85,7 @@ public sealed class Credential
|
|||||||
public sealed class CredentialPrivateKey
|
public sealed class CredentialPrivateKey
|
||||||
{
|
{
|
||||||
public Scalar25519 W, Wprime, X0, X1;
|
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 Scalar25519[] Y = new Scalar25519[CredentialSystem.NumSupportedAttrs];
|
||||||
|
|
||||||
public static CredentialPrivateKey Generate(byte[] randomness)
|
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>
|
/// <summary>The server's public credential key the client uses to receive + present credentials.</summary>
|
||||||
public sealed class CredentialPublicKey
|
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
|
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>
|
/// <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;
|
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),
|
/// <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>
|
/// ordered by inbox position then name. Filtered in memory because the columns are encrypted.</summary>
|
||||||
public IReadOnlyList<Contact> Search(string? query, int limit = 25)
|
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
|
public sealed class SyncProcessor
|
||||||
{
|
{
|
||||||
private readonly ContactsStore _contacts;
|
private readonly ContactsStore _contacts;
|
||||||
|
private readonly ProfileKeyStore? _profileKeys;
|
||||||
private readonly AttachmentDownloader _downloader;
|
private readonly AttachmentDownloader _downloader;
|
||||||
|
|
||||||
/// <summary>Raised for each read receipt synced from the primary (senderAci, message timestamp).</summary>
|
/// <summary>Raised for each read receipt synced from the primary (senderAci, message timestamp).</summary>
|
||||||
public event Action<string, long>? ReadReceiptReceived;
|
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;
|
_contacts = contacts;
|
||||||
|
_profileKeys = profileKeys;
|
||||||
_downloader = downloader ?? new AttachmentDownloader();
|
_downloader = downloader ?? new AttachmentDownloader();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ public sealed class SyncProcessor
|
|||||||
/// (no network) — the offline-testable core of contacts sync.</summary>
|
/// (no network) — the offline-testable core of contacts sync.</summary>
|
||||||
public int ImportContacts(byte[] blob)
|
public int ImportContacts(byte[] blob)
|
||||||
{
|
{
|
||||||
int count = 0;
|
int count = 0, keys = 0;
|
||||||
foreach (ContactRecord record in ContactRecordStream.Parse(blob))
|
foreach (ContactRecord record in ContactRecordStream.Parse(blob))
|
||||||
{
|
{
|
||||||
string? aci = AciOf(record.Details.Aci, record.Details.AciBinary);
|
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? name = string.IsNullOrWhiteSpace(record.Details.Name) ? null : record.Details.Name;
|
||||||
string? number = string.IsNullOrWhiteSpace(record.Details.Number) ? null : record.Details.Number;
|
string? number = string.IsNullOrWhiteSpace(record.Details.Number) ? null : record.Details.Number;
|
||||||
_contacts.Upsert(new Contact(aci, number, name, (int)record.Details.InboxPosition));
|
_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++;
|
count++;
|
||||||
}
|
}
|
||||||
|
FileLog.Write($"sync: imported {count} contact(s), {keys} profile key(s)");
|
||||||
return count;
|
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");
|
?? 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
|
/// <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>
|
/// 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(
|
public async Task<(bool Ok, HttpStatusCode Status, string Body)> SendMessagesAsync(
|
||||||
|
|||||||
@@ -944,7 +944,7 @@ message ContactDetails {
|
|||||||
optional Avatar avatar = 3;
|
optional Avatar avatar = 3;
|
||||||
reserved /* color */ 4;
|
reserved /* color */ 4;
|
||||||
reserved /* verified */ 5;
|
reserved /* verified */ 5;
|
||||||
reserved /* profileKey */ 6;
|
optional bytes profileKey = 6; // used to fetch + decrypt the contact's profile name
|
||||||
reserved /* blocked */ 7;
|
reserved /* blocked */ 7;
|
||||||
optional uint32 expireTimer = 8;
|
optional uint32 expireTimer = 8;
|
||||||
optional uint32 expireTimerVersion = 12;
|
optional uint32 expireTimerVersion = 12;
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ public sealed class BackupImporter
|
|||||||
{
|
{
|
||||||
private readonly MessageStore _messages;
|
private readonly MessageStore _messages;
|
||||||
private readonly ContactsStore _contacts;
|
private readonly ContactsStore _contacts;
|
||||||
|
private readonly ProfileKeyStore? _profileKeys;
|
||||||
private readonly string _ownAci;
|
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;
|
_messages = messages;
|
||||||
_contacts = contacts;
|
_contacts = contacts;
|
||||||
|
_profileKeys = profileKeys;
|
||||||
_ownAci = ownAci.ToLowerInvariant();
|
_ownAci = ownAci.ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +59,13 @@ public sealed class BackupImporter
|
|||||||
recipients[r.Id] = new ResolvedPeer(aci, name);
|
recipients[r.Id] = new ResolvedPeer(aci, name);
|
||||||
string? number = r.Contact.E164 != 0 ? "+" + r.Contact.E164 : null;
|
string? number = r.Contact.E164 != 0 ? "+" + r.Contact.E164 : null;
|
||||||
_contacts.Upsert(new StoreContact(aci, number, name, InboxPosition: 0));
|
_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++;
|
contactCount++;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -24,13 +24,16 @@ public sealed class MessageHistoryImporter
|
|||||||
private readonly SignalRestClient _rest;
|
private readonly SignalRestClient _rest;
|
||||||
private readonly MessageStore _messages;
|
private readonly MessageStore _messages;
|
||||||
private readonly ContactsStore _contacts;
|
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;
|
_account = account;
|
||||||
_rest = rest;
|
_rest = rest;
|
||||||
_messages = messages;
|
_messages = messages;
|
||||||
_contacts = contacts;
|
_contacts = contacts;
|
||||||
|
_profileKeys = profileKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <param name="ShouldRetry">True when the failure was transient (timed out / network / parse error)
|
/// <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);
|
MessageBackupKey key = BackupKey.ForLinkAndSync(ephemeral, aci);
|
||||||
BackupContents backup = BackupReader.Read(file, key);
|
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)");
|
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");
|
return new Result(true, summary, $"imported {summary.Messages} messages, {summary.Contacts} contacts");
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<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>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>Wingnal.Service</RootNamespace>
|
<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>
|
/// <summary>
|
||||||
/// Live diagnostic: loads the persisted account and connects the authenticated chat socket for a few
|
/// 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.
|
/// seconds, letting ChatReceiver log connect/frames/close to wingnal.log. Skipped by default because it
|
||||||
/// Run: dotnet test --filter "Category=Live". Requires a linked account on this machine.
|
/// 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>
|
/// </summary>
|
||||||
[Trait("Category", "Live")]
|
[Trait("Category", "Live")]
|
||||||
public class LiveChatConnectTests
|
public class LiveChatConnectTests
|
||||||
@@ -17,7 +18,7 @@ public class LiveChatConnectTests
|
|||||||
private readonly ITestOutputHelper _output;
|
private readonly ITestOutputHelper _output;
|
||||||
public LiveChatConnectTests(ITestOutputHelper output) => _output = 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()
|
public async Task ConnectsAuthenticatedSocket_AndLogs()
|
||||||
{
|
{
|
||||||
// The packaged WinUI app redirects LocalApplicationData into its package container.
|
// The packaged WinUI app redirects LocalApplicationData into its package container.
|
||||||
|
|||||||
+38
-17
@@ -73,7 +73,7 @@
|
|||||||
<!-- ════ Conversation sidebar ════ -->
|
<!-- ════ Conversation sidebar ════ -->
|
||||||
<Grid x:Name="SidebarPane" Grid.Column="0" Background="{ThemeResource LayerFillColorDefaultBrush}"
|
<Grid x:Name="SidebarPane" Grid.Column="0" Background="{ThemeResource LayerFillColorDefaultBrush}"
|
||||||
BorderThickness="0,0,1,0" BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
BorderThickness="0,0,1,0" BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||||
Padding="12,8,12,12" RowSpacing="10">
|
Padding="6,8,6,12" RowSpacing="10">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
@@ -98,28 +98,40 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Right padding reserves a gutter so the scrollbar doesn't overlap the rows. -->
|
||||||
<ListView Grid.Row="1" x:Name="ConversationList" SelectionMode="Single"
|
<ListView Grid.Row="1" x:Name="ConversationList" SelectionMode="Single"
|
||||||
SelectionChanged="OnConversationSelected" Padding="0,2">
|
SelectionChanged="OnConversationSelected" Padding="0,2,8,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>
|
|
||||||
<ListView.ItemContainerStyle>
|
<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">
|
<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="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>
|
</Style>
|
||||||
</ListView.ItemContainerStyle>
|
</ListView.ItemContainerStyle>
|
||||||
<ListView.ItemTemplate>
|
<ListView.ItemTemplate>
|
||||||
<DataTemplate x:DataType="local:ConversationItem">
|
<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 ColumnSpacing="12">
|
||||||
<Grid.ContextFlyout>
|
<Grid.ContextFlyout>
|
||||||
<MenuFlyout>
|
<MenuFlyout>
|
||||||
@@ -142,20 +154,29 @@
|
|||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Text="{x:Bind Title, Mode=OneWay}"
|
<TextBlock Text="{x:Bind Title, Mode=OneWay}"
|
||||||
FontWeight="{x:Bind TitleFontWeight, Mode=OneWay}"
|
FontWeight="{x:Bind TitleFontWeight, Mode=OneWay}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
<InfoBadge Grid.Column="1" Value="{x:Bind UnreadCount, Mode=OneWay}"
|
<!-- Right-aligned time, then a small accent dot when there are unread messages. -->
|
||||||
Visibility="{x:Bind UnreadVisibility, Mode=OneWay}"
|
<TextBlock Grid.Column="1" Text="{x:Bind TimeLabel, Mode=OneWay}"
|
||||||
|
Style="{StaticResource CaptionTextBlockStyle}"
|
||||||
|
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
||||||
VerticalAlignment="Center" />
|
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>
|
</Grid>
|
||||||
<TextBlock Text="{x:Bind Preview, Mode=OneWay}"
|
<TextBlock Text="{x:Bind Preview, Mode=OneWay}"
|
||||||
Style="{StaticResource CaptionTextBlockStyle}"
|
Style="{StaticResource CaptionTextBlockStyle}"
|
||||||
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
||||||
|
TextWrapping="NoWrap" MaxLines="1"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListView.ItemTemplate>
|
</ListView.ItemTemplate>
|
||||||
</ListView>
|
</ListView>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ namespace Wingnal
|
|||||||
private readonly MessageStore _store = new();
|
private readonly MessageStore _store = new();
|
||||||
private readonly ContactsStore _contacts = new();
|
private readonly ContactsStore _contacts = new();
|
||||||
private readonly ProfileKeyStore _profileKeys = 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 SqliteSenderKeyStore _senderKeys = new(); // GroupsV2 G1 receive
|
||||||
private readonly Wingnal.Service.Groups.GroupStore _groups = new(); // GroupsV2 group state
|
private readonly Wingnal.Service.Groups.GroupStore _groups = new(); // GroupsV2 group state
|
||||||
private readonly Wingnal.Service.Attachments.AttachmentService _attachments = new();
|
private readonly Wingnal.Service.Attachments.AttachmentService _attachments = new();
|
||||||
@@ -49,6 +50,7 @@ namespace Wingnal
|
|||||||
private CancellationTokenSource? _importCts;
|
private CancellationTokenSource? _importCts;
|
||||||
private SignalAccount? _account;
|
private SignalAccount? _account;
|
||||||
private SignalRestClient? _rest;
|
private SignalRestClient? _rest;
|
||||||
|
private ProfileService? _profiles;
|
||||||
private SyncProcessor? _syncProcessor;
|
private SyncProcessor? _syncProcessor;
|
||||||
// One durable per-peer session store shared by BOTH send and receive (sessions keyed by peer
|
// 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).
|
// address, so a single store serves the whole conversation without initiator/responder clobber).
|
||||||
@@ -129,7 +131,7 @@ namespace Wingnal
|
|||||||
_account = account;
|
_account = account;
|
||||||
_protocolStore = new SqliteSignalProtocolStore(account, "protocol.db",
|
_protocolStore = new SqliteSignalProtocolStore(account, "protocol.db",
|
||||||
onChanged: () => new AccountStore().Save(account));
|
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
|
_store.Deduplicate(); // clean up any duplicate rows left by an earlier double-import
|
||||||
|
|
||||||
@@ -151,6 +153,10 @@ namespace Wingnal
|
|||||||
_ = StartReceiveAsync(account, _protocolStore, _cts.Token);
|
_ = StartReceiveAsync(account, _protocolStore, _cts.Token);
|
||||||
// Ask the primary to push contacts/blocked/configuration so the list can show names.
|
// Ask the primary to push contacts/blocked/configuration so the list can show names.
|
||||||
_ = RequestSyncAsync(account, _protocolStore, _cts.Token);
|
_ = 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 this device was just re-linked with link+sync, show the import screen + backfill once.
|
||||||
if (account.EphemeralBackupKey is { Length: 32 })
|
if (account.EphemeralBackupKey is { Length: 32 })
|
||||||
ImportOverlay.Visibility = Visibility.Visible;
|
ImportOverlay.Visibility = Visibility.Visible;
|
||||||
@@ -171,10 +177,27 @@ namespace Wingnal
|
|||||||
|
|
||||||
private void OnConversationSelected(object sender, SelectionChangedEventArgs e)
|
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;
|
if (ConversationList.SelectedItem is not ConversationItem item) return;
|
||||||
SelectPeer(item.Peer, item.Title);
|
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)
|
private void SelectPeer(string peer, string title)
|
||||||
{
|
{
|
||||||
_selectedPeer = peer;
|
_selectedPeer = peer;
|
||||||
@@ -566,7 +589,7 @@ namespace Wingnal
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_rest ??= new SignalRestClient();
|
_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(
|
MessageHistoryImporter.Result result = await Task.Run(
|
||||||
() => importer.ImportAsync(ct: _importCts.Token), _importCts.Token);
|
() => importer.ImportAsync(ct: _importCts.Token), _importCts.Token);
|
||||||
@@ -714,7 +737,10 @@ namespace Wingnal
|
|||||||
if (!stored.Outgoing && !IsGroupKey(stored.Peer)
|
if (!stored.Outgoing && !IsGroupKey(stored.Peer)
|
||||||
&& !string.Equals(stored.Peer, _account?.Aci, StringComparison.OrdinalIgnoreCase)
|
&& !string.Equals(stored.Peer, _account?.Aci, StringComparison.OrdinalIgnoreCase)
|
||||||
&& string.IsNullOrEmpty(_contacts.NameFor(stored.Peer)))
|
&& string.IsNullOrEmpty(_contacts.NameFor(stored.Peer)))
|
||||||
|
{
|
||||||
MaybeResyncContacts();
|
MaybeResyncContacts();
|
||||||
|
MaybeResolveProfile(stored.Peer); // try their Signal profile name too
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,8 +776,14 @@ namespace Wingnal
|
|||||||
{
|
{
|
||||||
if (_syncProcessor is null) return;
|
if (_syncProcessor is null) return;
|
||||||
await _syncProcessor.ProcessAsync(sync).ConfigureAwait(false);
|
await _syncProcessor.ProcessAsync(sync).ConfigureAwait(false);
|
||||||
// Contacts may now have names — refresh conversation titles on the UI thread.
|
// Contacts may now have names (and profile keys). Refresh titles, then try to resolve a
|
||||||
_dispatcher.TryEnqueue(RefreshTitles);
|
// 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 ──
|
// ── delivery/read receipts + typing ──
|
||||||
@@ -945,10 +977,49 @@ namespace Wingnal
|
|||||||
string? title = _groups.Load(gid)?.Group.Title;
|
string? title = _groups.Load(gid)?.Group.Title;
|
||||||
return string.IsNullOrWhiteSpace(title) ? $"Group {gid[..Math.Min(8, gid.Length)]}" : 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);
|
string? name = _contacts.NameFor(peer);
|
||||||
return string.IsNullOrWhiteSpace(name)
|
if (!string.IsNullOrWhiteSpace(name)) return name;
|
||||||
? ConversationItem.TitleFor(peer, _account?.Aci ?? "")
|
string? profileName = _profileNames.Get(peer);
|
||||||
: name;
|
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 ──
|
// ── GroupsV2 conversation helpers ──
|
||||||
|
|||||||
@@ -68,7 +68,22 @@ namespace Wingnal
|
|||||||
public long LastTimestamp
|
public long LastTimestamp
|
||||||
{
|
{
|
||||||
get => _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>
|
/// <summary>Initials/avatar glyph — first character of the title.</summary>
|
||||||
@@ -77,6 +92,40 @@ namespace Wingnal
|
|||||||
/// <summary>Stable avatar colour for this peer.</summary>
|
/// <summary>Stable avatar colour for this peer.</summary>
|
||||||
public Brush AvatarBrush => BrushFor(Peer);
|
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
|
/// <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
|
/// 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
|
/// Contrast, custom colours break the system's contrast guarantees, so every avatar uses the
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ namespace Wingnal
|
|||||||
{
|
{
|
||||||
var accountStore = new AccountStore();
|
var accountStore = new AccountStore();
|
||||||
using var rest = new SignalRestClient();
|
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
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<Identity
|
<Identity
|
||||||
Name="cdb9e5d5-57cc-4e6a-954d-85bc9da6892e"
|
Name="cdb9e5d5-57cc-4e6a-954d-85bc9da6892e"
|
||||||
Publisher="CN=micro"
|
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"/>
|
<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 MessageStore().Clear(); } catch { }
|
||||||
try { new ContactsStore().Clear(); } catch { }
|
try { new ContactsStore().Clear(); } catch { }
|
||||||
try { new ProfileKeyStore().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 SqliteSenderKeyStore().Clear(); } catch { } // group sender keys (GroupsV2)
|
||||||
try { new Wingnal.Service.Groups.GroupStore().Clear(); } catch { } // group state (GroupsV2)
|
try { new Wingnal.Service.Groups.GroupStore().Clear(); } catch { } // group state (GroupsV2)
|
||||||
new AccountStore().Delete();
|
new AccountStore().Delete();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Media.Animation;
|
||||||
using Microsoft.UI.Xaml.Navigation;
|
using Microsoft.UI.Xaml.Navigation;
|
||||||
|
|
||||||
namespace Wingnal
|
namespace Wingnal
|
||||||
@@ -59,9 +60,11 @@ namespace Wingnal
|
|||||||
// The title-bar contact search belongs to Chats only.
|
// The title-bar contact search belongs to Chats only.
|
||||||
App.SearchVisibilitySink?.Invoke(target == typeof(ChatPage));
|
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)
|
if (target is not null && ContentFrame.CurrentSourcePageType != target)
|
||||||
ContentFrame.Navigate(target);
|
ContentFrame.Navigate(target, null, new SuppressNavigationTransitionInfo());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user