Public Access
Write down what the phone found, and stop it rotting
docs/android-port.md was an audit of work not started; it now says what is built. Three of its statements needed correcting rather than extending, and they are marked where they sit: the Android version question is settled and was never as open as it looked, because Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly and nothing lower can resolve it; cleartext to loopback has to be permitted explicitly, which the audit missed entirely; and the spike produced a structural change it did not anticipate, in DodoSSH.Client.Shell. A CI job of its own, because the head is deliberately not in DodoSSH.slnx and a project outside the solution is a project nobody notices breaking. It packages as well as builds: a native library with no Android ABI and an assembly that will not dex are both invisible to a compile, and both are exactly what this head is exposed to. The README says plainly that signing in is not built, that a fingerprint re-enrolment destroys the device key, that a notification appears while a shell is open, and that none of it has run on a device.
This commit is contained in:
@@ -53,3 +53,50 @@ jobs:
|
|||||||
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
||||||
# macOS, whose runners have no daemon at all. Expect the Keycloak image pull to
|
# macOS, whose runners have no daemon at all. Expect the Keycloak image pull to
|
||||||
# dominate a cold run.
|
# dominate a cold run.
|
||||||
|
android:
|
||||||
|
name: android head
|
||||||
|
runs-on: [linux]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
|
with:
|
||||||
|
global-json-file: global.json
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: '**/packages.lock.json'
|
||||||
|
|
||||||
|
# A job of its own, because DodoSSH.Client.Android is deliberately not in DodoSSH.slnx.
|
||||||
|
# Adding it there would make the android workload and a full Android SDK a prerequisite of
|
||||||
|
# `dotnet build DodoSSH.slnx` for everyone — including the build job above, which needs
|
||||||
|
# neither and would grow several minutes for a head it does not compile.
|
||||||
|
#
|
||||||
|
# The cost of keeping it out is that nothing in the main job would notice this head
|
||||||
|
# breaking, which for a project sharing view models with the desktop one is a matter of
|
||||||
|
# when rather than whether. This job is that notice.
|
||||||
|
- name: install the android workload
|
||||||
|
run: dotnet workload install android --skip-sign-check
|
||||||
|
|
||||||
|
# API 36 specifically, and it is not a preference: Avalonia.Controls.WebView ships only a
|
||||||
|
# net10.0-android36.0 assembly, so anything lower cannot resolve it and the head loses its
|
||||||
|
# terminal. See docs/android-port.md.
|
||||||
|
- name: install the android sdk platform
|
||||||
|
run: |
|
||||||
|
echo "y" | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
|
"platforms;android-36" "build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: restore
|
||||||
|
run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: >
|
||||||
|
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
|
||||||
|
--no-restore --configuration Release
|
||||||
|
|
||||||
|
# Packaging rather than only compiling, because the two failures this head is most exposed to
|
||||||
|
# are both link-time: a native library with no android ABI, and a managed assembly that
|
||||||
|
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
|
||||||
|
- name: package
|
||||||
|
run: >
|
||||||
|
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
|
||||||
|
--no-restore --configuration Release
|
||||||
|
-t:SignAndroidPackage -p:RuntimeIdentifier=android-arm64
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ zero-knowledge.
|
|||||||
| Component | Choice |
|
| Component | Choice |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Backend | ASP.NET Core on .NET 10, PostgreSQL + EF Core |
|
| Backend | ASP.NET Core on .NET 10, PostgreSQL + EF Core |
|
||||||
| Client | Avalonia (C#) for Windows/Linux/macOS; terminal pane is a WebView running xterm.js |
|
| Client | Avalonia (C#): a desktop head for Windows/Linux/macOS and a phone-first Android head, sharing one set of view models; terminal pane is a WebView running xterm.js |
|
||||||
| Auth | OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik) |
|
| Auth | OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik) |
|
||||||
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
|
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
|
||||||
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay |
|
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay |
|
||||||
@@ -70,18 +70,26 @@ src/
|
|||||||
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
||||||
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
|
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
|
||||||
DodoSSH.Client.Import reading ~/.ssh/config, with no I/O of its own
|
DodoSSH.Client.Import reading ~/.ssh/config, with no I/O of its own
|
||||||
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit
|
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
|
||||||
|
DodoSSH.Client.App the desktop head: its views, and its Windows integration
|
||||||
|
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
||||||
tests/ one test project per source project
|
tests/ one test project per source project
|
||||||
docs/adr/ architecture decision records
|
docs/adr/ architecture decision records
|
||||||
docs/design-import-gaps.md what the client's design asked for and this build has not got
|
docs/design-import-gaps.md what the client's design asked for and this build has not got
|
||||||
docs/platform-flags.md what differs off Windows, and the gotchas that have cost time
|
docs/platform-flags.md what differs off Windows, and the gotchas that have cost time
|
||||||
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
||||||
docs/android-port.md what an Android client would take, and what has been decided about it
|
docs/android-port.md the Android head: what was decided, what is built, what is left
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything under `src/DodoSSH.Client.*` except `App` is deliberately free of Avalonia. That is the
|
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||||
seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a UI
|
That is the seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a
|
||||||
toolkit or a browser engine — which is most of why they are testable at all.
|
UI toolkit or a browser engine — which is most of why they are testable at all.
|
||||||
|
|
||||||
|
`Shell` is the narrow exception and it earns it: what it takes from Avalonia is `Dispatcher`, the asset
|
||||||
|
loader and a resource dictionary, none of which imply a window, and what it holds is the shell's state
|
||||||
|
machine — which two heads have to agree on exactly rather than approximately. The rule's purpose was never
|
||||||
|
Avalonia-avoidance for its own sake; it was that the layers with the hard logic stay testable, and none of
|
||||||
|
them are here.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -250,6 +258,53 @@ Items are filed into one vault at a time. When more than one vault is writable,
|
|||||||
show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team
|
show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team
|
||||||
vault is visible to everybody in that team and moving it back means deleting and retyping.
|
vault is visible to everybody in that team and moving it back means deleting and retyping.
|
||||||
|
|
||||||
|
### The Android head
|
||||||
|
|
||||||
|
`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one. It is
|
||||||
|
**partly built**: what exists is the unlock screen, the platform integration under it, and a shell that
|
||||||
|
names each state it has not built yet rather than showing an empty screen. What it is for, what was decided
|
||||||
|
and what is left are in [`docs/android-port.md`](docs/android-port.md).
|
||||||
|
|
||||||
|
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
|
||||||
|
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
|
||||||
|
builds *and packages* it, because the two failures it is most exposed to — a native library with no Android
|
||||||
|
ABI, and an assembly that resolves but will not dex — are both invisible to a plain compile.
|
||||||
|
|
||||||
|
Building it needs the workload and **API 36 specifically**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet workload install android
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
API 36 is not a preference. `Avalonia.Controls.WebView` ships only a `net10.0-android36.0` assembly, so
|
||||||
|
anything lower cannot resolve it and the head loses its terminal. The floor is API 28, which is where
|
||||||
|
`BiometricPrompt` and StrongBox-backed keys exist without an AndroidX shim.
|
||||||
|
|
||||||
|
Three things about it are worth expecting rather than discovering.
|
||||||
|
|
||||||
|
**Signing in is not built yet, and that is the one real gap.** The desktop client receives the authorization
|
||||||
|
response on a loopback `TcpListener`; reusing that on a phone would be a security regression rather than a
|
||||||
|
shortcut, because on a shared device any other app can bind a loopback port — the attack RFC 8252 §8.3 names.
|
||||||
|
It needs an app link instead. A phone enrolled from the desktop client unlocks here perfectly well, because
|
||||||
|
**unlocking needs no network at all**, so this blocks first-run rather than the product.
|
||||||
|
|
||||||
|
**A fingerprint releases the device key, and re-enrolling a fingerprint destroys it.** The key is generated
|
||||||
|
with `setInvalidatedByBiometricEnrollment`, which is what stops somebody who can add their own fingerprint to
|
||||||
|
an unlocked phone from inheriting the vault. The cost is that adding a finger legitimately means typing the
|
||||||
|
passphrase again and re-registering — which the unlock screen treats as ordinary, because it is.
|
||||||
|
|
||||||
|
**A notification appears while a shell is open.** Android stops backgrounded processes, and the desktop
|
||||||
|
client's promise that locking the vault does not close your shells is only true here behind a foreground
|
||||||
|
service. The notification is the price of that promise; it goes when the last shell does.
|
||||||
|
|
||||||
|
**Nothing has been run on a device.** It compiles, links, packages, and carries the right native libraries
|
||||||
|
for arm64 — that is verified. Everything about its runtime behaviour is not, and `docs/android-port.md` says
|
||||||
|
which claims those are.
|
||||||
|
|
||||||
### End-to-end verification
|
### End-to-end verification
|
||||||
|
|
||||||
One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it
|
One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it
|
||||||
|
|||||||
+74
-22
@@ -1,8 +1,28 @@
|
|||||||
# An Android client: what it would take
|
# An Android client: what it would take
|
||||||
|
|
||||||
**Status: audited, scoped, not started.** No Android code exists. The four decisions that shape the work
|
**Status: started. Steps 3, 4 and 6 are done; the interface is next.** `src/DodoSSH.Client.Android` is a
|
||||||
have been taken and are recorded in [Decisions](#decisions-taken); everything else here is the audit they
|
real head that builds and packages. The four decisions that shape the work are recorded in
|
||||||
were taken against.
|
[Decisions](#decisions-taken); everything below them is the audit they were taken against, and it has held
|
||||||
|
up — with three corrections and one addition, all marked **✅ settled** or **⚠️ corrected** where they
|
||||||
|
belong.
|
||||||
|
|
||||||
|
**What the spike came back with**, since everything downstream was gated on it:
|
||||||
|
|
||||||
|
- **The cryptography works.** `libsodium.so` and `libe_sqlite3.so` are both in the arm64 APK. NSec
|
||||||
|
resolving its native half despite shipping no Android-specific build was the one dependency this
|
||||||
|
document said was worth proving with a build, and it proves out.
|
||||||
|
- **`Avalonia.Controls.WebView` forces `targetSdk` 36**, which settles the open question about Android
|
||||||
|
versions — see [Still open](#still-open). This head is minSdk 28 / targetSdk 36.
|
||||||
|
- **Cleartext to loopback has to be allowed explicitly.** Not in the original audit and easy to lose a day
|
||||||
|
to: Android has blocked plaintext HTTP by default since API 28, so the terminal renderer needs a network
|
||||||
|
security config scoped to `127.0.0.1` or the WebView loads nothing and the failure looks like a renderer
|
||||||
|
that never attached. See `Resources/xml/network_security_config.xml`.
|
||||||
|
|
||||||
|
**Still unverified, and it is the honest limit of all this:** nothing has run on a device or an emulator.
|
||||||
|
No system image is installed here, so every statement below about *runtime* behaviour — Argon2id actually
|
||||||
|
completing at the 256 MiB profile on a phone, the WebView actually loading the renderer, the biometric
|
||||||
|
prompt actually releasing the key — remains reasoning from the code and the platform's rules. What is
|
||||||
|
verified is that it compiles, links, packages, and carries the right natives.
|
||||||
|
|
||||||
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
|
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
|
||||||
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
||||||
@@ -398,26 +418,49 @@ go at 360dp:
|
|||||||
1. ~~Decide the product questions.~~ **Done** — see [Decisions](#decisions-taken).
|
1. ~~Decide the product questions.~~ **Done** — see [Decisions](#decisions-taken).
|
||||||
2. ~~Move `WindowsDeviceKeyStore` into the desktop head.~~ **Done** — `DodoSSH.Client.Session` is now free
|
2. ~~Move `WindowsDeviceKeyStore` into the desktop head.~~ **Done** — `DodoSSH.Client.Session` is now free
|
||||||
of Windows APIs entirely.
|
of Windows APIs entirely.
|
||||||
3. **The spike, and it answers two questions at once.** One throwaway Android head that unlocks a vault from
|
3. ~~**The spike.**~~ **Done.** It came back clean — see the summary at the top. The throwaway screen has
|
||||||
a passphrase, then puts a WebView on the screen pointing at the terminal data plane. The first half
|
been deleted; what it proved is now load-bearing in the head itself.
|
||||||
settles NSec-on-Android, libsodium's native resolution, SQLite and whether the sync stack runs; the second
|
|
||||||
settles whether the terminal is possible at all, which the scope decision now depends on. Both are cheap
|
It also produced the one structural change the audit did not anticipate. The interface is a redesign, but
|
||||||
and both are gates — nothing after this is worth starting until it comes back.
|
*what survives it* — every view model, command and piece of state — had to be reachable from two heads,
|
||||||
4. **Android device key store**, with biometric or device-credential release. A straightforward
|
and it was sitting inside the desktop one. So **`DodoSSH.Client.Shell` is new**: the view models, the
|
||||||
implementation of `IDeviceKeyStore`, and the piece of platform integration most clearly worth doing well:
|
terminal renderer's files and the palette moved there, and both heads reference it. It is the one place
|
||||||
the Android Keystore is a closer match to what the unlock screen wants than the Windows one is.
|
the "everything except `App` is free of Avalonia" rule bends, and it bends narrowly — what it uses is
|
||||||
|
`Dispatcher`, the asset loader and a resource dictionary, none of which imply a window.
|
||||||
|
4. ~~**Android device key store.**~~ **Done** — `AndroidDeviceKeyStore` plus `BiometricGate`. A StrongBox-backed
|
||||||
|
AES-GCM key, generated with `setUserAuthenticationRequired`, wraps the X25519 scalar into a file beside
|
||||||
|
the cache; the keystore will not perform the unwrap unless the prompt actually happened, because the
|
||||||
|
cipher is bound to the prompt rather than merely following it.
|
||||||
|
|
||||||
|
Two things worth knowing. StrongBox is asked for and fell back from, because refusing a TEE-backed key
|
||||||
|
would cost a mid-range phone the whole feature to buy a distinction the threat model does not draw. And
|
||||||
|
`setInvalidatedByBiometricEnrollment` is on, which is what stops somebody who can add a fingerprint to an
|
||||||
|
unlocked phone from inheriting the vault — the cost being that re-enrolment permanently destroys the key,
|
||||||
|
which the load path treats as ordinary rather than exceptional.
|
||||||
5. **Android sign-in**: Custom Tabs plus an app link, behind the existing seams. Not the loopback listener —
|
5. **Android sign-in**: Custom Tabs plus an app link, behind the existing seams. Not the loopback listener —
|
||||||
on a shared device any app can bind a loopback port, which is the attack RFC 8252 §8.3 names.
|
on a shared device any app can bind a loopback port, which is the attack RFC 8252 §8.3 names.
|
||||||
6. **The foreground service**, before the terminal rather than after it. A session that dies on backgrounding
|
|
||||||
would otherwise shape every decision made while building the screen, and be expensive to unpick.
|
**Not started, and it is now the gap that matters most.** The head composes a sign-in delegate that
|
||||||
7. **The interface**, phone-first. The actual project, and the one that dominates the estimate.
|
throws, and `NeedsServer` draws a screen saying so in the user's words. A phone enrolled from the desktop
|
||||||
|
client unlocks here perfectly well, because unlocking needs no network — so this blocks first-run, not
|
||||||
|
the product. It is the next piece of work.
|
||||||
|
6. ~~**The foreground service.**~~ **Done** — `SessionForegroundService`, with `SessionKeepAlive` starting and
|
||||||
|
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
|
||||||
|
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
|
||||||
|
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
|
||||||
|
dishonesty the unlock screen's count exists to prevent.
|
||||||
|
7. **The interface**, phone-first. The actual project, and the one that dominates the estimate. **Started:**
|
||||||
|
the shell switches on `ShellState` and the unlock screen is built from the design — including both of its
|
||||||
|
◆ disclosures, the shell count and the paragraph saying *locked* describes the keychain and not this
|
||||||
|
phone's access to the hosts. Every other state draws a `PendingScreen` naming what is missing, in the
|
||||||
|
desktop head's `NotBuiltScreen` tradition and for the same reason.
|
||||||
8. **The terminal**, last — the highest-value screen, and the one whose remaining unknowns are cheapest to
|
8. **The terminal**, last — the highest-value screen, and the one whose remaining unknowns are cheapest to
|
||||||
resolve once the shell around it exists. Plus an accessory key row: a software keyboard has no Ctrl, Esc,
|
resolve once the shell around it exists. Plus an accessory key row: a software keyboard has no Ctrl, Esc,
|
||||||
Tab or arrows, and every Android SSH client ships one for exactly this reason.
|
Tab or arrows, and every Android SSH client ships one for exactly this reason.
|
||||||
|
|
||||||
Steps 1 and 2 are done. Step 3 is a few days and retires nearly all the remaining technical risk. Steps 4–6
|
Steps 1–4 and 6 are done. Step 5 is ordinary work behind seams that already exist, and is what first-run
|
||||||
are each perhaps a week and are ordinary work behind interfaces that already exist. Step 7 dominates
|
waits on. Step 7 still dominates everything else put together — the unlock screen is one of nine
|
||||||
everything else put together, and step 8 is small only because step 7 came first.
|
destinations and the least structural of them — and step 8 is small only because step 7 comes first.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -425,11 +468,20 @@ everything else put together, and step 8 is small only because step 7 came first
|
|||||||
|
|
||||||
Neither of these blocks the spike, and both want answering before there is anything to release.
|
Neither of these blocks the spike, and both want answering before there is anything to release.
|
||||||
|
|
||||||
- **Which Android versions.** Less forced than it first looked: the packages *compile against* API 36 and 31
|
- ~~**Which Android versions.**~~ **✅ Settled: minSdk 28, targetSdk 36**, and the reasoning divided in two.
|
||||||
respectively, which constrains `targetSdk` rather than `minSdk`. The floor is therefore a real choice about
|
|
||||||
which devices are worth supporting, and it should be made deliberately rather than inherited from whatever
|
The ceiling is not a choice at all, which the audit had slightly wrong. `Avalonia.Controls.WebView` ships
|
||||||
restores. Worth settling before the interface work, since it decides which platform APIs are available to
|
**only** a `net10.0-android36.0` assembly — there is no lower Android target in the package — so a project
|
||||||
design against.
|
below `targetSdk` 36 cannot resolve it and therefore cannot have a terminal. That is a hard constraint
|
||||||
|
rather than a preference, and it also means the API 36 platform must be installed to build this head at
|
||||||
|
all.
|
||||||
|
|
||||||
|
The floor *was* a real choice, and 28 is where the three platform APIs the device key store depends on all
|
||||||
|
exist in the framework rather than behind an AndroidX shim: `BiometricPrompt`, StrongBox-backed keys, and
|
||||||
|
`setInvalidatedByBiometricEnrollment`. Going lower would mean carrying `androidx.biometric` to reach
|
||||||
|
devices that mostly cannot hold a hardware-backed key anyway — which is the one thing the store is for.
|
||||||
|
API 28 and 29 still cost one branch each in `BiometricGate`, because allowed-authenticator lists arrived
|
||||||
|
in 30.
|
||||||
- **How it is distributed, and what that does to the supply-chain story.** ADR 0001 says plainly that an
|
- **How it is distributed, and what that does to the supply-chain story.** ADR 0001 says plainly that an
|
||||||
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
|
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
|
||||||
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
|
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a
|
the nearest thing to hand, and reaching past it to a button is the sort of friction that gets a
|
||||||
phone client called slow.
|
phone client called slow.
|
||||||
-->
|
-->
|
||||||
<TextBox Text="{Binding Passphrase}" PasswordChar="•" Watermark="vault passphrase"
|
<TextBox Text="{Binding Passphrase}" PasswordChar="•" PlaceholderText="vault passphrase"
|
||||||
Height="48" Padding="14,0" VerticalContentAlignment="Center"
|
Height="48" Padding="14,0" VerticalContentAlignment="Center"
|
||||||
Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1" CornerRadius="6" Foreground="{StaticResource Text}"
|
BorderThickness="1" CornerRadius="6" Foreground="{StaticResource Text}"
|
||||||
|
|||||||
Reference in New Issue
Block a user