The checks engine, the path and the cards: me-web? 0.1 reads the connection

MeWeb.Core runs seven checks concurrently and reports each as it settles:
online or behind a login page, this connection, the public IPv4 and IPv6
seen from v4./v6.meweb, the provider from a built-in map, the router
(UPnP IGD, NAT-PMP and PCP, read-only), address sharing (router WAN
against the echo, TTL hops only as a hint), and the history kept on the
device. The app draws the path from this device to the internet and lights
it node by node, with a card per check and a sheet listing every host it
contacts.

The provider map is iptoasn's table compressed to 2.1 MB by
tools/knowledge.cs and built before every app release; eleven Italian
provider profiles carry each fact with who said it, the source and the
month. The server gains a download page (en, it) and /download/{platform}
from the latest Gitea release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-15 04:39:03 +02:00
1 parent 16a1cecaae
commit 0dffbc27d6
61 files changed
+3750 -149

No files matched your search

+1
View File
@@ -7,6 +7,7 @@
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://MeWeb.App/Resources/Colors.axaml" />
<ResourceInclude Source="avares://MeWeb.App/Resources/Icons.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
+4 -1
View File
@@ -6,6 +6,7 @@ using Avalonia.Markup.Xaml;
using MeWeb.App.Services;
using MeWeb.App.ViewModels;
using MeWeb.App.Views;
using MeWeb.Core.Net;
using Microsoft.Extensions.DependencyInjection;
@@ -15,6 +16,8 @@ public partial class App : Application
{
public static IServiceProvider Services { get; private set; }
public static IPlatform Platform { get; set; } = new SystemPlatform();
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -25,7 +28,7 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
Services = new ServiceCollection().AddMeWebApp().BuildServiceProvider();
Services = new ServiceCollection().AddMeWebApp(Platform).BuildServiceProvider();
var stored = AppSettings.Read();
Localizer.Instance.SetLanguage(Languages.Find(stored.Language) ?? Languages.ForCulture(System.Globalization.CultureInfo.CurrentUICulture));
+19 -6
View File
@@ -1,5 +1,8 @@
using MeWeb.App.Services;
using MeWeb.App.ViewModels;
using MeWeb.Core.Checks;
using MeWeb.Core.History;
using MeWeb.Core.Knowledge;
using MeWeb.Core.Net;
using MeWeb.Shared;
using Microsoft.Extensions.DependencyInjection;
@@ -8,14 +11,24 @@ namespace MeWeb.App;
public static class AppServices
{
public static IServiceCollection AddMeWebApp(this IServiceCollection services)
public const string Secure = "secure";
public const string Plain = "plain";
public static IServiceCollection AddMeWebApp(this IServiceCollection services, IPlatform platform)
{
services.AddHttpClient(ServerClient.Name, http =>
var agent = $"me-web/{BuildInfo.Version}";
services.AddHttpClient(Secure, http => http.DefaultRequestHeaders.UserAgent.ParseAdd(agent));
services.AddHttpClient(Plain, http => http.DefaultRequestHeaders.UserAgent.ParseAdd(agent))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false });
services.AddSingleton(platform);
services.AddSingleton(sp =>
{
http.BaseAddress = new Uri(Endpoints.Base);
http.DefaultRequestHeaders.UserAgent.ParseAdd($"me-web/{BuildInfo.Version}");
var factory = sp.GetRequiredService<IHttpClientFactory>();
return new ServerProbe(factory.CreateClient(Secure), factory.CreateClient(Plain));
});
services.AddSingleton<ServerClient>();
services.AddSingleton<KnowledgeBase>();
services.AddSingleton(new HistoryStore(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeWeb")));
services.AddSingleton<CheckRunner>();
services.AddTransient<MainViewModel>();
return services;
}
+1
View File
@@ -27,5 +27,6 @@
<ItemGroup>
<ProjectReference Include="..\MeWeb.Shared\MeWeb.Shared.csproj" />
<ProjectReference Include="..\MeWeb.Core\MeWeb.Core.csproj" />
</ItemGroup>
</Project>
+15 -3
View File
@@ -6,14 +6,21 @@
<Color x:Key="Ground">#1B141C</Color>
<Color x:Key="Surface">#2C222D</Color>
<Color x:Key="SurfaceRaised">#352A36</Color>
<Color x:Key="Line">#3A2E3B</Color>
<Color x:Key="Line">#4A3C4B</Color>
<Color x:Key="TextStrong">#F0EAEF</Color>
<Color x:Key="TextSoft">#A79AA6</Color>
<Color x:Key="TextSoft">#B3A6B2</Color>
<Color x:Key="Good">#6FCF97</Color>
<Color x:Key="Caution">#F2C14E</Color>
<Color x:Key="Bad">#E85D6C</Color>
<Color x:Key="Bad">#F06B79</Color>
<Color x:Key="GoodTint">#2E6FCF97</Color>
<Color x:Key="CautionTint">#2EF2C14E</Color>
<Color x:Key="BadTint">#33F06B79</Color>
<Color x:Key="NeutralTint">#33B3A6B2</Color>
<Color x:Key="Scrim">#E6140F15</Color>
<SolidColorBrush x:Key="BrandRedBrush" Color="{StaticResource BrandRed}" />
<SolidColorBrush x:Key="BrandRedDarkBrush" Color="{StaticResource BrandRedDark}" />
<SolidColorBrush x:Key="BrandRedLightBrush" Color="{StaticResource BrandRedLight}" />
<SolidColorBrush x:Key="GroundBrush" Color="{StaticResource Ground}" />
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource Surface}" />
<SolidColorBrush x:Key="SurfaceRaisedBrush" Color="{StaticResource SurfaceRaised}" />
@@ -23,4 +30,9 @@
<SolidColorBrush x:Key="GoodBrush" Color="{StaticResource Good}" />
<SolidColorBrush x:Key="CautionBrush" Color="{StaticResource Caution}" />
<SolidColorBrush x:Key="BadBrush" Color="{StaticResource Bad}" />
<SolidColorBrush x:Key="GoodTintBrush" Color="{StaticResource GoodTint}" />
<SolidColorBrush x:Key="CautionTintBrush" Color="{StaticResource CautionTint}" />
<SolidColorBrush x:Key="BadTintBrush" Color="{StaticResource BadTint}" />
<SolidColorBrush x:Key="NeutralTintBrush" Color="{StaticResource NeutralTint}" />
<SolidColorBrush x:Key="ScrimBrush" Color="{StaticResource Scrim}" />
</ResourceDictionary>
+29
View File
@@ -0,0 +1,29 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StreamGeometry x:Key="IconLaptop">M 18 5 a 2 2 0 0 1 2 2 v 8.526 a 2 2 0 0 0 .212 .897 l 1.068 2.127 a 1 1 0 0 1 -.9 1.45 H 3.62 a 1 1 0 0 1 -.9 -1.45 l 1.068 -2.127 A 2 2 0 0 0 4 15.526 V 7 a 2 2 0 0 1 2 -2 z M 20.054 15.987 H 3.946</StreamGeometry>
<StreamGeometry x:Key="IconSmartphone">M 7 2 H 17 A 2 2 0 0 1 19 4 V 20 A 2 2 0 0 1 17 22 H 7 A 2 2 0 0 1 5 20 V 4 A 2 2 0 0 1 7 2 Z M 12 18 h .01</StreamGeometry>
<StreamGeometry x:Key="IconWifi">M 12 20 h .01 M 2 8.82 a 15 15 0 0 1 20 0 M 5 12.859 a 10 10 0 0 1 14 0 M 8.5 16.429 a 5 5 0 0 1 7 0</StreamGeometry>
<StreamGeometry x:Key="IconCable">M 17 19 a 1 1 0 0 1 -1 -1 v -2 a 2 2 0 0 1 2 -2 h 2 a 2 2 0 0 1 2 2 v 2 a 1 1 0 0 1 -1 1 z M 17 21 v -2 M 19 14 V 6.5 a 1 1 0 0 0 -7 0 v 11 a 1 1 0 0 1 -7 0 V 10 M 21 21 v -2 M 3 5 V 3 M 4 10 a 2 2 0 0 1 -2 -2 V 6 a 1 1 0 0 1 1 -1 h 4 a 1 1 0 0 1 1 1 v 2 a 2 2 0 0 1 -2 2 z M 7 5 V 3</StreamGeometry>
<StreamGeometry x:Key="IconRouter">M 4 14 H 20 A 2 2 0 0 1 22 16 V 20 A 2 2 0 0 1 20 22 H 4 A 2 2 0 0 1 2 20 V 16 A 2 2 0 0 1 4 14 Z M 6.01 18 H 6 M 10.01 18 H 10 M 15 10 v 4 M 17.84 7.17 a 4 4 0 0 0 -5.66 0 M 20.66 4.34 a 8 8 0 0 0 -11.31 0</StreamGeometry>
<StreamGeometry x:Key="IconBuilding2">M 10 12 h 4 M 10 8 h 4 M 14 21 v -3 a 2 2 0 0 0 -4 0 v 3 M 6 10 H 4 a 2 2 0 0 0 -2 2 v 7 a 2 2 0 0 0 2 2 h 16 a 2 2 0 0 0 2 -2 V 9 a 2 2 0 0 0 -2 -2 h -2 M 6 21 V 5 a 2 2 0 0 1 2 -2 h 8 a 2 2 0 0 1 2 2 v 16</StreamGeometry>
<StreamGeometry x:Key="IconGlobe">M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z M 12 2 a 14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0 -20 M 2 12 h 20</StreamGeometry>
<StreamGeometry x:Key="IconNetwork">M 17 16 H 21 A 1 1 0 0 1 22 17 V 21 A 1 1 0 0 1 21 22 H 17 A 1 1 0 0 1 16 21 V 17 A 1 1 0 0 1 17 16 Z M 3 16 H 7 A 1 1 0 0 1 8 17 V 21 A 1 1 0 0 1 7 22 H 3 A 1 1 0 0 1 2 21 V 17 A 1 1 0 0 1 3 16 Z M 10 2 H 14 A 1 1 0 0 1 15 3 V 7 A 1 1 0 0 1 14 8 H 10 A 1 1 0 0 1 9 7 V 3 A 1 1 0 0 1 10 2 Z M 5 16 v -3 a 1 1 0 0 1 1 -1 h 12 a 1 1 0 0 1 1 1 v 3 M 12 12 V 8</StreamGeometry>
<StreamGeometry x:Key="IconRadioTower">M 4.9 16.1 C 1 12.2 1 5.8 4.9 1.9 M 7.8 4.7 a 6.14 6.14 0 0 0 -.8 7.5 M 10 9 A 2 2 0 1 0 14 9 A 2 2 0 1 0 10 9 Z M 16.2 4.8 c 2 2 2.26 5.11 .8 7.47 M 19.1 1.9 a 9.96 9.96 0 0 1 0 14.1 M 9.5 18 h 5 m 8 22 4 -11 4 11</StreamGeometry>
<StreamGeometry x:Key="IconHistory">M 3 12 a 9 9 0 1 0 9 -9 9.75 9.75 0 0 0 -6.74 2.74 L 3 8 M 3 3 v 5 h 5 M 12 7 v 5 l 4 2</StreamGeometry>
<StreamGeometry x:Key="IconInfo">M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z M 12 16 v -4 M 12 8 h .01</StreamGeometry>
<StreamGeometry x:Key="IconCircleCheck">M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z m 16 9 -5.5 5.5 L 8 12</StreamGeometry>
<StreamGeometry x:Key="IconTriangleAlert">m 21.73 18 -8 -14 a 2 2 0 0 0 -3.48 0 l -8 14 A 2 2 0 0 0 4 21 h 16 a 2 2 0 0 0 1.73 -3 M 12 9 v 4 M 12 17 h .01</StreamGeometry>
<StreamGeometry x:Key="IconCircleX">M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z m 15 9 -6 6 m 9 9 6 6</StreamGeometry>
<StreamGeometry x:Key="IconCircleHelp">M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z M 9.09 9 a 3 3 0 0 1 5.83 1 c 0 2 -3 3 -3 3 M 12 17 h .01</StreamGeometry>
<StreamGeometry x:Key="IconRefreshCw">M 3 12 a 9 9 0 0 1 9 -9 9.75 9.75 0 0 1 6.74 2.74 L 21 8 M 21 3 v 5 h -5 M 21 12 a 9 9 0 0 1 -9 9 9.75 9.75 0 0 1 -6.74 -2.74 L 3 16 M 8 16 H 3 v 5</StreamGeometry>
<StreamGeometry x:Key="IconLanguages">m 5 8 6 6 m 4 14 6 -6 2 -3 M 2 5 h 12 M 7 2 h 1 m 22 22 -5 -10 -5 10 M 14 18 h 6</StreamGeometry>
<StreamGeometry x:Key="IconPlug">M 12 22 v -5 M 15 8 V 2 M 17 8 a 1 1 0 0 1 1 1 v 4 a 4 4 0 0 1 -4 4 h -4 a 4 4 0 0 1 -4 -4 V 9 a 1 1 0 0 1 1 -1 z M 9 8 V 2</StreamGeometry>
<StreamGeometry x:Key="IconLayers">M 12.83 2.18 a 2 2 0 0 0 -1.66 0 L 2.6 6.08 a 1 1 0 0 0 0 1.83 l 8.58 3.91 a 2 2 0 0 0 1.66 0 l 8.58 -3.9 a 1 1 0 0 0 0 -1.83 z M 2 12 a 1 1 0 0 0 .58 .91 l 8.6 3.91 a 2 2 0 0 0 1.65 0 l 8.58 -3.9 A 1 1 0 0 0 22 12 M 2 17 a 1 1 0 0 0 .58 .91 l 8.6 3.91 a 2 2 0 0 0 1.65 0 l 8.58 -3.9 A 1 1 0 0 0 22 17</StreamGeometry>
<StreamGeometry x:Key="IconMapPin">M 20 10 c 0 4.993 -5.539 10.193 -7.399 11.799 a 1 1 0 0 1 -1.202 0 C 9.539 20.193 4 14.993 4 10 a 8 8 0 0 1 16 0 M 9 10 A 3 3 0 1 0 15 10 A 3 3 0 1 0 9 10 Z</StreamGeometry>
<StreamGeometry x:Key="IconChevronDown">m 6 9 6 6 6 -6</StreamGeometry>
<StreamGeometry x:Key="IconServer">M 4 2 H 20 A 2 2 0 0 1 22 4 V 8 A 2 2 0 0 1 20 10 H 4 A 2 2 0 0 1 2 8 V 4 A 2 2 0 0 1 4 2 Z M 4 14 H 20 A 2 2 0 0 1 22 16 V 20 A 2 2 0 0 1 20 22 H 4 A 2 2 0 0 1 2 20 V 16 A 2 2 0 0 1 4 14 Z M 6 6 L 6.01 6 M 6 18 L 6.01 18</StreamGeometry>
<StreamGeometry x:Key="IconShieldCheck">M 20 13 c 0 5 -3.5 7.5 -7.66 8.95 a 1 1 0 0 1 -.67 -.01 C 7.5 20.5 4 18 4 13 V 6 a 1 1 0 0 1 1 -1 c 2 0 4.5 -1.2 6.24 -2.72 a 1.17 1.17 0 0 1 1.52 0 C 14.51 3.81 17 5 19 5 a 1 1 0 0 1 1 1 z m 9 12 2 2 4 -4</StreamGeometry>
<StreamGeometry x:Key="IconRoute">M 3 19 A 3 3 0 1 0 9 19 A 3 3 0 1 0 3 19 Z M 9 19 h 8.5 a 3.5 3.5 0 0 0 0 -7 h -11 a 3.5 3.5 0 0 1 0 -7 H 15 M 15 5 A 3 3 0 1 0 21 5 A 3 3 0 1 0 15 5 Z</StreamGeometry>
<StreamGeometry x:Key="IconArrowUpRight">M 7 7 h 10 v 10 M 7 17 17 7</StreamGeometry>
<StreamGeometry x:Key="IconDownload">M 12 15 V 3 M 21 15 v 4 a 2 2 0 0 1 -2 2 H 5 a 2 2 0 0 1 -2 -2 v -4 m 7 10 5 5 5 -5</StreamGeometry>
</ResourceDictionary>
+263 -7
View File
@@ -1,12 +1,12 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="TextBlock.title">
<Setter Property="FontSize" Value="34" />
<Setter Property="FontSize" Value="30" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
</Style>
<Style Selector="TextBlock.lead">
<Setter Property="FontSize" Value="18" />
<Setter Property="FontSize" Value="17" />
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="TextAlignment" Value="Center" />
@@ -16,11 +16,26 @@
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
<Setter Property="TextAlignment" Value="Center" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.section">
<Setter Property="FontSize" Value="19" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
<Setter Property="Margin" Value="8,0" />
</Style>
<Style Selector="TextBlock.small">
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.body">
<Setter Property="FontSize" Value="15" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="LineHeight" Value="22" />
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{StaticResource BrandRedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
@@ -30,27 +45,268 @@
<Setter Property="CornerRadius" Value="28" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="Button.primary:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource BrandRedLightBrush}" />
<Setter Property="Foreground" Value="{StaticResource GroundBrush}" />
</Style>
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource BrandRedDarkBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
</Style>
<Style Selector="Button.link">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource BrandRedBrush}" />
<Setter Property="Padding" Value="4,2" />
<Setter Property="Foreground" Value="{StaticResource BrandRedLightBrush}" />
<Setter Property="Padding" Value="0,2" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Button.link:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
</Style>
<Style Selector="Button.quiet">
<Setter Property="Background" Value="{StaticResource SurfaceRaisedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="Padding" Value="14,8" />
</Style>
<Style Selector="Button.round">
<Setter Property="Background" Value="{StaticResource SurfaceRaisedBrush}" />
<Setter Property="CornerRadius" Value="20" />
<Setter Property="Width" Value="40" />
<Setter Property="Height" Value="40" />
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Path.icon">
<Setter Property="Stroke" Value="{StaticResource TextStrongBrush}" />
<Setter Property="StrokeThickness" Value="2" />
<Setter Property="StrokeLineCap" Value="Round" />
<Setter Property="StrokeJoin" Value="Round" />
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="24" />
</Style>
<Style Selector="Ellipse.pulse">
<Setter Property="Fill" Value="{StaticResource BrandRedBrush}" />
<Style.Animations>
<Animation Duration="0:0:1.6" IterationCount="Infinite">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="0.85" />
<Setter Property="Opacity" Value="0.7" />
<Setter Property="ScaleTransform.ScaleX" Value="0.8" />
<Setter Property="ScaleTransform.ScaleY" Value="0.8" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="0" />
<Setter Property="ScaleTransform.ScaleX" Value="1.5" />
<Setter Property="ScaleTransform.ScaleY" Value="1.5" />
<Setter Property="ScaleTransform.ScaleX" Value="1.45" />
<Setter Property="ScaleTransform.ScaleY" Value="1.45" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="Border.banner">
<Setter Property="Background" Value="{StaticResource SurfaceRaisedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource BrandRedBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="16,10" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="Ellipse.disc">
<Setter Property="Fill" Value="{StaticResource SurfaceRaisedBrush}" />
<Setter Property="Stroke" Value="{StaticResource LineBrush}" />
<Setter Property="StrokeThickness" Value="3" />
</Style>
<Style Selector="Ellipse.disc.good">
<Setter Property="Stroke" Value="{StaticResource GoodBrush}" />
<Setter Property="Fill" Value="{StaticResource GoodTintBrush}" />
</Style>
<Style Selector="Ellipse.disc.caution">
<Setter Property="Stroke" Value="{StaticResource CautionBrush}" />
<Setter Property="Fill" Value="{StaticResource CautionTintBrush}" />
</Style>
<Style Selector="Ellipse.disc.bad">
<Setter Property="Stroke" Value="{StaticResource BadBrush}" />
<Setter Property="Fill" Value="{StaticResource BadTintBrush}" />
</Style>
<Style Selector="Ellipse.disc.unknown">
<Setter Property="Stroke" Value="{StaticResource TextSoftBrush}" />
<Setter Property="Fill" Value="{StaticResource NeutralTintBrush}" />
</Style>
<Style Selector=":is(Panel).node">
<Setter Property="Opacity" Value="0.45" />
<Setter Property="RenderTransform" Value="scale(0.9)" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.45" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.45" Easing="BackEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector=":is(Panel).node.lit">
<Setter Property="Opacity" Value="1" />
<Setter Property="RenderTransform" Value="scale(1)" />
</Style>
<Style Selector="TextBlock.nodeTitle">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.nodeSubtitle">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.linkLabel">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
</Style>
<Style Selector="Line.connector">
<Setter Property="Stroke" Value="{StaticResource LineBrush}" />
<Setter Property="StrokeThickness" Value="3" />
<Setter Property="StrokeLineCap" Value="Round" />
<Setter Property="StrokeDashArray" Value="1.5,1.5" />
</Style>
<Style Selector="Line.connector.flowing">
<Setter Property="Stroke" Value="{StaticResource BrandRedBrush}" />
<Style.Animations>
<Animation Duration="0:0:0.5" IterationCount="Infinite">
<KeyFrame Cue="0%">
<Setter Property="StrokeDashOffset" Value="0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="StrokeDashOffset" Value="-3" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="Line.connector.lit">
<Setter Property="Stroke" Value="{StaticResource TextSoftBrush}" />
<Setter Property="StrokeDashArray" Value="1,0" />
</Style>
<Style Selector="Border.card">
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="4,0,0,0" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="18,16" />
<Setter Property="Margin" Value="8" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="BorderBrush" Duration="0:0:0.4" />
</Transitions>
</Setter>
</Style>
<Style Selector="Border.card.good">
<Setter Property="BorderBrush" Value="{StaticResource GoodBrush}" />
</Style>
<Style Selector="Border.card.caution">
<Setter Property="BorderBrush" Value="{StaticResource CautionBrush}" />
</Style>
<Style Selector="Border.card.bad">
<Setter Property="BorderBrush" Value="{StaticResource BadBrush}" />
</Style>
<Style Selector="Border.card.unknown, Border.card.info">
<Setter Property="BorderBrush" Value="{StaticResource TextSoftBrush}" />
</Style>
<Style Selector="Border.chip">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="9,3" />
<Setter Property="Background" Value="{StaticResource NeutralTintBrush}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Border.chip TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
</Style>
<Style Selector="Border.card.good Border.chip">
<Setter Property="Background" Value="{StaticResource GoodTintBrush}" />
</Style>
<Style Selector="Border.card.good Border.chip TextBlock">
<Setter Property="Foreground" Value="{StaticResource GoodBrush}" />
</Style>
<Style Selector="Border.card.caution Border.chip">
<Setter Property="Background" Value="{StaticResource CautionTintBrush}" />
</Style>
<Style Selector="Border.card.caution Border.chip TextBlock">
<Setter Property="Foreground" Value="{StaticResource CautionBrush}" />
</Style>
<Style Selector="Border.card.bad Border.chip">
<Setter Property="Background" Value="{StaticResource BadTintBrush}" />
</Style>
<Style Selector="Border.card.bad Border.chip TextBlock">
<Setter Property="Foreground" Value="{StaticResource BadBrush}" />
</Style>
<Style Selector="TextBlock.cardTitle">
<Setter Property="FontSize" Value="17" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="10,0" />
</Style>
<Style Selector="TextBlock.summary">
<Setter Property="FontSize" Value="15" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="LineHeight" Value="21" />
</Style>
<Style Selector="TextBlock.factLabel">
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Margin" Value="0,1,10,0" />
</Style>
<Style Selector="SelectableTextBlock.factValue">
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="ProgressBar.thin">
<Setter Property="MinHeight" Value="3" />
<Setter Property="Height" Value="3" />
<Setter Property="Foreground" Value="{StaticResource BrandRedBrush}" />
<Setter Property="Background" Value="{StaticResource SurfaceRaisedBrush}" />
</Style>
<Style Selector="Expander.explain">
<Style.Resources>
<SolidColorBrush x:Key="ExpanderHeaderBackground" Color="{StaticResource SurfaceRaised}" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPointerOver" Color="{StaticResource Line}" />
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPressed" Color="{StaticResource Line}" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="Transparent" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPressed" Color="Transparent" />
<SolidColorBrush x:Key="ExpanderHeaderForeground" Color="{StaticResource TextStrong}" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundPointerOver" Color="{StaticResource TextStrong}" />
<SolidColorBrush x:Key="ExpanderHeaderForegroundPressed" Color="{StaticResource TextStrong}" />
<SolidColorBrush x:Key="ExpanderContentBackground" Color="{StaticResource SurfaceRaised}" />
<SolidColorBrush x:Key="ExpanderContentBorderBrush" Color="Transparent" />
</Style.Resources>
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="FontSize" Value="13" />
<Setter Property="CornerRadius" Value="10" />
</Style>
<Style Selector="Border.scrim">
<Setter Property="Background" Value="{StaticResource ScrimBrush}" />
</Style>
<Style Selector="Border.sheet">
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Padding" Value="22,18" />
<Setter Property="MaxWidth" Value="660" />
<Setter Property="Margin" Value="16" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
</Styles>
+1 -1
View File
@@ -30,7 +30,7 @@ public static class Languages
var found = Find(current.Name);
if (found is not null) return found;
}
return Fallback;
return Find(Fallback.Code) ?? Fallback;
}
static List<Language> Load()
+11 -1
View File
@@ -27,7 +27,17 @@ public sealed class Localizer : INotifyPropertyChanged
public string this[string key] => current.GetValueOrDefault(key) ?? english.GetValueOrDefault(key) ?? key;
public string Format(string key, params object[] args) => string.Format(Culture, this[key], args);
public string Format(string key, params object[] args)
{
try
{
return string.Format(Culture, this[key], args);
}
catch (FormatException)
{
return this[key];
}
}
public void SetLanguage(Language language)
{
-33
View File
@@ -1,33 +0,0 @@
using System.Net.Http.Json;
using MeWeb.Shared;
namespace MeWeb.App.Services;
public enum Reachability
{
Unknown,
Online,
Offline,
}
public class ServerClient(IHttpClientFactory factory)
{
public const string Name = "server";
public async Task<(Reachability State, ViewInfo Info)> Probe(CancellationToken ct)
{
try
{
var http = factory.CreateClient(Name);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(6));
var info = await http.GetFromJsonAsync(Endpoints.Server.GET_Info, MeWebJsonContext.Default.ViewInfo, timeout.Token);
return (Reachability.Online, info);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
{
return (Reachability.Offline, null);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using System.Globalization;
using MeWeb.Core.Checks;
namespace MeWeb.App.Services;
public static class Texts
{
public static string Summary(Finding finding) => finding?.Summary is null ? "" : Localizer.Instance.Format(finding.Summary, finding.Args.Select(Argument).ToArray());
public static string Value(Fact fact)
{
if (fact.Text is not null) return Isolate(fact.Text);
if (fact.Key is null) return fact.Date is null ? "" : Date(fact.Date);
var args = fact.Amount is null ? fact.Args.Select(Argument).ToArray() : new object[] { Money(fact.Amount.Value, fact.Currency) };
return Localizer.Instance.Format(fact.Key, args);
}
public static string Source(Fact fact)
{
var parts = new List<string>();
if (fact.Basis is not null) parts.Add(Localizer.Instance[fact.Basis]);
if (fact.Date is not null && fact.Key is not null) parts.Add(Date(fact.Date));
return string.Join(", ", parts);
}
public static string Money(decimal amount, string currency)
{
var format = (NumberFormatInfo)Localizer.Instance.Culture.NumberFormat.Clone();
format.CurrencySymbol = currency switch
{
"EUR" => "€",
"USD" => "$",
"GBP" => "£",
_ => currency ?? "",
};
return amount.ToString("C2", format);
}
public static string Date(string text)
{
var culture = Localizer.Instance.Culture;
if (DateTime.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day)) return day.ToString("d", culture);
if (DateTime.TryParseExact(text, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out var month)) return month.ToString("Y", culture);
return text;
}
public static string Isolate(string text) => string.IsNullOrEmpty(text) ? text : $"\u2068{text}\u2069";
static object Argument(string text) =>
DateTime.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day) ? day.ToString("d", Localizer.Instance.Culture) : text;
}
+159 -1
View File
@@ -1,11 +1,169 @@
{
"AboutLine": "me-web? is free software (AGPL-3.0) published by Arasaka di Hvozdikov Yevheniy, Busto Arsizio, Italy.",
"AddressesBoth": "The internet sees you over both IPv4 and IPv6.",
"AddressesNone": "Your public address couldn't be read.",
"AddressesV4Only": "The internet sees you over IPv4 only: this connection has no IPv6.",
"AddressesV6Only": "The internet sees you over IPv6 only.",
"AppTagline": "Find out whether you can run your own server from this connection, and what stands in the way.",
"BasisOneUser": "one user's report",
"BasisOperator": "operator",
"BasisPress": "press",
"BasisResearch": "me-web? research",
"BasisUsers": "users' reports",
"CardAddresses": "Your public addresses",
"CardConnection": "This connection",
"CardHistory": "Earlier checks",
"CardNat": "Address sharing",
"CardOnline": "Internet connection",
"CardProvider": "Your provider",
"CardRouter": "Your router",
"Check": "Check",
"CheckAgain": "Check again",
"CheckFailed": "This check stopped unexpectedly ({0}).",
"Checking": "Checking…",
"ClearHistory": "Forget earlier checks",
"Close": "Close",
"ConnectionCable": "Connected by cable.",
"ConnectionMobile": "Connected by mobile data.",
"ConnectionNone": "No network adapter is connected.",
"ConnectionOther": "Connected through a network adapter.",
"ConnectionWifi": "Connected by Wi-Fi.",
"ContactDns": "Your name server (DNS): to look up meweb.arasaka.software.",
"ContactLinks": "Links you choose to open (sources, corrections, downloads) open in your browser.",
"ContactRoute": "The routers on the way to meweb.arasaka.software: a few short test messages with a limited lifetime (ping) show the first steps of the route.",
"ContactRouter": "Your router, on your own network: asked whether it lets apps open ports and which internet address it has. Nothing is changed.",
"ContactServer": "meweb.arasaka.software, run by the publisher in Italy: to see whether you are online, which public addresses the internet sees, and whether a newer version exists. The me-web? server stores nothing about your checks.",
"Contacts": "What this app contacts",
"ContactsIntro": "me-web? talks only to these, and only when it checks:",
"DraftNotice": "This translation is a machine draft.",
"ExplainAddresses": "The address the rest of the internet sees when this device talks to it. With IPv4 the devices at home usually share one address, held by the router; with IPv6 each device usually has its own.",
"ExplainConnection": "How this device is connected right now, and the address your router gave it. Addresses starting with 192.168, 10, or 172.16 to 172.31 only work inside your home (private addresses).",
"ExplainHistory": "Each check is saved on this device only. If your public address stays the same for weeks it is probably fixed (static); if it changes, you need a name that follows it (dynamic DNS).",
"ExplainNat": "To host something at home, the internet has to reach your router. That works when your router holds the public address. When your provider shares one address among many customers (CGNAT), nobody can reach you from outside over IPv4 unless the provider gives you an address of your own; a tunnel or IPv6 are the ways around it.",
"ExplainOnline": "me-web? asks its own server, meweb.arasaka.software, over a secure connection and over a plain one. If the secure one fails and the plain one is sent somewhere else, a login page (captive portal) is in the way, as in hotels and on trains.",
"ExplainProvider": "Found without going online, in the provider map inside me-web?, from your public address. The notes come from the operator's own technical sheet or from users' reports, each with its source and date: they say what is usual, not what your contract says.",
"ExplainRouter": "Game consoles, calls and file sharing ask the router to open a port for them, using UPnP, NAT-PMP or PCP. me-web? only asks your router whether it answers and which internet address it has; it opens nothing.",
"FactAdapter": "Network adapter",
"FactAddressesSeen": "Public IPv4 addresses seen",
"FactChecks": "Checks",
"FactDns": "Name servers (DNS)",
"FactGateway": "Router address",
"FactHint": "Hint",
"FactIpv6": "IPv6",
"FactLinkSpeed": "Link speed",
"FactLocalAddress": "Address on your network",
"FactLocalIpv6": "IPv6 addresses of this device",
"FactNatPmp": "NAT-PMP",
"FactNetwork": "Network",
"FactNetworkIpv6": "Network for IPv6",
"FactPcp": "PCP",
"FactProviderIpv4": "IPv4 address",
"FactProviderIpv6": "IPv6",
"FactProviderPort25": "Mail port 25",
"FactProviderPublic": "An address of your own",
"FactProviderSheet": "Technical sheet",
"FactPublicV4": "Public IPv4",
"FactPublicV6": "Public IPv6",
"FactRedirectedTo": "Sent to",
"FactRoute": "First steps of the route",
"FactRouter": "Router",
"FactRouterWan": "Router's internet address",
"FactSince": "Since",
"FactUpnp": "UPnP",
"FactWayOut": "Way around it",
"FactWhoHasIpv6": "IPv6 address held by",
"FactWhoHasIt": "IPv4 address held by",
"HintSharedHop": "A router of your provider uses a shared-space address (100.64.x.x). That happens with address sharing and without it, so on its own it proves nothing.",
"HistoryChanged": "Changes of your public IPv4 address since {1}: {2} (checks: {0}).",
"HistoryCleared": "Earlier checks forgotten.",
"HistoryFirst": "This is the first check from this connection. Check again on other days to see whether your address changes.",
"HistoryStable": "Your public IPv4 address has stayed the same since {1} (checks: {0}).",
"IconsCredit": "Icons: Lucide (ISC).",
"Language": "Language",
"LinkCable": "Cable",
"LinkMobile": "Mobile data",
"LinkOther": "Network",
"LinkWifi": "Wi-Fi",
"MapCredit": "Provider map: iptoasn.com (PDDL), data of {0}.",
"MapMissing": "This copy has no provider map.",
"NatDirect": "This device holds your public IPv4 address itself.",
"NatDouble": "Two routers in a row: your router sits behind another one (double NAT).",
"NatHomeRouter": "One router, and it holds your public IPv4 address.",
"NatNoIpv4": "This connection has no IPv4.",
"NatShared": "Your provider shares one public IPv4 address among many customers (CGNAT).",
"NatTranslated": "Your address is translated again after your router: the address your router has isn't the one the internet sees.",
"NatUnknown": "Can't tell from here whether your public IPv4 address is yours alone: your router didn't say which address it has.",
"OnlineCaptive": "A login page is in the way: open your browser and sign in to this network.",
"OnlineGood": "This device reaches the internet.",
"OnlineNoNames": "The network is up, but names such as meweb.arasaka.software can't be looked up (DNS).",
"OnlineNoNetwork": "This device isn't connected to any network.",
"OnlineOffline": "This device is on a network, but doesn't reach the internet.",
"OnlineRewritten": "The internet answers, but something on the way changes plain web pages.",
"OutcomeBad": "In the way",
"OutcomeCaution": "Needs attention",
"OutcomeGood": "Good",
"OutcomeInfo": "Information",
"OutcomeRunning": "Checking",
"OutcomeUnknown": "Can't tell",
"OutcomeWaiting": "Waiting",
"PathAnotherRouter": "Another router",
"PathInternet": "Internet",
"PathMobileNetwork": "Mobile network",
"PathProvider": "Your provider",
"PathRouter": "Your router",
"PathSharedGateway": "Your provider's shared exit",
"PathThisComputer": "This computer",
"PathThisPhone": "This phone",
"PathTitle": "From this device to the internet",
"PathTranslation": "Another address translation",
"PrivacyLine": "No telemetry, no analytics, no crash reports. Your checks are kept on this device only.",
"ProviderIpv4PublicDynamic": "usually public, and it can change",
"ProviderIpv4Shared": "usually shared with other customers (CGNAT)",
"ProviderIpv4SharedPorts": "usually shared, each customer getting a slice of the ports",
"ProviderIpv4SharedUntilForward": "shared until you open a port on the provider's router, then yours alone",
"ProviderIpv6Native": "offered",
"ProviderIpv6None": "not offered",
"ProviderIpv6Tunnel": "through a tunnel (6rd)",
"ProviderListed": "Your provider is {0}.",
"ProviderNoAddress": "Without a public address the provider can't be found.",
"ProviderNoMap": "This copy of me-web? has no provider map.",
"ProviderNotFound": "The provider map doesn't know this address.",
"ProviderPort25Blocked": "blocked",
"ProviderPort25BlockedOnRequest": "blocked, opened if you ask",
"ProviderPort25Closed": "closed",
"ProviderPort25Open": "open",
"ProviderPublicBusinessOnly": "business lines only",
"ProviderPublicFreeOnRequest": "free if you ask for it",
"ProviderPublicNone": "not offered to homes",
"ProviderPublicNotFound": "no offer for homes was found",
"ProviderPublicPaid": "a static one from about {0} a month",
"ProviderUnlisted": "Your provider is {0}. me-web? has no notes on it yet.",
"ResultsTitle": "What me-web? found",
"RouterMobile": "On mobile data there is no home router: your provider's network comes first.",
"RouterNone": "No home router was found on this connection.",
"RouterOpens": "Your router answers apps that ask it to open ports.",
"RouterRefuses": "Your router answers, but refuses apps that ask it to open ports.",
"RouterSilent": "Your router doesn't answer apps that ask it to open ports.",
"SourceCode": "Source code",
"StatusCaptive": "A login page is in the way",
"StatusChecking": "Checking the connection…",
"StatusOffline": "Not connected to the internet",
"StatusOnline": "Connected to the internet",
"SuggestCorrection": "Suggest a correction",
"VersionLine": "me-web? {0} · free software (AGPL-3.0)"
"UpdateAvailable": "me-web? {0} is available.",
"UpdateOpen": "Download",
"ValueAnswered": "answered",
"ValueCount": "{0}",
"ValueIpv6Direct": "each device has its own public address",
"ValueIpv6NotThisDevice": "something other than this device",
"ValueIpv6Translated": "translated on the way",
"ValueIpv6Way": "Over IPv6 a device at home can be reached directly, if your router's firewall lets it.",
"ValueMbps": "{0} Mbit/s",
"ValueOnThisDevice": "this device",
"ValueOpenSheet": "Open the operator's sheet",
"ValueRefused": "refused (code {0})",
"ValueSharedAtHome": "your router, shared by every device at home",
"ValueSilent": "no answer",
"VersionLine": "me-web? {0} · free software (AGPL-3.0)",
"WhatIsThis": "What does this mean?"
}
+159 -1
View File
@@ -1,11 +1,169 @@
{
"AboutLine": "me-web? è software libero (AGPL-3.0) pubblicato da Arasaka di Hvozdikov Yevheniy, Busto Arsizio.",
"AddressesBoth": "Internet la vede sia in IPv4 sia in IPv6.",
"AddressesNone": "Non è stato possibile leggere il suo indirizzo pubblico.",
"AddressesV4Only": "Internet la vede solo in IPv4: questa connessione non ha IPv6.",
"AddressesV6Only": "Internet la vede solo in IPv6.",
"AppTagline": "Scopra se da questa connessione può ospitare un server suo, e che cosa lo impedisce.",
"BasisOneUser": "segnalazione di un utente",
"BasisOperator": "operatore",
"BasisPress": "stampa",
"BasisResearch": "ricerca di me-web?",
"BasisUsers": "segnalazioni degli utenti",
"CardAddresses": "I suoi indirizzi pubblici",
"CardConnection": "Questa connessione",
"CardHistory": "Verifiche precedenti",
"CardNat": "Condivisione dell'indirizzo",
"CardOnline": "Connessione a internet",
"CardProvider": "Il suo operatore",
"CardRouter": "Il suo router",
"Check": "Verifica",
"CheckAgain": "Verifica di nuovo",
"CheckFailed": "Questa verifica si è interrotta in modo inatteso ({0}).",
"Checking": "Verifica in corso…",
"ClearHistory": "Dimentica le verifiche precedenti",
"Close": "Chiudi",
"ConnectionCable": "Collegato via cavo.",
"ConnectionMobile": "Collegato con i dati mobili.",
"ConnectionNone": "Nessuna scheda di rete è collegata.",
"ConnectionOther": "Collegato tramite una scheda di rete.",
"ConnectionWifi": "Collegato via Wi-Fi.",
"ContactDns": "Il suo server dei nomi (DNS): per trovare meweb.arasaka.software.",
"ContactLinks": "I collegamenti che sceglie di aprire (fonti, correzioni, download) si aprono nel suo browser.",
"ContactRoute": "I router lungo la strada verso meweb.arasaka.software: alcuni brevi messaggi di prova a durata limitata (ping) mostrano i primi passi del percorso.",
"ContactRouter": "Il suo router, nella sua rete: gli si chiede se lascia aprire porte alle app e quale indirizzo internet ha. Non si cambia nulla.",
"ContactServer": "meweb.arasaka.software, gestito dall'editore in Italia: per sapere se è in linea, quali indirizzi pubblici vede internet e se esiste una versione più recente. Il server di me-web? non conserva nulla delle sue verifiche.",
"Contacts": "Con chi parla questa app",
"ContactsIntro": "me-web? parla solo con questi, e solo quando fa una verifica:",
"DraftNotice": "Questa traduzione è una bozza automatica.",
"ExplainAddresses": "L'indirizzo che il resto di internet vede quando questo dispositivo gli parla. In IPv4 i dispositivi di casa di solito condividono un indirizzo, che ha il router; in IPv6 di solito ogni dispositivo ha il suo.",
"ExplainConnection": "Come è collegato in questo momento il dispositivo, e l'indirizzo che gli ha dato il router. Gli indirizzi che iniziano con 192.168, 10 o da 172.16 a 172.31 valgono solo dentro casa (indirizzi privati).",
"ExplainHistory": "Ogni verifica resta salvata solo su questo dispositivo. Se l'indirizzo pubblico rimane uguale per settimane, probabilmente è fisso (statico); se cambia, serve un nome che lo segua (DNS dinamico).",
"ExplainNat": "Per ospitare qualcosa a casa, internet deve poter raggiungere il suo router. Funziona quando l'indirizzo pubblico ce l'ha il router. Quando l'operatore condivide un indirizzo tra molti clienti (CGNAT), da fuori nessuno la raggiunge in IPv4, a meno che l'operatore non le dia un indirizzo tutto suo; le alternative sono un tunnel o IPv6.",
"ExplainOnline": "me-web? interroga il proprio server, meweb.arasaka.software, con una connessione protetta e con una non protetta. Se quella protetta non va e quella non protetta viene mandata altrove, c'è di mezzo una pagina di accesso (captive portal), come negli alberghi e sui treni.",
"ExplainProvider": "Trovato senza andare in rete, nella mappa degli operatori contenuta in me-web?, a partire dal suo indirizzo pubblico. Le note vengono dalla scheda tecnica dell'operatore o da segnalazioni degli utenti, ciascuna con fonte e data: dicono che cosa è normale, non che cosa dice il suo contratto.",
"ExplainRouter": "Console, chiamate e condivisione di file chiedono al router di aprire una porta, con UPnP, NAT-PMP o PCP. me-web? chiede al router solo se risponde e quale indirizzo internet ha; non apre nulla.",
"FactAdapter": "Scheda di rete",
"FactAddressesSeen": "Indirizzi IPv4 pubblici visti",
"FactChecks": "Verifiche",
"FactDns": "Server dei nomi (DNS)",
"FactGateway": "Indirizzo del router",
"FactHint": "Indizio",
"FactIpv6": "IPv6",
"FactLinkSpeed": "Velocità del collegamento",
"FactLocalAddress": "Indirizzo nella sua rete",
"FactLocalIpv6": "Indirizzi IPv6 di questo dispositivo",
"FactNatPmp": "NAT-PMP",
"FactNetwork": "Rete",
"FactNetworkIpv6": "Rete per IPv6",
"FactPcp": "PCP",
"FactProviderIpv4": "Indirizzo IPv4",
"FactProviderIpv6": "IPv6",
"FactProviderPort25": "Porta 25 della posta",
"FactProviderPublic": "Un indirizzo tutto suo",
"FactProviderSheet": "Scheda tecnica",
"FactPublicV4": "IPv4 pubblico",
"FactPublicV6": "IPv6 pubblico",
"FactRedirectedTo": "Reindirizzato a",
"FactRoute": "Primi passi del percorso",
"FactRouter": "Router",
"FactRouterWan": "Indirizzo internet del router",
"FactSince": "Dal",
"FactUpnp": "UPnP",
"FactWayOut": "Alternativa",
"FactWhoHasIpv6": "Indirizzo IPv6 assegnato a",
"FactWhoHasIt": "Indirizzo IPv4 assegnato a",
"HintSharedHop": "Un router dell'operatore usa un indirizzo dello spazio condiviso (100.64.x.x). Succede sia con la condivisione degli indirizzi sia senza, quindi da solo non prova nulla.",
"HistoryChanged": "Cambi del suo indirizzo IPv4 pubblico dal {1}: {2} (verifiche: {0}).",
"HistoryCleared": "Verifiche precedenti dimenticate.",
"HistoryFirst": "È la prima verifica da questa connessione. Verifichi di nuovo in altri giorni per vedere se l'indirizzo cambia.",
"HistoryStable": "Il suo indirizzo IPv4 pubblico è rimasto lo stesso dal {1} (verifiche: {0}).",
"IconsCredit": "Icone: Lucide (ISC).",
"Language": "Lingua",
"LinkCable": "Cavo",
"LinkMobile": "Dati mobili",
"LinkOther": "Rete",
"LinkWifi": "Wi-Fi",
"MapCredit": "Mappa degli operatori: iptoasn.com (PDDL), dati del {0}.",
"MapMissing": "Questa copia non contiene la mappa degli operatori.",
"NatDirect": "L'indirizzo IPv4 pubblico è assegnato direttamente a questo dispositivo.",
"NatDouble": "Due router in fila: il suo router sta dietro a un altro (doppio NAT).",
"NatHomeRouter": "Un solo router, ed è lui ad avere il suo indirizzo IPv4 pubblico.",
"NatNoIpv4": "Questa connessione non ha IPv4.",
"NatShared": "Il suo operatore condivide un solo indirizzo IPv4 pubblico tra molti clienti (CGNAT).",
"NatTranslated": "Il suo indirizzo viene tradotto di nuovo dopo il router: l'indirizzo del router non è quello che vede internet.",
"NatUnknown": "Da qui non si può dire se l'indirizzo IPv4 pubblico è solo suo: il router non ha detto quale indirizzo ha.",
"OnlineCaptive": "Una pagina di accesso blocca la connessione: apra il browser ed entri in questa rete.",
"OnlineGood": "Questo dispositivo raggiunge internet.",
"OnlineNoNames": "La rete funziona, ma i nomi come meweb.arasaka.software non si risolvono (DNS).",
"OnlineNoNetwork": "Questo dispositivo non è collegato a nessuna rete.",
"OnlineOffline": "Questo dispositivo è in rete, ma non raggiunge internet.",
"OnlineRewritten": "Internet risponde, ma qualcosa lungo la strada modifica le pagine web non protette.",
"OutcomeBad": "Ostacolo",
"OutcomeCaution": "Attenzione",
"OutcomeGood": "Bene",
"OutcomeInfo": "Informazione",
"OutcomeRunning": "In verifica",
"OutcomeUnknown": "Non determinabile",
"OutcomeWaiting": "In attesa",
"PathAnotherRouter": "Un altro router",
"PathInternet": "Internet",
"PathMobileNetwork": "Rete mobile",
"PathProvider": "Il suo operatore",
"PathRouter": "Il suo router",
"PathSharedGateway": "Uscita condivisa dell'operatore",
"PathThisComputer": "Questo computer",
"PathThisPhone": "Questo telefono",
"PathTitle": "Da questo dispositivo a internet",
"PathTranslation": "Un'altra traduzione degli indirizzi",
"PrivacyLine": "Niente telemetria, niente statistiche, niente rapporti sugli arresti anomali. Le sue verifiche restano solo su questo dispositivo.",
"ProviderIpv4PublicDynamic": "di solito pubblico, e può cambiare",
"ProviderIpv4Shared": "di solito condiviso con altri clienti (CGNAT)",
"ProviderIpv4SharedPorts": "di solito condiviso, con una parte delle porte per ciascun cliente",
"ProviderIpv4SharedUntilForward": "condiviso finché non apre una porta sul router dell'operatore, poi solo suo",
"ProviderIpv6Native": "offerto",
"ProviderIpv6None": "non offerto",
"ProviderIpv6Tunnel": "tramite un tunnel (6rd)",
"ProviderListed": "Il suo operatore è {0}.",
"ProviderNoAddress": "Senza un indirizzo pubblico non si può risalire all'operatore.",
"ProviderNoMap": "Questa copia di me-web? non contiene la mappa degli operatori.",
"ProviderNotFound": "La mappa degli operatori non conosce questo indirizzo.",
"ProviderPort25Blocked": "bloccata",
"ProviderPort25BlockedOnRequest": "bloccata, aperta su richiesta",
"ProviderPort25Closed": "chiusa",
"ProviderPort25Open": "aperta",
"ProviderPublicBusinessOnly": "solo per le linee aziendali",
"ProviderPublicFreeOnRequest": "gratis se lo chiede",
"ProviderPublicNone": "non offerto ai privati",
"ProviderPublicNotFound": "non è stata trovata un'offerta per la casa",
"ProviderPublicPaid": "statico, da circa {0} al mese",
"ProviderUnlisted": "Il suo operatore è {0}. me-web? non ha ancora note su questo operatore.",
"ResultsTitle": "Che cosa ha trovato me-web?",
"RouterMobile": "Con i dati mobili non c'è un router di casa: si passa subito dalla rete dell'operatore.",
"RouterNone": "Su questa connessione non è stato trovato un router di casa.",
"RouterOpens": "Il suo router risponde alle app che gli chiedono di aprire porte.",
"RouterRefuses": "Il suo router risponde, ma rifiuta le app che gli chiedono di aprire porte.",
"RouterSilent": "Il suo router non risponde alle app che gli chiedono di aprire porte.",
"SourceCode": "Codice sorgente",
"StatusCaptive": "Una pagina di accesso blocca la connessione",
"StatusChecking": "Verifico la connessione…",
"StatusOffline": "Non connesso a internet",
"StatusOnline": "Connesso a internet",
"SuggestCorrection": "Proponi una correzione",
"VersionLine": "me-web? {0} · software libero (AGPL-3.0)"
"UpdateAvailable": "È disponibile me-web? {0}.",
"UpdateOpen": "Scarica",
"ValueAnswered": "ha risposto",
"ValueCount": "{0}",
"ValueIpv6Direct": "ogni dispositivo ha il suo indirizzo pubblico",
"ValueIpv6NotThisDevice": "qualcosa di diverso da questo dispositivo",
"ValueIpv6Translated": "tradotto lungo la strada",
"ValueIpv6Way": "In IPv6 un dispositivo di casa si può raggiungere direttamente, se il firewall del router lo permette.",
"ValueMbps": "{0} Mbit/s",
"ValueOnThisDevice": "questo dispositivo",
"ValueOpenSheet": "Apri la scheda dell'operatore",
"ValueRefused": "ha rifiutato (codice {0})",
"ValueSharedAtHome": "il router, condiviso da tutti i dispositivi di casa",
"ValueSilent": "nessuna risposta",
"VersionLine": "me-web? {0} · software libero (AGPL-3.0)",
"WhatIsThis": "Che cosa significa?"
}
+70
View File
@@ -0,0 +1,70 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MeWeb.App.Services;
using MeWeb.Core.Checks;
namespace MeWeb.App.ViewModels;
public partial class FactViewModel(Fact fact, CheckId card, Action<string> open) : ObservableObject
{
public string AutomationId => $"fact-{card.ToString().ToLowerInvariant()}-{fact.Label}";
public string Label => Localizer.Instance[fact.Label];
public string Value => Texts.Value(fact);
public string Source => Texts.Source(fact);
public bool ShowLink => fact.Link is not null;
public bool ShowSource => ShowLink && Source.Length > 0;
public bool ShowPlainLink => ShowLink && Source.Length == 0;
public bool ShowValue => !ShowPlainLink;
[RelayCommand]
void Open() => open(fact.Link);
}
public partial class CardViewModel(CheckId id, Action<string> open) : ObservableObject
{
public CheckId Id { get; } = id;
public string AutomationId => $"card-{Id.ToString().ToLowerInvariant()}";
public string ExplainAutomationId => $"explain-{Id.ToString().ToLowerInvariant()}";
public Geometry Icon => Application.Current?.FindResource(Id switch
{
CheckId.Online => "IconGlobe",
CheckId.Connection => "IconNetwork",
CheckId.Addresses => "IconMapPin",
CheckId.Provider => "IconBuilding2",
CheckId.Router => "IconRouter",
CheckId.Nat => "IconLayers",
_ => "IconHistory",
}) as Geometry;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Outcome), nameof(Summary), nameof(Facts), nameof(OutcomeFormatted), nameof(ShowRunning), nameof(ShowSettled),
nameof(IsGood), nameof(IsCaution), nameof(IsBad), nameof(IsUnknown), nameof(IsInfo))]
public partial Finding Finding { get; set; }
[ObservableProperty]
public partial bool IsExpanded { get; set; }
public Outcome Outcome => Finding?.Outcome ?? Outcome.Waiting;
public string Title => Localizer.Instance[$"Card{Id}"];
public string Summary => Texts.Summary(Finding);
public string Explain => Localizer.Instance[$"Explain{Id}"];
public string OutcomeFormatted => Localizer.Instance[$"Outcome{Outcome}"];
public IReadOnlyList<FactViewModel> Facts => Finding?.Facts.Select(f => new FactViewModel(f, Id, open)).ToList() ?? [];
public bool ShowRunning => Outcome is Outcome.Running or Outcome.Waiting;
public bool ShowSettled => !ShowRunning;
public bool IsGood => Outcome == Outcome.Good;
public bool IsCaution => Outcome == Outcome.Caution;
public bool IsBad => Outcome == Outcome.Bad;
public bool IsUnknown => Outcome == Outcome.Unknown;
public bool IsInfo => Outcome == Outcome.Info;
public void Refresh() => OnPropertyChanged(string.Empty);
}
+293 -13
View File
@@ -1,61 +1,336 @@
using System.Collections.ObjectModel;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MeWeb.App.Services;
using MeWeb.Core.Checks;
using MeWeb.Core.History;
using MeWeb.Core.Knowledge;
using MeWeb.Core.Net;
using MeWeb.Shared;
namespace MeWeb.App.ViewModels;
public partial class MainViewModel(ServerClient server) : ObservableObject
public enum Reachability
{
Unknown,
Online,
Captive,
Offline,
}
public partial class MainViewModel : ObservableObject
{
public const string CorrectionsUrl = "https://git.thepra.dev/Arasaka/MeWeb/issues";
public const string SourceUrl = "https://git.thepra.dev/Arasaka/MeWeb";
static readonly CheckId[] CardOrder = [CheckId.Online, CheckId.Nat, CheckId.Router, CheckId.Addresses, CheckId.Provider, CheckId.Connection, CheckId.History];
readonly ServerProbe server;
readonly CheckRunner runner;
readonly KnowledgeBase knowledge;
readonly HistoryStore history;
readonly IPlatform platform;
bool revealing;
bool instant;
public MainViewModel(ServerProbe server, CheckRunner runner, KnowledgeBase knowledge, HistoryStore history, IPlatform platform)
{
this.server = server;
this.runner = runner;
this.knowledge = knowledge;
this.history = history;
this.platform = platform;
Cards = new ObservableCollection<CardViewModel>(CardOrder.Select(id => new CardViewModel(id, OpenLink)));
}
public IReadOnlyList<Language> Languages { get; } = Services.Languages.All;
public ObservableCollection<CardViewModel> Cards { get; }
public ObservableCollection<PathNodeViewModel> Path { get; } = [];
public ILauncher Launcher { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusFormatted), nameof(ShowOnline), nameof(ShowOffline), nameof(ShowChecking))]
[NotifyPropertyChangedFor(nameof(StatusFormatted), nameof(ShowOnline), nameof(ShowOffline), nameof(ShowCaptive), nameof(ShowChecking))]
public partial Reachability Status { get; set; } = Reachability.Unknown;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowDraftNotice), nameof(Flow))]
public partial Language SelectedLanguage { get; set; } = Localizer.Instance.Language;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CheckFormatted))]
public partial bool HasResults { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CheckFormatted))]
public partial bool IsChecking { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(UpdateFormatted), nameof(ShowUpdate))]
public partial string LatestVersion { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(MapFormatted))]
public partial bool ShowContacts { get; set; }
[ObservableProperty]
public partial bool HistoryCleared { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsWide), nameof(IsNarrow), nameof(CardWidth))]
public partial double AvailableWidth { get; set; } = 1100;
public bool IsWide => AvailableWidth >= 840;
public bool IsNarrow => !IsWide;
public double CardWidth
{
get
{
var usable = Math.Max(296, AvailableWidth - 32);
var columns = Math.Max(1, (int)(usable / 360));
return Math.Floor(usable / columns);
}
}
public bool ShowChecking => Status == Reachability.Unknown;
public bool ShowOnline => Status == Reachability.Online;
public bool ShowOffline => Status == Reachability.Offline;
public bool ShowCaptive => Status == Reachability.Captive;
public bool ShowDraftNotice => SelectedLanguage?.IsDraft == true;
public bool ShowUpdate => LatestVersion is not null;
public FlowDirection Flow => Localizer.Instance.IsRightToLeft ? FlowDirection.RightToLeft : FlowDirection.LeftToRight;
public string StatusFormatted => Status switch
public string StatusFormatted => Localizer.Instance[Status switch
{
Reachability.Online => Localizer.Instance["StatusOnline"],
Reachability.Offline => Localizer.Instance["StatusOffline"],
_ => Localizer.Instance["StatusChecking"],
};
Reachability.Online => "StatusOnline",
Reachability.Offline => "StatusOffline",
Reachability.Captive => "StatusCaptive",
_ => "StatusChecking",
}];
public string CheckFormatted => Localizer.Instance[IsChecking ? "Checking" : HasResults ? "CheckAgain" : "Check"];
public string VersionFormatted => Localizer.Instance.Format("VersionLine", BuildInfo.Version);
public string UpdateFormatted => LatestVersion is null ? "" : Localizer.Instance.Format("UpdateAvailable", LatestVersion);
public string MapFormatted => !ShowContacts ? "" : knowledge.HasAsnTable ? Localizer.Instance.Format("MapCredit", knowledge.Asn.SourceDate.ToString("d", Localizer.Instance.Culture)) : Localizer.Instance["MapMissing"];
[RelayCommand]
async Task Refresh(CancellationToken ct)
async Task Start(CancellationToken ct)
{
Status = Reachability.Unknown;
var (state, _) = await server.Probe(ct);
Status = state;
var report = await server.Online(ct);
Status = report.State switch
{
OnlineState.Online or OnlineState.Rewritten => Reachability.Online,
OnlineState.Captive => Reachability.Captive,
_ => Reachability.Offline,
};
NoteVersion(report.Server);
}
[RelayCommand]
async Task OpenCorrections()
async Task Check(CancellationToken ct)
{
if (Launcher is null) return;
await Launcher.LaunchUriAsync(new Uri(CorrectionsUrl));
IsChecking = true;
HasResults = true;
HistoryCleared = false;
foreach (var card in Cards) card.Finding = Finding.Running(card.Id);
ResetPath(platform.Device);
try
{
await runner.Run((finding, run) => Dispatcher.UIThread.Post(() => Apply(finding, run)), ct);
}
finally
{
IsChecking = false;
}
}
public void Replay(RunReport run)
{
HasResults = true;
instant = true;
foreach (var card in Cards) card.Finding = Finding.Running(card.Id);
ResetPath(run.Device);
foreach (var finding in run.Findings.OrderBy(f => f.Id)) Apply(finding, run);
instant = false;
}
[RelayCommand]
void ToggleContacts() => ShowContacts = !ShowContacts;
[RelayCommand]
void ClearHistory()
{
history.Clear();
HistoryCleared = true;
}
[RelayCommand]
void OpenCorrections() => OpenLink(CorrectionsUrl);
[RelayCommand]
void OpenSource() => OpenLink(SourceUrl);
[RelayCommand]
void OpenDownloads() => OpenLink(Endpoints.Base);
void OpenLink(string url)
{
if (Launcher is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return;
_ = Launcher.LaunchUriAsync(uri);
}
void NoteVersion(ViewInfo info)
{
if (info is null || !Version.TryParse(info.Version, out var latest) || !Version.TryParse(BuildInfo.Version, out var mine)) return;
LatestVersion = latest > mine ? info.Version : null;
}
void Apply(Finding finding, RunReport run)
{
var card = Cards.FirstOrDefault(c => c.Id == finding.Id);
if (card is not null) card.Finding = finding;
if (finding.Outcome == Outcome.Running) return;
switch (finding.Id)
{
case CheckId.Online:
Status = finding.Outcome switch
{
Outcome.Good => Reachability.Online,
Outcome.Caution when run.Online?.State == OnlineState.Captive => Reachability.Captive,
Outcome.Caution => Reachability.Online,
_ => Reachability.Offline,
};
NoteVersion(run.Online?.Server);
Settle(NodeRole.Internet, finding.Outcome == Outcome.Good ? Outcome.Good : finding.Outcome);
break;
case CheckId.Connection:
var device = Node(NodeRole.Device);
if (device is not null) device.Subtitle = run.Local?.LocalV4 ?? run.Local?.GlobalV6.FirstOrDefault();
var kind = run.Local?.Active?.Kind ?? LinkKind.Unknown;
var next = Path.Count > 1 ? Path[1] : null;
if (next is not null) next.LinkKey = kind switch
{
LinkKind.Wifi => "LinkWifi",
LinkKind.Cable => "LinkCable",
LinkKind.Mobile => "LinkMobile",
_ => "LinkOther",
};
var router = Node(NodeRole.Router);
if (router is not null && kind == LinkKind.Mobile)
{
router.IconKey = "IconRadioTower";
router.TitleKey = "PathMobileNetwork";
}
Settle(NodeRole.Device, finding.Outcome == Outcome.Bad ? Outcome.Bad : Outcome.Good);
break;
case CheckId.Router:
var routerNode = Node(NodeRole.Router);
if (routerNode is not null && run.Router is not null)
{
if (run.Router.Label is not null) routerNode.TitleText = run.Router.Label;
routerNode.Subtitle = run.Router.WanAddress ?? run.Router.Gateway;
}
Settle(NodeRole.Router, finding.Outcome == Outcome.Info ? Outcome.Good : finding.Outcome);
break;
case CheckId.Nat:
var extra = run.Nat switch
{
NatKind.DoubleNat => new PathNodeViewModel(NodeRole.Extra, "IconRouter", "PathAnotherRouter") { State = Outcome.Running },
NatKind.Shared => new PathNodeViewModel(NodeRole.Extra, "IconLayers", "PathSharedGateway") { State = Outcome.Running },
NatKind.Translated => new PathNodeViewModel(NodeRole.Extra, "IconLayers", "PathTranslation") { State = Outcome.Running },
_ => null,
};
if (extra is not null)
{
var at = Path.IndexOf(Node(NodeRole.Provider));
Path.Insert(at < 0 ? Path.Count : at, extra);
Renumber();
Settle(NodeRole.Extra, finding.Outcome);
}
break;
case CheckId.Provider:
var providerNode = Node(NodeRole.Provider);
var network = run.NetworkV4 ?? run.NetworkV6;
if (providerNode is not null && network is not null)
{
providerNode.TitleText = run.Provider?.Name ?? network.Name;
providerNode.Subtitle = $"AS{network.Number}";
}
Settle(NodeRole.Provider, finding.Outcome == Outcome.Info ? Outcome.Good : finding.Outcome);
break;
case CheckId.Addresses:
var internet = Node(NodeRole.Internet);
if (internet is not null) internet.Subtitle = run.PublicV4 ?? run.PublicV6;
break;
}
}
void ResetPath(DeviceKind device)
{
Path.Clear();
Path.Add(new PathNodeViewModel(NodeRole.Device, device == DeviceKind.Phone ? "IconSmartphone" : "IconLaptop", device == DeviceKind.Phone ? "PathThisPhone" : "PathThisComputer"));
Path.Add(new PathNodeViewModel(NodeRole.Router, "IconRouter", "PathRouter"));
Path.Add(new PathNodeViewModel(NodeRole.Provider, "IconBuilding2", "PathProvider"));
Path.Add(new PathNodeViewModel(NodeRole.Internet, "IconGlobe", "PathInternet"));
foreach (var node in Path) node.State = Outcome.Running;
pending.Clear();
Renumber();
}
readonly Dictionary<PathNodeViewModel, Outcome> pending = [];
void Settle(NodeRole role, Outcome outcome)
{
var node = Node(role);
if (node is null) return;
if (instant)
{
node.State = outcome;
return;
}
pending[node] = outcome;
_ = Reveal();
}
async Task Reveal()
{
if (revealing) return;
revealing = true;
try
{
while (true)
{
var next = Path.FirstOrDefault(n => !n.IsLit);
if (next is null || !pending.TryGetValue(next, out var outcome)) break;
await Task.Delay(320);
next.State = outcome;
pending.Remove(next);
}
}
finally
{
revealing = false;
}
}
void Renumber()
{
for (var i = 0; i < Path.Count; i++) Path[i].ShowConnector = i > 0;
}
PathNodeViewModel Node(NodeRole role) => Path.FirstOrDefault(n => n.Role == role);
partial void OnSelectedLanguageChanged(Language value)
{
if (value is null || value.Code == Localizer.Instance.Language.Code) return;
@@ -63,8 +338,13 @@ public partial class MainViewModel(ServerClient server) : ObservableObject
var settings = AppSettings.Read();
settings.Language = value.Code;
AppSettings.Write(settings);
foreach (var card in Cards) card.Refresh();
foreach (var node in Path) node.Refresh();
OnPropertyChanged(nameof(StatusFormatted));
OnPropertyChanged(nameof(CheckFormatted));
OnPropertyChanged(nameof(VersionFormatted));
OnPropertyChanged(nameof(UpdateFormatted));
OnPropertyChanged(nameof(MapFormatted));
OnPropertyChanged(nameof(Flow));
}
}
@@ -0,0 +1,64 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using MeWeb.App.Services;
using MeWeb.Core.Checks;
namespace MeWeb.App.ViewModels;
public enum NodeRole
{
Device,
Router,
Extra,
Provider,
Internet,
}
public partial class PathNodeViewModel(NodeRole role, string icon, string titleKey) : ObservableObject
{
public NodeRole Role { get; } = role;
public string AutomationId => $"path-{Role.ToString().ToLowerInvariant()}";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Icon))]
public partial string IconKey { get; set; } = icon;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Title))]
public partial string TitleKey { get; set; } = titleKey;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Title))]
public partial string TitleText { get; set; }
[ObservableProperty]
public partial string Subtitle { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LinkLabel))]
public partial string LinkKey { get; set; }
[ObservableProperty]
public partial bool ShowConnector { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLit), nameof(IsRunning), nameof(IsGood), nameof(IsCaution), nameof(IsBad), nameof(IsUnknown))]
public partial Outcome State { get; set; } = Outcome.Waiting;
public Geometry Icon => Application.Current?.FindResource(IconKey) as Geometry;
public string Title => TitleText ?? Localizer.Instance[TitleKey];
public string LinkLabel => LinkKey is null ? "" : Localizer.Instance[LinkKey];
public bool IsLit => State is not (Outcome.Waiting or Outcome.Running);
public bool IsRunning => State == Outcome.Running;
public bool IsGood => State is Outcome.Good or Outcome.Info;
public bool IsCaution => State == Outcome.Caution;
public bool IsBad => State == Outcome.Bad;
public bool IsUnknown => State == Outcome.Unknown;
public void Refresh() => OnPropertyChanged(string.Empty);
}
+219 -40
View File
@@ -6,45 +6,224 @@
x:DataType="vm:MainViewModel"
FlowDirection="{Binding Flow}"
Background="{StaticResource GroundBrush}">
<DockPanel Margin="24,18">
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="title" Text="me-web?" VerticalAlignment="Center" />
<ComboBox Grid.Column="1" MinWidth="180" VerticalAlignment="Center"
AutomationProperties.AutomationId="main-language"
AutomationProperties.Name="{s:Tr Language}"
ItemsSource="{Binding Languages}"
SelectedItem="{Binding SelectedLanguage}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="s:Language">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
<StackPanel DockPanel.Dock="Bottom" Spacing="6" HorizontalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center" IsVisible="{Binding ShowDraftNotice}">
<TextBlock Classes="small" Text="{s:Tr DraftNotice}" VerticalAlignment="Center" />
<Button Classes="link" Content="{s:Tr SuggestCorrection}"
AutomationProperties.AutomationId="main-suggest-correction"
Command="{Binding OpenCorrectionsCommand}" />
</StackPanel>
<TextBlock Classes="small" Text="{Binding VersionFormatted}" HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Spacing="22" VerticalAlignment="Center" HorizontalAlignment="Center" MaxWidth="560">
<Grid Width="140" Height="140" HorizontalAlignment="Center">
<Ellipse Classes="pulse" Width="140" Height="140" Fill="{StaticResource BrandRedBrush}" IsVisible="{Binding ShowChecking}" />
<Ellipse Width="112" Height="112" Fill="{StaticResource SurfaceRaisedBrush}" Stroke="{StaticResource LineBrush}" StrokeThickness="2" />
<Ellipse Width="64" Height="64" Fill="{StaticResource GoodBrush}" IsVisible="{Binding ShowOnline}" />
<Ellipse Width="64" Height="64" Fill="{StaticResource BadBrush}" IsVisible="{Binding ShowOffline}" />
<Ellipse Width="64" Height="64" Fill="{StaticResource TextSoftBrush}" Opacity="0.4" IsVisible="{Binding ShowChecking}" />
<Grid>
<DockPanel>
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto" Margin="20,14,20,6">
<TextBlock Grid.Column="0" Classes="title" Text="me-web?" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="round" Margin="0,0,10,0" VerticalAlignment="Center"
AutomationProperties.AutomationId="main-contacts"
AutomationProperties.Name="{s:Tr Contacts}"
ToolTip.Tip="{s:Tr Contacts}"
Command="{Binding ToggleContactsCommand}">
<Path Classes="icon" Data="{StaticResource IconInfo}" />
</Button>
<ComboBox Grid.Column="2" MinWidth="150" MaxWidth="220" VerticalAlignment="Center"
AutomationProperties.AutomationId="main-language"
AutomationProperties.Name="{s:Tr Language}"
ItemsSource="{Binding Languages}"
SelectedItem="{Binding SelectedLanguage}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="s:Language">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
<TextBlock Classes="status" Text="{Binding StatusFormatted}" />
<TextBlock Classes="lead" Text="{s:Tr AppTagline}" />
<Button Classes="primary" Content="{s:Tr Check}"
AutomationProperties.AutomationId="main-check"
Command="{Binding RefreshCommand}" />
</StackPanel>
</DockPanel>
<StackPanel DockPanel.Dock="Bottom" Spacing="4" Margin="16,6,16,10" HorizontalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center" IsVisible="{Binding ShowDraftNotice}">
<TextBlock Classes="small" Text="{s:Tr DraftNotice}" VerticalAlignment="Center" />
<Button Classes="link" Content="{s:Tr SuggestCorrection}"
AutomationProperties.AutomationId="main-suggest-correction"
Command="{Binding OpenCorrectionsCommand}" />
</StackPanel>
<TextBlock Classes="small" Text="{Binding VersionFormatted}" HorizontalAlignment="Center" />
</StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="26" Margin="8,4,8,24">
<Border Classes="banner" IsVisible="{Binding ShowUpdate}">
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Classes="body" Text="{Binding UpdateFormatted}" VerticalAlignment="Center" />
<Button Classes="quiet" Content="{s:Tr UpdateOpen}"
AutomationProperties.AutomationId="main-update"
Command="{Binding OpenDownloadsCommand}" />
</StackPanel>
</Border>
<StackPanel Spacing="16" HorizontalAlignment="Center" MaxWidth="600" Margin="8,6,8,0">
<Grid Width="120" Height="120" HorizontalAlignment="Center">
<Ellipse Classes="pulse" Width="120" Height="120" IsVisible="{Binding ShowChecking}" />
<Ellipse Width="96" Height="96" Fill="{StaticResource SurfaceRaisedBrush}" Stroke="{StaticResource LineBrush}" StrokeThickness="2" />
<Ellipse Width="54" Height="54" Fill="{StaticResource GoodBrush}" IsVisible="{Binding ShowOnline}" />
<Ellipse Width="54" Height="54" Fill="{StaticResource CautionBrush}" IsVisible="{Binding ShowCaptive}" />
<Ellipse Width="54" Height="54" Fill="{StaticResource BadBrush}" IsVisible="{Binding ShowOffline}" />
<Ellipse Width="54" Height="54" Fill="{StaticResource TextSoftBrush}" Opacity="0.4" IsVisible="{Binding ShowChecking}" />
</Grid>
<TextBlock Classes="status" Text="{Binding StatusFormatted}" />
<TextBlock Classes="lead" Text="{s:Tr AppTagline}" />
<Button Classes="primary" Content="{Binding CheckFormatted}"
AutomationProperties.AutomationId="main-check"
Command="{Binding CheckCommand}" />
</StackPanel>
<StackPanel Spacing="14" IsVisible="{Binding HasResults}">
<TextBlock Classes="section" Text="{s:Tr PathTitle}" HorizontalAlignment="Center" />
<ItemsControl ItemsSource="{Binding Path}" IsVisible="{Binding IsWide}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PathNodeViewModel">
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
<Grid Width="56" Height="64" VerticalAlignment="Top" IsVisible="{Binding ShowConnector}">
<TextBlock Classes="linkLabel" Text="{Binding LinkLabel}" HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,6,0,0" />
<Line StartPoint="4,32" EndPoint="52,32" Classes="connector" Classes.flowing="{Binding IsRunning}" Classes.lit="{Binding IsLit}" />
</Grid>
<StackPanel Width="124" Spacing="6" Classes="node" Classes.lit="{Binding IsLit}"
AutomationProperties.AutomationId="{Binding AutomationId}">
<Grid Width="64" Height="64" HorizontalAlignment="Center">
<Ellipse Classes="pulse" Width="64" Height="64" IsVisible="{Binding IsRunning}" />
<Ellipse Classes="disc" Width="64" Height="64"
Classes.good="{Binding IsGood}" Classes.caution="{Binding IsCaution}"
Classes.bad="{Binding IsBad}" Classes.unknown="{Binding IsUnknown}" />
<Viewbox Width="28" Height="28">
<Path Classes="icon" Data="{Binding Icon}" />
</Viewbox>
</Grid>
<TextBlock Classes="nodeTitle" Text="{Binding Title}" TextAlignment="Center" />
<TextBlock Classes="nodeSubtitle" Text="{Binding Subtitle}" TextAlignment="Center" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding Path}" IsVisible="{Binding IsNarrow}" HorizontalAlignment="Center" MinWidth="280" Margin="16,0">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PathNodeViewModel">
<StackPanel>
<Grid Height="36" ColumnDefinitions="64,*" IsVisible="{Binding ShowConnector}">
<Line Grid.Column="0" StartPoint="32,2" EndPoint="32,34" Classes="connector" Classes.flowing="{Binding IsRunning}" Classes.lit="{Binding IsLit}" />
<TextBlock Grid.Column="1" Classes="linkLabel" Text="{Binding LinkLabel}" VerticalAlignment="Center" Margin="8,0,0,0" />
</Grid>
<Grid ColumnDefinitions="64,*" Classes="node" Classes.lit="{Binding IsLit}"
AutomationProperties.AutomationId="{Binding AutomationId}">
<Grid Grid.Column="0" Width="56" Height="56">
<Ellipse Classes="pulse" Width="56" Height="56" IsVisible="{Binding IsRunning}" />
<Ellipse Classes="disc" Width="56" Height="56"
Classes.good="{Binding IsGood}" Classes.caution="{Binding IsCaution}"
Classes.bad="{Binding IsBad}" Classes.unknown="{Binding IsUnknown}" />
<Viewbox Width="24" Height="24">
<Path Classes="icon" Data="{Binding Icon}" />
</Viewbox>
</Grid>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="12,0,0,0" Spacing="2">
<TextBlock Classes="nodeTitle" Text="{Binding Title}" />
<TextBlock Classes="nodeSubtitle" Text="{Binding Subtitle}" />
</StackPanel>
</Grid>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding HasResults}">
<TextBlock Classes="section" Text="{s:Tr ResultsTitle}" HorizontalAlignment="Center" />
<ItemsControl ItemsSource="{Binding Cards}" HorizontalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel ItemWidth="{Binding CardWidth}" HorizontalAlignment="Center" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CardViewModel">
<Border Classes="card"
Classes.good="{Binding IsGood}" Classes.caution="{Binding IsCaution}"
Classes.bad="{Binding IsBad}" Classes.unknown="{Binding IsUnknown}" Classes.info="{Binding IsInfo}">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="Auto,*,Auto">
<Path Grid.Column="0" Classes="icon" Data="{Binding Icon}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="cardTitle" Text="{Binding Title}" />
<Border Grid.Column="2" Classes="chip">
<TextBlock Text="{Binding OutcomeFormatted}" />
</Border>
</Grid>
<ProgressBar Classes="thin" IsIndeterminate="True" IsVisible="{Binding ShowRunning}" />
<TextBlock Classes="summary" Text="{Binding Summary}" IsVisible="{Binding ShowSettled}" />
<ItemsControl ItemsSource="{Binding Facts}" IsVisible="{Binding ShowSettled}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:FactViewModel">
<Grid ColumnDefinitions="2*,3*" Margin="0,3">
<TextBlock Grid.Column="0" Classes="factLabel" Text="{Binding Label}" />
<StackPanel Grid.Column="1">
<SelectableTextBlock Classes="factValue" Text="{Binding Value}" IsVisible="{Binding ShowValue}" />
<Button Classes="link" Content="{Binding Source}" IsVisible="{Binding ShowSource}"
AutomationProperties.AutomationId="{Binding AutomationId}"
Command="{Binding OpenCommand}" />
<Button Classes="link" Content="{Binding Value}" IsVisible="{Binding ShowPlainLink}"
AutomationProperties.AutomationId="{Binding AutomationId}"
Command="{Binding OpenCommand}" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Expander Classes="explain" Header="{s:Tr WhatIsThis}" IsExpanded="{Binding IsExpanded}"
AutomationProperties.AutomationId="{Binding ExplainAutomationId}">
<TextBlock Classes="body" FontSize="14" Text="{Binding Explain}" />
</Expander>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>
<Border Classes="scrim" IsVisible="{Binding ShowContacts}">
<Border Classes="sheet">
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Classes="section" Margin="0" Text="{s:Tr Contacts}" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="quiet" Content="{s:Tr Close}"
AutomationProperties.AutomationId="contacts-close"
Command="{Binding ToggleContactsCommand}" />
</Grid>
<TextBlock Classes="body" Text="{s:Tr ContactsIntro}" />
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<Path Grid.Row="0" Classes="icon" Data="{StaticResource IconServer}" VerticalAlignment="Top" Margin="0,0,12,12" />
<TextBlock Grid.Row="0" Grid.Column="1" Classes="body" Text="{s:Tr ContactServer}" Margin="0,0,0,12" />
<Path Grid.Row="1" Classes="icon" Data="{StaticResource IconRouter}" VerticalAlignment="Top" Margin="0,0,12,12" />
<TextBlock Grid.Row="1" Grid.Column="1" Classes="body" Text="{s:Tr ContactRouter}" Margin="0,0,0,12" />
<Path Grid.Row="2" Classes="icon" Data="{StaticResource IconRoute}" VerticalAlignment="Top" Margin="0,0,12,12" />
<TextBlock Grid.Row="2" Grid.Column="1" Classes="body" Text="{s:Tr ContactRoute}" Margin="0,0,0,12" />
<Path Grid.Row="3" Classes="icon" Data="{StaticResource IconGlobe}" VerticalAlignment="Top" Margin="0,0,12,12" />
<TextBlock Grid.Row="3" Grid.Column="1" Classes="body" Text="{s:Tr ContactDns}" Margin="0,0,0,12" />
<Path Grid.Row="4" Classes="icon" Data="{StaticResource IconArrowUpRight}" VerticalAlignment="Top" Margin="0,0,12,0" />
<TextBlock Grid.Row="4" Grid.Column="1" Classes="body" Text="{s:Tr ContactLinks}" />
</Grid>
<TextBlock Classes="body" Text="{s:Tr PrivacyLine}" />
<StackPanel Orientation="Horizontal" Spacing="12">
<Button Classes="quiet" Content="{s:Tr ClearHistory}"
AutomationProperties.AutomationId="contacts-clear-history"
Command="{Binding ClearHistoryCommand}" />
<TextBlock Classes="small" Text="{s:Tr HistoryCleared}" VerticalAlignment="Center" IsVisible="{Binding HistoryCleared}" />
</StackPanel>
<TextBlock Classes="small" Text="{s:Tr AboutLine}" />
<Button Classes="link" Content="{s:Tr SourceCode}"
AutomationProperties.AutomationId="contacts-source"
Command="{Binding OpenSourceCommand}" />
<TextBlock Classes="small" Text="{Binding MapFormatted}" />
<TextBlock Classes="small" Text="{s:Tr IconsCredit}" />
</StackPanel>
</ScrollViewer>
</Border>
</Border>
</Grid>
</UserControl>
+11 -5
View File
@@ -1,3 +1,4 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
@@ -12,10 +13,15 @@ public partial class MainView : UserControl
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
if (DataContext is MainViewModel model)
{
model.Launcher = TopLevel.GetTopLevel(this)?.Launcher;
model.RefreshCommand.Execute(null);
}
if (DataContext is not MainViewModel model) return;
model.Launcher = TopLevel.GetTopLevel(this)?.Launcher;
model.AvailableWidth = Bounds.Width;
model.StartCommand.Execute(null);
}
protected override void OnSizeChanged(SizeChangedEventArgs e)
{
base.OnSizeChanged(e);
if (DataContext is MainViewModel model) model.AvailableWidth = e.NewSize.Width;
}
}