Initial commit: SiteViewer kiosk app (WPF + WebView2, schedule, LAN admin).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kykaevv 2026-09-01 13:09:05 +03:00
commit f04a0d517c
13 changed files with 3145 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
# Build
**/bin/
**/obj/
publish/
# IDE
.vs/
*.user
*.suo
*.userosscache
*.sln.docstates
# OS
Thumbs.db
Desktop.ini
# Logs / local data (not in repo)
*.log

18
README.md Normal file
View File

@ -0,0 +1,18 @@
# SiteViewer
Полноэкранное Windows-приложение на C# (WPF + WebView2), целевая платформа **.NET Framework 4.8**.
## Запуск
`D:\SiteViewer\publish\SiteViewer.exe`
Требуется:
- [.NET Framework 4.8](https://dotnet.microsoft.com/download/dotnet-framework/net48)
- WebView2 Runtime (обычно уже есть в Windows 10/11)
## Сборка
```powershell
cd D:\SiteViewer\SiteViewer
dotnet publish -c Release -o D:\SiteViewer\publish
```

28
SiteViewer/App.xaml Normal file
View File

@ -0,0 +1,28 @@
<Application x:Class="SiteViewer.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources>
<!-- iOS Dark System colors -->
<SolidColorBrush x:Key="BgBrush" Color="#000000"/>
<SolidColorBrush x:Key="PanelBrush" Color="#1C1C1E"/>
<SolidColorBrush x:Key="ElevatedBrush" Color="#2C2C2E"/>
<SolidColorBrush x:Key="LineBrush" Color="#38383A"/>
<SolidColorBrush x:Key="TextBrush" Color="#F2F2F7"/>
<SolidColorBrush x:Key="MutedBrush" Color="#8E8E93"/>
<SolidColorBrush x:Key="AccentBrush" Color="#0A84FF"/>
<SolidColorBrush x:Key="DangerBrush" Color="#FF453A"/>
<SolidColorBrush x:Key="FillTertiaryBrush" Color="#3A3A3C"/>
<FontFamily x:Key="AppFont">Segoe UI, Arial Unicode MS, Arial</FontFamily>
<!-- Переопределяем системные кисти, иначе ComboBox рисует белый фон и светлый текст -->
<SolidColorBrush x:Key="{x:Static SystemColors.WindowBrushKey}" Color="#2C2C2E"/>
<SolidColorBrush x:Key="{x:Static SystemColors.WindowTextBrushKey}" Color="#F2F2F7"/>
<SolidColorBrush x:Key="{x:Static SystemColors.ControlBrushKey}" Color="#2C2C2E"/>
<SolidColorBrush x:Key="{x:Static SystemColors.ControlTextBrushKey}" Color="#F2F2F7"/>
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="#0A84FF"/>
<SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="#FFFFFF"/>
<SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}" Color="#3A3A3C"/>
<SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightTextBrushKey}" Color="#F2F2F7"/>
</Application.Resources>
</Application>

97
SiteViewer/App.xaml.cs Normal file
View File

@ -0,0 +1,97 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using SiteViewer.Services;
namespace SiteViewer
{
public partial class App : Application
{
public static SitesStore Sites { get; } = new SitesStore();
public static UiSettings Ui { get; } = new UiSettings();
public static AdminHost Admin { get; } = new AdminHost(Sites);
public static string WebViewUserDataFolder { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SiteViewer",
"WebView2");
protected override void OnStartup(StartupEventArgs e)
{
DispatcherUnhandledException += (_, args) =>
{
args.Handled = true;
try
{
MessageBox.Show(
"Произошла ошибка, приложение продолжит работу.\n\n" + args.Exception.Message,
"SiteViewer",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
catch
{
// ignore
}
};
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
{
if (args.ExceptionObject is Exception ex)
{
try
{
var folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SiteViewer");
Directory.CreateDirectory(folder);
File.AppendAllText(
Path.Combine(folder, "crash.log"),
"[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "] " + ex + "\n\n");
}
catch
{
// ignore
}
}
};
TaskScheduler.UnobservedTaskException += (_, args) =>
{
args.SetObserved();
};
Directory.CreateDirectory(WebViewUserDataFolder);
base.OnStartup(e);
try
{
Admin.Start();
}
catch (Exception ex)
{
MessageBox.Show(
"Не удалось запустить веб-сервер на порту " + AdminHost.Port + ".\n" + ex.Message,
"SiteViewer",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
protected override void OnExit(ExitEventArgs e)
{
try
{
Admin.Dispose();
}
catch
{
// ignore
}
base.OnExit(e);
}
}
}

BIN
SiteViewer/Assets/app.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

314
SiteViewer/MainWindow.xaml Normal file
View File

@ -0,0 +1,314 @@
<Window x:Class="SiteViewer.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="SiteViewer"
FontFamily="{StaticResource AppFont}"
WindowStyle="None"
WindowState="Maximized"
ResizeMode="NoResize"
Background="{StaticResource BgBrush}"
KeyDown="Window_KeyDown"
SizeChanged="Window_SizeChanged">
<Window.Resources>
<Style x:Key="IosButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}"/>
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="14,8"/>
<Setter Property="Margin" Value="0,0,8,0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
CornerRadius="10"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource FillTertiaryBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.72"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="IosDangerButton" TargetType="Button" BasedOn="{StaticResource IosButton}">
<Setter Property="Foreground" Value="{StaticResource DangerBrush}"/>
<Setter Property="Margin" Value="0"/>
</Style>
<Style TargetType="ComboBox">
<Setter Property="MinWidth" Value="200"/>
<Setter Property="MaxWidth" Value="360"/>
<Setter Property="Margin" Value="0,0,10,0"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontFamily" Value="{StaticResource AppFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="Background" Value="{StaticResource ElevatedBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="MainBorder"
Background="{StaticResource ElevatedBrush}"
CornerRadius="10"
SnapsToDevicePixels="True">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="28"/>
</Grid.ColumnDefinitions>
<ToggleButton Grid.ColumnSpan="2"
Focusable="False"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
ClickMode="Press"
Background="Transparent"
BorderThickness="0">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent"/>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter Grid.Column="0"
Margin="12,8,4,8"
VerticalAlignment="Center"
HorizontalAlignment="Left"
RecognizesAccessKey="True"
IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"
TextBlock.Foreground="{StaticResource TextBrush}"
TextBlock.FontFamily="{StaticResource AppFont}"/>
<Path Grid.Column="1"
Data="M 0,0 L 4,4 L 8,0"
Stroke="{StaticResource MutedBrush}"
StrokeThickness="1.6"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"/>
</Grid>
</Border>
<Popup x:Name="Popup"
Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="Slide">
<Grid Name="DropDown"
SnapsToDevicePixels="True"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<Border x:Name="DropDownBorder"
Background="{StaticResource ElevatedBrush}"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
CornerRadius="12"
Margin="0,6,0,0"
Padding="4">
<ScrollViewer Margin="0" SnapsToDevicePixels="True"
VerticalScrollBarVisibility="Auto">
<StackPanel IsItemsHost="True"
KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Border>
</Grid>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
CornerRadius="8"
Padding="{TemplateBinding Padding}">
<ContentPresenter
TextBlock.Foreground="{StaticResource TextBrush}"
TextBlock.FontFamily="{StaticResource AppFont}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource FillTertiaryBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="CaptionLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource MutedBrush}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="0,0,8,0"/>
</Style>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition x:Name="TopRow" Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0"
x:Name="TopChrome"
Background="{StaticResource PanelBrush}"
MouseEnter="TopChrome_MouseEnter"
MouseLeave="TopChrome_MouseLeave">
<Border x:Name="TopHotZone"
Height="18"
VerticalAlignment="Top"
Background="#1C1C1E"
Visibility="Collapsed"/>
<Border x:Name="Toolbar"
Background="#CC1C1C1E"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,0,0,1"
Padding="16,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="SiteViewer"
Foreground="{StaticResource TextBrush}"
FontSize="20"
FontWeight="Bold"
Margin="0,0,20,0"
VerticalAlignment="Center"/>
<StackPanel Margin="0,0,16,0" VerticalAlignment="Center" MinWidth="200">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Окно 1" Style="{StaticResource CaptionLabel}"/>
<ComboBox x:Name="LeftSiteCombo"
SelectionChanged="LeftSiteCombo_SelectionChanged"
MinWidth="160">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Title}"
FontFamily="{StaticResource AppFont}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<TextBlock x:Name="LeftScheduleInfo"
Foreground="{StaticResource MutedBrush}"
FontSize="11"
Margin="0,4,0,0"
TextWrapping="Wrap"
MaxWidth="280"
Opacity="0.9"/>
</StackPanel>
<StackPanel x:Name="RightSchedulePanel"
VerticalAlignment="Center"
MinWidth="200"
Visibility="Collapsed">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="RightLabel"
Text="Окно 2"
Style="{StaticResource CaptionLabel}"/>
<ComboBox x:Name="RightSiteCombo"
SelectionChanged="RightSiteCombo_SelectionChanged"
MinWidth="160">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Title}"
FontFamily="{StaticResource AppFont}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<TextBlock x:Name="RightScheduleInfo"
Foreground="{StaticResource MutedBrush}"
FontSize="11"
Margin="0,4,0,0"
TextWrapping="Wrap"
MaxWidth="280"
Opacity="0.9"/>
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="BtnLayout" Style="{StaticResource IosButton}" Content="Два окна" Click="BtnLayout_Click"/>
<Button Style="{StaticResource IosButton}" Content="Сайты" Click="BtnAdmin_Click"/>
<Button Style="{StaticResource IosButton}" Content="Обновить" Click="BtnRefresh_Click"/>
<Button Style="{StaticResource IosButton}" Content="Очистить кэш" Click="BtnClearCache_Click"/>
<Button x:Name="BtnFullscreen" Style="{StaticResource IosButton}" Content="Окно" Click="BtnFullscreen_Click"/>
<Button Style="{StaticResource IosDangerButton}" Content="Выход" Click="BtnExit_Click"/>
</StackPanel>
</Grid>
</Border>
</Grid>
<Grid x:Name="ContentGrid" Grid.Row="1" Margin="0" Background="{StaticResource BgBrush}">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="ColLeft" Width="*"/>
<ColumnDefinition x:Name="ColSplitter" Width="0"/>
<ColumnDefinition x:Name="ColRight" Width="0"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" ClipToBounds="True">
<wv2:WebView2 x:Name="LeftWebView" DefaultBackgroundColor="#000000"/>
</Border>
<GridSplitter x:Name="Splitter"
Grid.Column="1"
Width="1"
HorizontalAlignment="Stretch"
Background="{StaticResource LineBrush}"
Visibility="Collapsed"/>
<Border x:Name="RightPane"
Grid.Column="2"
Visibility="Collapsed"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1,0,0,0"
ClipToBounds="True">
<wv2:WebView2 x:Name="RightWebView" DefaultBackgroundColor="#000000"/>
</Border>
</Grid>
</Grid>
</Window>

View File

@ -0,0 +1,967 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using SiteViewer.Models;
using SiteViewer.Services;
namespace SiteViewer;
public partial class MainWindow : Window
{
private const double DesignWidth = 1920;
private bool _splitMode;
private bool _isFullscreen = true;
private bool _leftReady;
private bool _rightReady;
private bool _suppressComboEvents;
private bool _exitRequested;
private Guid? _currentLeftId;
private Guid? _currentRightId;
private double _lastZoom = -1;
private CoreWebView2Environment? _webEnv;
private readonly DispatcherTimer _scheduleTimer;
private readonly DispatcherTimer _toolbarHideTimer;
private readonly DispatcherTimer _memoryTimer;
private const string AdaptiveScript = """
(() => {
const css = `
html, body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
height: 100% !important;
overflow: hidden !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
html::-webkit-scrollbar, body::-webkit-scrollbar, *::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}
img, video, iframe, embed, object {
max-width: 100% !important;
height: auto !important;
}
* { box-sizing: border-box; }
`;
let style = document.getElementById('siteviewer-adaptive');
if (!style) {
style = document.createElement('style');
style.id = 'siteviewer-adaptive';
document.documentElement.appendChild(style);
}
style.textContent = css;
let meta = document.querySelector('meta[name="viewport"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'viewport';
document.head?.appendChild(meta);
}
meta.content = 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no';
return true;
})();
""";
public MainWindow()
{
InitializeComponent();
try
{
Icon = BitmapFrame.Create(
new Uri("pack://application:,,,/Assets/app.ico", UriKind.Absolute));
}
catch
{
// без иконки окна приложение всё равно должно запускаться
}
// Таймер расписания: интервал пересчитывается под ближайшую смену
_scheduleTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_scheduleTimer.Tick += (_, _) =>
{
try
{
ApplyScheduledSites(forceReload: false);
}
catch (Exception ex)
{
TryLogSchedule("timer-error: " + ex.Message);
}
};
_toolbarHideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1200) };
_toolbarHideTimer.Tick += (_, _) =>
{
_toolbarHideTimer.Stop();
if (_isFullscreen && !TopChrome.IsMouseOver)
{
SetToolbarExpanded(false);
}
};
// Периодически снижаем целевое потребление памяти WebView2
_memoryTimer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(5) };
_memoryTimer.Tick += (_, _) =>
{
try { ApplyLowMemoryMode(); }
catch { /* ignore */ }
};
Loaded += MainWindow_Loaded;
Closing += MainWindow_Closing;
App.Sites.Changed += OnSitesChanged;
Closed += (_, _) => Cleanup();
}
private void Cleanup()
{
App.Sites.Changed -= OnSitesChanged;
_scheduleTimer.Stop();
_toolbarHideTimer.Stop();
_memoryTimer.Stop();
try
{
if (_leftReady)
{
LeftWebView.CoreWebView2?.Navigate("about:blank");
}
if (_rightReady)
{
RightWebView.CoreWebView2?.Navigate("about:blank");
}
}
catch
{
// ignore
}
}
private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
// Закрытие только по кнопке «Выход» — случайный Alt+F4 / Esc не гасит киоск
if (!_exitRequested)
{
e.Cancel = true;
if (_isFullscreen)
{
ApplyFullscreen(false);
}
}
}
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
try
{
// Дождаться handle окна — иначе WebView2 на net48 часто падает
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Loaded);
if (!LeftWebView.IsVisible)
{
LeftWebView.Visibility = Visibility.Visible;
}
await EnsureLeftWebViewAsync();
RefreshSiteLists();
// Восстановить режим одного/двух окон с прошлого запуска
_splitMode = App.Ui.SplitMode;
ApplyLayout();
if (_splitMode)
{
try
{
await EnsureRightWebViewAsync();
}
catch
{
_splitMode = false;
ApplyLayout();
App.Ui.SplitMode = false;
App.Ui.Save();
}
}
ApplyScheduledSites(forceReload: true);
ApplyFullscreen(App.Ui.Fullscreen);
ApplyAdaptiveZoom();
ApplyLowMemoryMode();
_memoryTimer.Start();
// _scheduleTimer уже запущен из ApplyScheduledSites → ArmScheduleTimer
}
catch (Exception ex)
{
var details = ex.Message;
if (ex.InnerException != null)
{
details += "\n\n" + ex.InnerException.Message;
}
MessageBox.Show(
this,
"Не удалось инициализировать просмотр страниц.\n\n" + details +
"\n\nПроверьте установку WebView2 Runtime:\nhttps://developer.microsoft.com/microsoft-edge/webview2/",
"SiteViewer",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
private async Task EnsureEnvironmentAsync()
{
if (_webEnv != null)
{
return;
}
Directory.CreateDirectory(App.WebViewUserDataFolder);
// Сначала простой профиль; доп. аргументы — только если получится
Exception? lastError = null;
try
{
var options = new CoreWebView2EnvironmentOptions(
additionalBrowserArguments: "--disk-cache-size=67108864");
_webEnv = await CoreWebView2Environment.CreateAsync(
null,
App.WebViewUserDataFolder,
options);
return;
}
catch (Exception ex)
{
lastError = ex;
}
try
{
_webEnv = await CoreWebView2Environment.CreateAsync(
null,
App.WebViewUserDataFolder);
return;
}
catch (Exception ex)
{
lastError = ex;
}
try
{
// Последний fallback — временная папка профиля
var tempProfile = Path.Combine(
Path.GetTempPath(),
"SiteViewerWebView2",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempProfile);
_webEnv = await CoreWebView2Environment.CreateAsync(null, tempProfile);
return;
}
catch (Exception ex)
{
throw new InvalidOperationException(
"WebView2 Environment не создан. " + (lastError ?? ex).Message,
ex);
}
}
private async Task EnsureLeftWebViewAsync()
{
if (_leftReady)
{
return;
}
await EnsureEnvironmentAsync();
await LeftWebView.EnsureCoreWebView2Async(_webEnv);
ConfigureWebView(LeftWebView);
_leftReady = true;
}
private async Task EnsureRightWebViewAsync()
{
if (_rightReady)
{
return;
}
await EnsureEnvironmentAsync();
await RightWebView.EnsureCoreWebView2Async(_webEnv);
ConfigureWebView(RightWebView);
_rightReady = true;
ApplyLowMemoryMode();
}
private void ConfigureWebView(WebView2 view)
{
var core = view.CoreWebView2;
core.Settings.AreDefaultContextMenusEnabled = true;
core.Settings.AreBrowserAcceleratorKeysEnabled = false;
core.Settings.IsStatusBarEnabled = false;
core.Settings.IsZoomControlEnabled = false;
core.Settings.IsGeneralAutofillEnabled = false;
core.Settings.IsPasswordAutosaveEnabled = false;
core.Settings.AreHostObjectsAllowed = false;
try
{
// Экономия RAM, когда окно в фоне / киоск
core.MemoryUsageTargetLevel = CoreWebView2MemoryUsageTargetLevel.Low;
}
catch
{
// старые runtime могут не поддерживать
}
core.NavigationCompleted += async (_, args) =>
{
if (!args.IsSuccess || view.CoreWebView2 is null)
{
return;
}
try
{
await view.CoreWebView2.ExecuteScriptAsync(AdaptiveScript);
}
catch
{
// ignore injection errors on special pages
}
ApplyAdaptiveZoom();
};
}
private void ApplyLowMemoryMode()
{
void Apply(WebView2 view, bool ready)
{
if (!ready || view.CoreWebView2 is null)
{
return;
}
try
{
view.CoreWebView2.MemoryUsageTargetLevel = CoreWebView2MemoryUsageTargetLevel.Low;
}
catch
{
// ignore
}
}
Apply(LeftWebView, _leftReady);
Apply(RightWebView, _rightReady);
}
private void OnSitesChanged()
{
// BeginInvoke — без блокировки потока ASP.NET и без риска дедлока
Dispatcher.BeginInvoke(new Action(() =>
{
try
{
RefreshSiteLists();
ApplyScheduledSites(forceReload: false);
}
catch
{
// ignore
}
}));
}
private void RefreshSiteLists()
{
var leftSites = App.Sites.GetForWindow(1).ToList();
var rightSites = App.Sites.GetForWindow(2).ToList();
var leftId = App.Sites.GetCurrent(1)?.Id ?? _currentLeftId ?? App.Ui.LeftSiteId;
var rightId = App.Sites.GetCurrent(2)?.Id ?? _currentRightId ?? App.Ui.RightSiteId;
_suppressComboEvents = true;
try
{
LeftSiteCombo.ItemsSource = leftSites;
RightSiteCombo.ItemsSource = rightSites;
if (leftId is Guid lid)
{
LeftSiteCombo.SelectedItem = leftSites.FirstOrDefault(s => s.Id == lid);
}
if (rightId is Guid rid)
{
RightSiteCombo.SelectedItem = rightSites.FirstOrDefault(s => s.Id == rid);
}
}
finally
{
_suppressComboEvents = false;
}
UpdateScheduleInfo();
}
private void ApplyScheduledSites(bool forceReload)
{
if (!_leftReady)
{
ArmScheduleTimer();
return;
}
App.Sites.ReloadIfChanged();
var current1 = App.Sites.GetCurrent(1);
var next1 = App.Sites.GetNextScheduled(1);
var current2 = App.Sites.GetCurrent(2);
var next2 = App.Sites.GetNextScheduled(2);
UpdateScheduleInfo(current1, next1, current2, next2);
ApplyWindowSchedule(
window: 1,
current: current1,
view: LeftWebView,
combo: LeftSiteCombo,
getId: () => _currentLeftId,
setId: id => _currentLeftId = id,
forceReload: forceReload);
if (_splitMode && _rightReady)
{
ApplyWindowSchedule(
window: 2,
current: current2,
view: RightWebView,
combo: RightSiteCombo,
getId: () => _currentRightId,
setId: id => _currentRightId = id,
forceReload: forceReload);
}
ArmScheduleTimer();
}
private void ApplyWindowSchedule(
int window,
SiteEntry? current,
WebView2 view,
ComboBox combo,
Func<Guid?> getId,
Action<Guid?> setId,
bool forceReload)
{
if (current == null)
{
return;
}
var shownId = getId();
if (!forceReload && shownId == current.Id)
{
return;
}
TryLogSchedule(
"window=" + window
+ " switch " + (shownId?.ToString() ?? "none")
+ " → " + current.Id
+ " [" + current.Title + "] at " + current.EnabledFrom.ToString("yyyy-MM-dd HH:mm:ss"));
SelectComboById(combo, current.Id);
Navigate(view, current.Url, force: true);
setId(current.Id);
PersistSelectedSites();
}
/// <summary>Ставим таймер на ближайшую дату включения (с запасным опросом).</summary>
private void ArmScheduleTimer()
{
try
{
_scheduleTimer.Stop();
var now = DateTime.Now;
var next = App.Sites.GetNextTransitionTime(now);
TimeSpan delay;
if (next is DateTime when && when > now)
{
delay = when - now + TimeSpan.FromMilliseconds(300);
// Не спим дольше 15 с — подстрахуемся от рассинхрона часов
if (delay > TimeSpan.FromSeconds(15))
{
delay = TimeSpan.FromSeconds(15);
}
if (delay < TimeSpan.FromMilliseconds(400))
{
delay = TimeSpan.FromMilliseconds(400);
}
}
else
{
delay = TimeSpan.FromSeconds(10);
}
_scheduleTimer.Interval = delay;
_scheduleTimer.Start();
}
catch
{
_scheduleTimer.Interval = TimeSpan.FromSeconds(5);
_scheduleTimer.Start();
}
}
private static void TryLogSchedule(string message)
{
try
{
var folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SiteViewer");
Directory.CreateDirectory(folder);
File.AppendAllText(
Path.Combine(folder, "schedule.log"),
"[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "] " + message + Environment.NewLine);
}
catch
{
// ignore
}
}
private void PersistSelectedSites()
{
App.Ui.LeftSiteId = _currentLeftId;
App.Ui.RightSiteId = _currentRightId;
App.Ui.Save();
}
private void UpdateScheduleInfo(
SiteEntry? current1 = null,
SiteEntry? next1 = null,
SiteEntry? current2 = null,
SiteEntry? next2 = null)
{
current1 = current1 ?? App.Sites.GetCurrent(1);
next1 = next1 ?? App.Sites.GetNextScheduled(1);
current2 = current2 ?? App.Sites.GetCurrent(2);
next2 = next2 ?? App.Sites.GetNextScheduled(2);
LeftScheduleInfo.Text = FormatSchedule(current1, next1);
RightScheduleInfo.Text = FormatSchedule(current2, next2);
}
private static string FormatSchedule(SiteEntry? current, SiteEntry? next)
{
if (current == null && next == null)
{
return "Нет сайтов — добавьте в админке";
}
if (current == null)
{
return "Ожидание: " + next!.Title + " · " + next.EnabledFrom.ToString("dd.MM HH:mm");
}
if (next == null)
{
return "Сейчас: " + current.Title + " · с " + current.EnabledFrom.ToString("dd.MM HH:mm")
+ " (нет следующей смены — добавьте ещё сайт в это же окно)";
}
return "Сейчас: " + current.Title + " · с " + current.EnabledFrom.ToString("dd.MM HH:mm")
+ " → " + next.Title + " · " + next.EnabledFrom.ToString("dd.MM HH:mm");
}
private void SelectComboById(ComboBox combo, Guid id)
{
if (combo.ItemsSource is not IEnumerable<SiteEntry> items)
{
return;
}
var match = items.FirstOrDefault(s => s.Id == id);
if (match is null || ReferenceEquals(combo.SelectedItem, match))
{
return;
}
_suppressComboEvents = true;
try
{
combo.SelectedItem = match;
}
finally
{
_suppressComboEvents = false;
}
}
private static void Navigate(WebView2 view, string url, bool force = false)
{
if (view.CoreWebView2 is null || string.IsNullOrWhiteSpace(url))
{
return;
}
try
{
var current = view.Source != null ? view.Source.AbsoluteUri : null;
if (!force
&& !string.IsNullOrEmpty(current)
&& string.Equals(current.TrimEnd('/'), url.TrimEnd('/'), StringComparison.OrdinalIgnoreCase))
{
return;
}
view.CoreWebView2.Navigate(url);
}
catch
{
// сеть/навигация не должна ронять приложение
}
}
private void LeftSiteCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_suppressComboEvents || !_leftReady || LeftSiteCombo.SelectedItem is not SiteEntry site)
{
return;
}
// Ручной просмотр; при наступлении новой даты таймер вернёт расписание
Navigate(LeftWebView, site.Url);
_currentLeftId = site.Id;
PersistSelectedSites();
UpdateScheduleInfo();
}
private void RightSiteCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_suppressComboEvents || !_rightReady || !_splitMode || RightSiteCombo.SelectedItem is not SiteEntry site)
{
return;
}
Navigate(RightWebView, site.Url);
_currentRightId = site.Id;
PersistSelectedSites();
}
private async void BtnLayout_Click(object sender, RoutedEventArgs e)
{
_splitMode = !_splitMode;
ApplyLayout();
App.Ui.SplitMode = _splitMode;
App.Ui.Save();
if (_splitMode)
{
try
{
await EnsureRightWebViewAsync();
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "SiteViewer", MessageBoxButton.OK, MessageBoxImage.Warning);
_splitMode = false;
ApplyLayout();
App.Ui.SplitMode = false;
App.Ui.Save();
return;
}
}
else if (_rightReady)
{
// Освобождаем страницу второго окна
try { RightWebView.CoreWebView2?.Navigate("about:blank"); }
catch { /* ignore */ }
_currentRightId = null;
}
ApplyScheduledSites(forceReload: true);
ApplyAdaptiveZoom();
}
private void ApplyLayout()
{
if (_splitMode)
{
ColLeft.Width = new GridLength(1, GridUnitType.Star);
ColSplitter.Width = new GridLength(4);
ColRight.Width = new GridLength(1, GridUnitType.Star);
Splitter.Visibility = Visibility.Visible;
RightPane.Visibility = Visibility.Visible;
RightSchedulePanel.Visibility = Visibility.Visible;
BtnLayout.Content = "Одно окно";
}
else
{
ColLeft.Width = new GridLength(1, GridUnitType.Star);
ColSplitter.Width = new GridLength(0);
ColRight.Width = new GridLength(0);
Splitter.Visibility = Visibility.Collapsed;
RightPane.Visibility = Visibility.Collapsed;
RightSchedulePanel.Visibility = Visibility.Collapsed;
BtnLayout.Content = "Два окна";
}
}
private void BtnAdmin_Click(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = AdminHost.BaseUrl,
UseShellExecute = true
});
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "SiteViewer", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void BtnRefresh_Click(object sender, RoutedEventArgs e)
{
RefreshSiteLists();
ApplyScheduledSites(forceReload: true);
PersistSelectedSites();
ApplyAdaptiveZoom();
}
private async void BtnClearCache_Click(object sender, RoutedEventArgs e)
{
try
{
await ClearWebViewCacheAsync();
// Перезагрузка текущих страниц без кэша
if (_leftReady && LeftWebView.CoreWebView2 is not null)
{
var url = (LeftSiteCombo.SelectedItem as SiteEntry)?.Url
?? LeftWebView.Source?.AbsoluteUri;
if (!string.IsNullOrWhiteSpace(url) && url != "about:blank")
{
LeftWebView.CoreWebView2.Navigate(url);
}
}
if (_splitMode && _rightReady && RightWebView.CoreWebView2 is not null)
{
var url = (RightSiteCombo.SelectedItem as SiteEntry)?.Url
?? RightWebView.Source?.AbsoluteUri;
if (!string.IsNullOrWhiteSpace(url) && url != "about:blank")
{
RightWebView.CoreWebView2.Navigate(url);
}
}
MessageBox.Show(this, "Кэш страниц очищен.", "SiteViewer",
MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show(this, "Не удалось очистить кэш.\n" + ex.Message, "SiteViewer",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private async Task ClearWebViewCacheAsync()
{
var kinds = CoreWebView2BrowsingDataKinds.DiskCache
| CoreWebView2BrowsingDataKinds.CacheStorage
| CoreWebView2BrowsingDataKinds.ServiceWorkers
| CoreWebView2BrowsingDataKinds.IndexedDb
| CoreWebView2BrowsingDataKinds.LocalStorage;
var tasks = new List<Task>();
if (_leftReady && LeftWebView.CoreWebView2 != null && LeftWebView.CoreWebView2.Profile != null)
{
tasks.Add(LeftWebView.CoreWebView2.Profile.ClearBrowsingDataAsync(kinds));
}
if (_rightReady && RightWebView.CoreWebView2 != null && RightWebView.CoreWebView2.Profile != null
&& !ReferenceEquals(LeftWebView.CoreWebView2?.Profile, RightWebView.CoreWebView2.Profile))
{
tasks.Add(RightWebView.CoreWebView2.Profile.ClearBrowsingDataAsync(kinds));
}
if (tasks.Count == 0 && _leftReady && LeftWebView.CoreWebView2 is not null)
{
// fallback: если Profile недоступен
await LeftWebView.CoreWebView2.CallDevToolsProtocolMethodAsync(
"Network.clearBrowserCache", "{}");
return;
}
if (tasks.Count > 0)
{
await Task.WhenAll(tasks);
}
}
private void BtnFullscreen_Click(object sender, RoutedEventArgs e)
{
ApplyFullscreen(!_isFullscreen);
}
private void ApplyFullscreen(bool fullscreen)
{
_isFullscreen = fullscreen;
_toolbarHideTimer.Stop();
if (fullscreen)
{
WindowStyle = WindowStyle.None;
ResizeMode = ResizeMode.NoResize;
WindowState = WindowState.Maximized;
// Topmost часто мешает стабильности киоска — не держим постоянно
Topmost = false;
BtnFullscreen.Content = "Окно";
SetToolbarExpanded(false);
}
else
{
Topmost = false;
WindowStyle = WindowStyle.SingleBorderWindow;
ResizeMode = ResizeMode.CanResize;
WindowState = WindowState.Normal;
Width = 1280;
Height = 800;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
BtnFullscreen.Content = "Полный экран";
SetToolbarExpanded(true);
}
App.Ui.Fullscreen = fullscreen;
App.Ui.Save();
ApplyAdaptiveZoom();
}
private void TopChrome_MouseEnter(object sender, MouseEventArgs e)
{
if (!_isFullscreen)
{
return;
}
_toolbarHideTimer.Stop();
SetToolbarExpanded(true);
}
private void TopChrome_MouseLeave(object sender, MouseEventArgs e)
{
if (!_isFullscreen)
{
return;
}
_toolbarHideTimer.Stop();
_toolbarHideTimer.Start();
}
private void SetToolbarExpanded(bool expanded)
{
if (expanded || !_isFullscreen)
{
Toolbar.Visibility = Visibility.Visible;
TopHotZone.Visibility = Visibility.Collapsed;
TopChrome.Height = double.NaN;
}
else
{
Toolbar.Visibility = Visibility.Collapsed;
TopHotZone.Visibility = Visibility.Visible;
TopChrome.Height = 18;
TopHotZone.Background = (System.Windows.Media.Brush)FindResource("PanelBrush");
}
}
private void BtnExit_Click(object sender, RoutedEventArgs e)
{
_exitRequested = true;
Close();
}
private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
{
ApplyAdaptiveZoom();
}
private void ApplyAdaptiveZoom()
{
if (!_leftReady)
{
return;
}
var paneWidth = _splitMode
? Math.Max(320, ActualWidth / 2)
: Math.Max(320, ActualWidth);
var zoom = Math.Min(1.35, Math.Max(0.55, paneWidth / DesignWidth));
if (Math.Abs(zoom - _lastZoom) < 0.01)
{
return;
}
_lastZoom = zoom;
try
{
LeftWebView.ZoomFactor = zoom;
if (_splitMode && _rightReady)
{
RightWebView.ZoomFactor = zoom;
}
}
catch
{
// ignore
}
}
private void Window_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
// Esc только выходит из полного экрана, не закрывает приложение
if (_isFullscreen)
{
ApplyFullscreen(false);
}
e.Handled = true;
}
else if (e.Key == Key.F11)
{
ApplyFullscreen(!_isFullscreen);
e.Handled = true;
}
else if (e.Key == Key.F2)
{
BtnLayout_Click(sender, e);
e.Handled = true;
}
}
}

View File

@ -0,0 +1,22 @@
using System;
namespace SiteViewer.Models
{
public sealed class SiteEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
/// <summary>Дата и время, с которых ссылка считается включённой.</summary>
public DateTime EnabledFrom { get; set; } = DateTime.Today;
/// <summary>Окно для показа по расписанию: 1 или 2.</summary>
public int WindowNumber { get; set; } = 1;
public override string ToString()
{
return string.IsNullOrWhiteSpace(Title) ? Url : Title;
}
}
}

View File

@ -0,0 +1,647 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using SiteViewer.Models;
namespace SiteViewer.Services
{
public sealed class AdminHost : IDisposable
{
public const int Port = 5055;
/// <summary>Локальный URL для открытия на этом ПК.</summary>
public const string BaseUrl = "http://127.0.0.1:5055/";
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private readonly SitesStore _store;
private readonly string _wwwroot;
private TcpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _loopTask;
private bool _lanEnabled;
public AdminHost(SitesStore store)
{
_store = store;
_wwwroot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "wwwroot");
}
public bool IsLanEnabled => _lanEnabled;
public IReadOnlyList<string> GetLanUrls()
{
var urls = new List<string>();
try
{
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != OperationalStatus.Up)
{
continue;
}
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback)
{
continue;
}
foreach (var addr in ni.GetIPProperties().UnicastAddresses)
{
if (addr.Address.AddressFamily != AddressFamily.InterNetwork)
{
continue;
}
if (IPAddress.IsLoopback(addr.Address))
{
continue;
}
var ip = addr.Address.ToString();
if (ip.StartsWith("169.254.", StringComparison.Ordinal))
{
continue;
}
urls.Add("http://" + ip + ":" + Port + "/");
}
}
}
catch
{
// ignore
}
return urls.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(u => u).ToList();
}
public void Start()
{
if (_listener != null)
{
return;
}
Directory.CreateDirectory(_wwwroot);
EnsureFirewallRule(Port);
Exception? lanError = null;
try
{
// 0.0.0.0 — доступ с этого ПК и из локальной сети (без URL ACL)
_listener = new TcpListener(IPAddress.Any, Port);
_listener.Start();
_lanEnabled = true;
}
catch (Exception ex)
{
lanError = ex;
try
{
_listener = new TcpListener(IPAddress.Loopback, Port);
_listener.Start();
_lanEnabled = false;
}
catch
{
throw lanError;
}
}
_cts = new CancellationTokenSource();
_loopTask = Task.Run(() => ListenLoopAsync(_cts.Token));
}
private static void EnsureFirewallRule(int port)
{
const string ruleName = "SiteViewer Admin";
RunHidden("netsh", "advfirewall firewall delete rule name=\"" + ruleName + "\"");
RunHidden(
"netsh",
"advfirewall firewall add rule name=\"" + ruleName
+ "\" dir=in action=allow protocol=TCP localport=" + port
+ " profile=private,domain");
}
private static void RunHidden(string fileName, string arguments)
{
try
{
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using (var process = Process.Start(psi))
{
process?.WaitForExit(10000);
}
}
catch
{
// нет прав — правило можно добавить вручную
}
}
private async Task ListenLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested && _listener != null)
{
TcpClient? client = null;
try
{
client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false);
}
catch (ObjectDisposedException)
{
break;
}
catch (SocketException)
{
if (token.IsCancellationRequested)
{
break;
}
continue;
}
catch
{
continue;
}
var accepted = client;
_ = Task.Run(() => HandleClient(accepted), token);
}
}
private void HandleClient(TcpClient client)
{
using (client)
{
try
{
client.ReceiveTimeout = 15000;
client.SendTimeout = 15000;
using (var stream = client.GetStream())
{
var request = ReadHttpRequest(stream);
if (request == null)
{
return;
}
var path = request.Path;
if (string.IsNullOrEmpty(path))
{
path = "/";
}
path = path.TrimEnd('/');
if (string.IsNullOrEmpty(path))
{
path = "/";
}
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
{
HandleApi(request, stream, path);
}
else
{
ServeStatic(stream, path);
}
}
}
catch
{
// обрыв соединения / некорректный запрос
}
}
}
private sealed class HttpRequest
{
public string Method { get; set; } = "GET";
public string Path { get; set; } = "/";
public string Query { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
}
private static HttpRequest? ReadHttpRequest(NetworkStream stream)
{
var buffer = new MemoryStream();
var chunk = new byte[4096];
var headerEnd = -1;
while (headerEnd < 0)
{
var read = stream.Read(chunk, 0, chunk.Length);
if (read <= 0)
{
return null;
}
buffer.Write(chunk, 0, read);
var data = buffer.ToArray();
headerEnd = IndexOfHeaderEnd(data);
if (buffer.Length > 1024 * 1024)
{
return null;
}
}
var raw = buffer.ToArray();
var headerBytes = new byte[headerEnd];
Buffer.BlockCopy(raw, 0, headerBytes, 0, headerEnd);
var headerText = Encoding.ASCII.GetString(headerBytes);
var lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None);
if (lines.Length == 0)
{
return null;
}
var parts = lines[0].Split(new[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
return null;
}
var target = parts[1];
var qIndex = target.IndexOf('?');
var path = qIndex >= 0 ? target.Substring(0, qIndex) : target;
var query = qIndex >= 0 ? target.Substring(qIndex) : string.Empty;
var contentLength = 0;
for (var i = 1; i < lines.Length; i++)
{
var line = lines[i];
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
{
int.TryParse(line.Substring("Content-Length:".Length).Trim(), out contentLength);
}
}
contentLength = Math.Max(0, Math.Min(contentLength, 2 * 1024 * 1024));
var bodyStart = headerEnd + 4;
var body = new MemoryStream();
if (raw.Length > bodyStart)
{
body.Write(raw, bodyStart, raw.Length - bodyStart);
}
while (body.Length < contentLength)
{
var need = contentLength - (int)body.Length;
var read = stream.Read(chunk, 0, Math.Min(chunk.Length, need));
if (read <= 0)
{
break;
}
body.Write(chunk, 0, read);
}
return new HttpRequest
{
Method = parts[0].ToUpperInvariant(),
Path = Uri.UnescapeDataString(path),
Query = query,
Body = Encoding.UTF8.GetString(body.ToArray())
};
}
private static int IndexOfHeaderEnd(byte[] data)
{
for (var i = 0; i + 3 < data.Length; i++)
{
if (data[i] == 13 && data[i + 1] == 10 && data[i + 2] == 13 && data[i + 3] == 10)
{
return i;
}
}
return -1;
}
private void HandleApi(HttpRequest request, NetworkStream stream, string path)
{
var method = request.Method;
if (path.Equals("/api/health", StringComparison.OrdinalIgnoreCase) && method == "GET")
{
WriteJson(stream, 200, new { status = "ok" });
return;
}
if (path.Equals("/api/info", StringComparison.OrdinalIgnoreCase) && method == "GET")
{
WriteJson(stream, 200, new
{
port = Port,
localUrl = BaseUrl,
lanEnabled = _lanEnabled,
lanUrls = GetLanUrls()
});
return;
}
if (path.Equals("/api/sites", StringComparison.OrdinalIgnoreCase) && method == "GET")
{
WriteJson(stream, 200, _store.GetAll());
return;
}
if (path.Equals("/api/sites/current", StringComparison.OrdinalIgnoreCase) && method == "GET")
{
var window = 1;
if (!string.IsNullOrEmpty(request.Query)
&& request.Query.IndexOf("window=2", StringComparison.OrdinalIgnoreCase) >= 0)
{
window = 2;
}
var current = _store.GetCurrent(window);
if (current == null)
{
WriteJson(stream, 404, new { error = "not found" });
}
else
{
WriteJson(stream, 200, current);
}
return;
}
if (path.Equals("/api/sites", StringComparison.OrdinalIgnoreCase) && method == "POST")
{
using (var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(request.Body) ? "{}" : request.Body))
{
var root = doc.RootElement;
var title = GetString(root, "title");
var url = GetString(root, "url");
var enabledFrom = ParseEnabledFrom(root);
if (!TryParseWindowNumber(root, out var windowNumber))
{
WriteJson(stream, 400, new { error = "Укажите окно (1 или 2)" });
return;
}
if (string.IsNullOrWhiteSpace(title))
{
WriteJson(stream, 400, new { error = "Укажите название сайта" });
return;
}
if (string.IsNullOrWhiteSpace(url))
{
WriteJson(stream, 400, new { error = "Укажите адрес сайта" });
return;
}
var created = _store.Add(title, url, enabledFrom, windowNumber);
WriteJson(stream, 200, created);
}
return;
}
if (path.StartsWith("/api/sites/", StringComparison.OrdinalIgnoreCase))
{
var idText = path.Substring("/api/sites/".Length);
if (!Guid.TryParse(idText, out var id))
{
WriteJson(stream, 400, new { error = "bad id" });
return;
}
if (method == "DELETE")
{
WriteJson(stream, _store.Remove(id) ? 200 : 404, new { });
return;
}
if (method == "PUT")
{
using (var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(request.Body) ? "{}" : request.Body))
{
var root = doc.RootElement;
var title = GetString(root, "title");
var url = GetString(root, "url");
var enabledFrom = ParseEnabledFrom(root);
if (!TryParseWindowNumber(root, out var windowNumber))
{
WriteJson(stream, 400, new { error = "Укажите окно (1 или 2)" });
return;
}
if (string.IsNullOrWhiteSpace(title))
{
WriteJson(stream, 400, new { error = "Укажите название сайта" });
return;
}
if (string.IsNullOrWhiteSpace(url))
{
WriteJson(stream, 400, new { error = "Укажите адрес сайта" });
return;
}
WriteJson(stream, _store.Update(id, title, url, enabledFrom, windowNumber) ? 200 : 404, new { });
}
return;
}
}
WriteJson(stream, 404, new { error = "not found" });
}
private void ServeStatic(NetworkStream stream, string path)
{
if (path == "/" || path.Equals("/index.html", StringComparison.OrdinalIgnoreCase))
{
path = "/index.html";
}
var relative = path.TrimStart('/').Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.GetFullPath(Path.Combine(_wwwroot, relative));
if (!fullPath.StartsWith(Path.GetFullPath(_wwwroot), StringComparison.OrdinalIgnoreCase)
|| !File.Exists(fullPath))
{
WriteBytes(stream, 404, "text/plain; charset=utf-8", Encoding.UTF8.GetBytes("Not found"));
return;
}
var bytes = File.ReadAllBytes(fullPath);
WriteBytes(stream, 200, GetContentType(fullPath), bytes);
}
private static string GetContentType(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
switch (ext)
{
case ".html": return "text/html; charset=utf-8";
case ".css": return "text/css; charset=utf-8";
case ".js": return "application/javascript; charset=utf-8";
case ".json": return "application/json; charset=utf-8";
case ".png": return "image/png";
case ".ico": return "image/x-icon";
default: return "application/octet-stream";
}
}
private static string GetString(JsonElement root, string name)
{
if (root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String)
{
return el.GetString() ?? string.Empty;
}
return string.Empty;
}
private static DateTime ParseEnabledFrom(JsonElement root)
{
if (root.TryGetProperty("enabledFrom", out var el))
{
if (el.ValueKind == JsonValueKind.String)
{
var text = el.GetString();
if (!string.IsNullOrWhiteSpace(text))
{
if (DateTimeOffset.TryParse(
text,
CultureInfo.InvariantCulture,
DateTimeStyles.AllowWhiteSpaces,
out var dto))
{
return SitesStore.ToLocalWallClock(dto.LocalDateTime);
}
if (DateTime.TryParse(
text,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces,
out var parsed))
{
return SitesStore.ToLocalWallClock(parsed);
}
}
}
if (el.TryGetDateTime(out var dt))
{
return SitesStore.ToLocalWallClock(dt);
}
}
return DateTime.Now;
}
private static bool TryParseWindowNumber(JsonElement root, out int windowNumber)
{
windowNumber = 0;
if (!root.TryGetProperty("windowNumber", out var el)
|| el.ValueKind == JsonValueKind.Null
|| el.ValueKind == JsonValueKind.Undefined)
{
return false;
}
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var n))
{
if (n == 1 || n == 2)
{
windowNumber = n;
return true;
}
return false;
}
if (el.ValueKind == JsonValueKind.String
&& int.TryParse(el.GetString(), out var parsed)
&& (parsed == 1 || parsed == 2))
{
windowNumber = parsed;
return true;
}
return false;
}
private static void WriteJson(NetworkStream stream, int statusCode, object payload)
{
var json = JsonSerializer.Serialize(payload, JsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
WriteBytes(stream, statusCode, "application/json; charset=utf-8", bytes);
}
private static void WriteBytes(NetworkStream stream, int statusCode, string contentType, byte[] body)
{
var reason = statusCode == 200 ? "OK"
: statusCode == 400 ? "Bad Request"
: statusCode == 404 ? "Not Found"
: "OK";
var header =
"HTTP/1.1 " + statusCode + " " + reason + "\r\n"
+ "Content-Type: " + contentType + "\r\n"
+ "Content-Length: " + body.Length + "\r\n"
+ "Cache-Control: no-store\r\n"
+ "Access-Control-Allow-Origin: *\r\n"
+ "Connection: close\r\n"
+ "\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
stream.Write(headerBytes, 0, headerBytes.Length);
if (body.Length > 0)
{
stream.Write(body, 0, body.Length);
}
stream.Flush();
}
public void Dispose()
{
try { _cts?.Cancel(); } catch { /* ignore */ }
try
{
_listener?.Stop();
}
catch { /* ignore */ }
try { _loopTask?.Wait(1000); } catch { /* ignore */ }
_cts?.Dispose();
_cts = null;
_listener = null;
_loopTask = null;
}
}
}

View File

@ -0,0 +1,434 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using SiteViewer.Models;
namespace SiteViewer.Services
{
public sealed class SitesStore
{
private static readonly Encoding Utf8NoBom = new UTF8Encoding(false);
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Converters = { new LocalDateTimeConverter() }
};
private readonly string _filePath;
private readonly object _sync = new object();
private List<SiteEntry> _sites = new List<SiteEntry>();
private DateTime _lastWriteUtc = DateTime.MinValue;
public SitesStore()
{
var folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SiteViewer");
Directory.CreateDirectory(folder);
_filePath = Path.Combine(folder, "sites.json");
Load();
}
public event Action? Changed;
/// <summary>Перечитать файл, если он изменился на диске.</summary>
public void ReloadIfChanged()
{
lock (_sync)
{
try
{
if (!File.Exists(_filePath))
{
return;
}
var write = File.GetLastWriteTimeUtc(_filePath);
if (write == _lastWriteUtc)
{
return;
}
LoadUnlocked();
}
catch
{
// ignore
}
}
}
public IReadOnlyList<SiteEntry> GetForWindow(int windowNumber)
{
var window = NormalizeWindow(windowNumber);
lock (_sync)
{
return _sites
.Where(s => NormalizeWindow(s.WindowNumber) == window)
.OrderBy(s => ToLocalWallClock(s.EnabledFrom))
.ThenBy(s => s.Title)
.Select(Clone)
.ToList();
}
}
/// <summary>Ближайшее будущее время смены среди окон (или null).</summary>
public DateTime? GetNextTransitionTime(DateTime? at = null)
{
var moment = ToLocalWallClock(at ?? DateTime.Now);
lock (_sync)
{
var next = _sites
.Select(s => (DateTime?)ToLocalWallClock(s.EnabledFrom))
.Where(t => t > moment)
.OrderBy(t => t)
.FirstOrDefault();
return next;
}
}
public IReadOnlyList<SiteEntry> GetAll()
{
lock (_sync)
{
return _sites
.OrderBy(s => s.WindowNumber)
.ThenBy(s => s.EnabledFrom)
.ThenBy(s => s.Title)
.Select(Clone)
.ToList();
}
}
public IReadOnlyList<SiteEntry> GetActive(int windowNumber, DateTime? at = null)
{
var moment = ToLocalWallClock(at ?? DateTime.Now);
var window = NormalizeWindow(windowNumber);
lock (_sync)
{
return _sites
.Where(s => NormalizeWindow(s.WindowNumber) == window
&& ToLocalWallClock(s.EnabledFrom) <= moment)
.OrderBy(s => ToLocalWallClock(s.EnabledFrom))
.Select(Clone)
.ToList();
}
}
public SiteEntry? GetCurrent(int windowNumber, DateTime? at = null)
{
var active = GetActive(windowNumber, at);
return active.Count == 0 ? null : active[active.Count - 1];
}
public SiteEntry? GetNextScheduled(int windowNumber, DateTime? at = null)
{
var moment = ToLocalWallClock(at ?? DateTime.Now);
var window = NormalizeWindow(windowNumber);
lock (_sync)
{
return _sites
.Where(s => NormalizeWindow(s.WindowNumber) == window
&& ToLocalWallClock(s.EnabledFrom) > moment)
.OrderBy(s => ToLocalWallClock(s.EnabledFrom))
.Select(Clone)
.FirstOrDefault();
}
}
public SiteEntry Add(string title, string url, DateTime enabledFrom, int windowNumber)
{
var entry = new SiteEntry
{
Title = title.Trim(),
Url = NormalizeUrl(url),
EnabledFrom = ToLocalWallClock(enabledFrom),
WindowNumber = NormalizeWindow(windowNumber)
};
lock (_sync)
{
_sites.Add(entry);
SaveUnlocked();
}
Changed?.Invoke();
return Clone(entry);
}
public bool Update(Guid id, string title, string url, DateTime enabledFrom, int windowNumber)
{
lock (_sync)
{
var item = _sites.FirstOrDefault(s => s.Id == id);
if (item == null)
{
return false;
}
item.Title = title.Trim();
item.Url = NormalizeUrl(url);
item.EnabledFrom = ToLocalWallClock(enabledFrom);
item.WindowNumber = NormalizeWindow(windowNumber);
SaveUnlocked();
}
Changed?.Invoke();
return true;
}
public bool Remove(Guid id)
{
lock (_sync)
{
var removed = _sites.RemoveAll(s => s.Id == id) > 0;
if (removed)
{
SaveUnlocked();
}
if (removed)
{
Changed?.Invoke();
}
return removed;
}
}
private void Load()
{
lock (_sync)
{
LoadUnlocked();
}
}
private void LoadUnlocked()
{
if (!File.Exists(_filePath))
{
_sites = new List<SiteEntry>
{
new SiteEntry
{
Title = "Example",
Url = "https://example.com",
EnabledFrom = DateTime.Today.AddDays(-1),
WindowNumber = 1
},
new SiteEntry
{
Title = "Wikipedia",
Url = "https://www.wikipedia.org",
EnabledFrom = DateTime.Today.AddDays(1),
WindowNumber = 2
}
};
SaveUnlocked();
return;
}
var json = File.ReadAllText(_filePath, Utf8NoBom);
using (var doc = JsonDocument.Parse(json))
{
_sites = new List<SiteEntry>();
foreach (var el in doc.RootElement.EnumerateArray())
{
var entry = new SiteEntry
{
Id = el.TryGetProperty("id", out var idEl) && idEl.TryGetGuid(out var id)
? id
: Guid.NewGuid(),
Title = el.TryGetProperty("title", out var titleEl)
? titleEl.GetString() ?? string.Empty
: string.Empty,
Url = el.TryGetProperty("url", out var urlEl)
? urlEl.GetString() ?? string.Empty
: string.Empty,
EnabledFrom = el.TryGetProperty("enabledFrom", out var fromEl)
? ReadEnabledFrom(fromEl)
: DateTime.Today,
WindowNumber = el.TryGetProperty("windowNumber", out var winEl)
&& winEl.TryGetInt32(out var win)
? NormalizeWindow(win)
: 1
};
if (string.IsNullOrWhiteSpace(entry.Url))
{
continue;
}
entry.Url = NormalizeUrl(entry.Url);
if (string.IsNullOrWhiteSpace(entry.Title))
{
entry.Title = entry.Url;
}
_sites.Add(entry);
}
}
try
{
_lastWriteUtc = File.GetLastWriteTimeUtc(_filePath);
}
catch
{
_lastWriteUtc = DateTime.UtcNow;
}
// Нормализуем даты в файле (без UTC-смещения, которое ломало сравнение)
if (_sites.Count > 0)
{
SaveUnlocked();
}
}
private void SaveUnlocked()
{
var json = JsonSerializer.Serialize(_sites, JsonOptions);
File.WriteAllText(_filePath, json, Utf8NoBom);
try
{
_lastWriteUtc = File.GetLastWriteTimeUtc(_filePath);
}
catch
{
_lastWriteUtc = DateTime.UtcNow;
}
}
private static int NormalizeWindow(int windowNumber)
{
return windowNumber == 2 ? 2 : 1;
}
private static string NormalizeUrl(string url)
{
url = url.Trim();
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
&& !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
url = "https://" + url;
}
return url;
}
/// <summary>
/// Всегда локальные «стенные» часы ПК. Иначе DateTime с Kind=Utc
/// сравнивается с DateTime.Now по тикам без конвертации и сдвигает расписание.
/// </summary>
public static DateTime ToLocalWallClock(DateTime value)
{
if (value.Kind == DateTimeKind.Utc)
{
return value.ToLocalTime();
}
if (value.Kind == DateTimeKind.Unspecified)
{
return DateTime.SpecifyKind(value, DateTimeKind.Local);
}
return value;
}
private static DateTime ReadEnabledFrom(JsonElement fromEl)
{
if (fromEl.ValueKind == JsonValueKind.String)
{
var text = fromEl.GetString();
if (!string.IsNullOrWhiteSpace(text))
{
// Смещение в строке (+03:00) учитываем и приводим к локальному времени
if (DateTimeOffset.TryParse(
text,
CultureInfo.InvariantCulture,
DateTimeStyles.AllowWhiteSpaces,
out var dto))
{
return ToLocalWallClock(dto.LocalDateTime);
}
if (DateTime.TryParse(
text,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal | DateTimeStyles.AllowWhiteSpaces,
out var parsed))
{
return ToLocalWallClock(parsed);
}
}
}
if (fromEl.TryGetDateTime(out var dt))
{
return ToLocalWallClock(dt);
}
return DateTime.Today;
}
private static SiteEntry Clone(SiteEntry source)
{
return new SiteEntry
{
Id = source.Id,
Title = source.Title,
Url = source.Url,
EnabledFrom = ToLocalWallClock(source.EnabledFrom),
WindowNumber = source.WindowNumber
};
}
/// <summary>Пишем дату без смещения, как локальное время киоска.</summary>
private sealed class LocalDateTimeConverter : JsonConverter<DateTime>
{
private const string Format = "yyyy-MM-dd'T'HH:mm:ss";
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
var text = reader.GetString();
if (!string.IsNullOrWhiteSpace(text)
&& DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var dto))
{
return ToLocalWallClock(dto.LocalDateTime);
}
if (!string.IsNullOrWhiteSpace(text)
&& DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt))
{
return ToLocalWallClock(dt);
}
}
if (reader.TryGetDateTime(out var value))
{
return ToLocalWallClock(value);
}
return DateTime.Today;
}
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
var local = ToLocalWallClock(value);
writer.WriteStringValue(local.ToString(Format, CultureInfo.InvariantCulture));
}
}
}
}

View File

@ -0,0 +1,92 @@
using System;
using System.IO;
using System.Text;
using System.Text.Json;
namespace SiteViewer.Services
{
/// <summary>Сохраняет режим окон и выбранные сайты между перезапусками.</summary>
public sealed class UiSettings
{
private static readonly Encoding Utf8NoBom = new UTF8Encoding(false);
private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly string _filePath;
private readonly object _sync = new object();
public UiSettings()
{
var folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SiteViewer");
Directory.CreateDirectory(folder);
_filePath = Path.Combine(folder, "ui.json");
Load();
}
public bool SplitMode { get; set; }
public bool Fullscreen { get; set; } = true;
public Guid? LeftSiteId { get; set; }
public Guid? RightSiteId { get; set; }
public void Save()
{
lock (_sync)
{
var json = JsonSerializer.Serialize(
new Data
{
SplitMode = SplitMode,
Fullscreen = Fullscreen,
LeftSiteId = LeftSiteId,
RightSiteId = RightSiteId
},
JsonOptions);
File.WriteAllText(_filePath, json, Utf8NoBom);
}
}
private void Load()
{
lock (_sync)
{
try
{
if (!File.Exists(_filePath))
{
return;
}
var json = File.ReadAllText(_filePath, Utf8NoBom);
var data = JsonSerializer.Deserialize<Data>(json, JsonOptions);
if (data == null)
{
return;
}
SplitMode = data.SplitMode;
Fullscreen = data.Fullscreen;
LeftSiteId = data.LeftSiteId;
RightSiteId = data.RightSiteId;
}
catch
{
// повреждённый файл — значения по умолчанию
}
}
}
private sealed class Data
{
public bool SplitMode { get; set; }
public bool Fullscreen { get; set; } = true;
public Guid? LeftSiteId { get; set; }
public Guid? RightSiteId { get; set; }
}
}
}

View File

@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net48</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>disable</ImplicitUsings>
<UseWPF>true</UseWPF>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<AssemblyName>SiteViewer</AssemblyName>
<RootNamespace>SiteViewer</RootNamespace>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<PlatformTarget>x64</PlatformTarget>
<Prefer32Bit>false</Prefer32Bit>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\app.ico" />
</ItemGroup>
<ItemGroup>
<None Update="wwwroot\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,469 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#000000" />
<title>SiteViewer</title>
<style>
:root {
--bg: #000000;
--grouped: #1c1c1e;
--elevated: #2c2c2e;
--fill: #3a3a3c;
--line: #38383a;
--text: #f2f2f7;
--muted: #8e8e93;
--accent: #0a84ff;
--danger: #ff453a;
--ok: #30d158;
--warn: #ffd60a;
--radius: 14px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI Variable", "Segoe UI", Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: var(--font);
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
.wrap {
width: min(920px, 100%);
margin: 0 auto;
padding: 28px 18px 80px;
}
.hero {
margin-bottom: 22px;
animation: rise .45s ease both;
}
h1 {
margin: 0;
font-size: clamp(2rem, 5vw, 2.6rem);
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1.05;
}
.sub {
margin: 10px 0 0;
color: var(--muted);
font-size: 1.02rem;
line-height: 1.45;
max-width: 36em;
}
.card {
background: var(--grouped);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 16px;
animation: rise .5s ease both;
}
.card + .card { animation-delay: .05s; }
.card-pad { padding: 16px; }
.row {
display: grid;
grid-template-columns: 1fr 1.25fr 1fr 0.85fr auto;
gap: 12px;
align-items: end;
}
label {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 7px;
font-weight: 600;
}
input, select {
width: 100%;
padding: 12px 14px;
border-radius: 12px;
border: 0;
background: var(--elevated);
color: var(--text);
font-size: 1rem;
font-family: inherit;
outline: none;
transition: box-shadow .2s ease, background .2s ease;
}
input:focus, select:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent);
background: #323234;
}
button {
border: 0;
border-radius: 12px;
padding: 12px 16px;
font-weight: 600;
font-size: 0.95rem;
font-family: inherit;
cursor: pointer;
transition: transform .12s ease, opacity .12s ease, background .2s ease;
}
button:active { transform: scale(0.97); opacity: 0.88; }
.btn-primary {
background: var(--accent);
color: #fff;
white-space: nowrap;
min-height: 44px;
}
.btn-secondary {
background: var(--elevated);
color: var(--accent);
min-height: 44px;
}
.btn-edit, .btn-danger {
background: transparent;
padding: 8px 12px;
font-size: 0.9rem;
border-radius: 10px;
}
.btn-edit { color: var(--accent); }
.btn-edit:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
.btn-danger { color: var(--danger); }
.btn-danger:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); }
.form-actions { display: flex; gap: 8px; align-items: stretch; }
.actions { white-space: nowrap; }
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
}
th, td {
text-align: left;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
tr:last-child td { border-bottom: 0; }
th {
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
background: color-mix(in srgb, var(--grouped) 80%, #000);
position: sticky;
top: 0;
}
td { font-size: 0.95rem; }
td a {
color: var(--accent);
text-decoration: none;
word-break: break-all;
}
.badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
.badge.on {
background: color-mix(in srgb, var(--ok) 18%, transparent);
color: var(--ok);
}
.badge.wait {
background: color-mix(in srgb, var(--warn) 16%, transparent);
color: var(--warn);
}
.empty {
color: var(--muted);
padding: 28px 16px;
text-align: center;
}
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%) translateY(12px);
background: var(--elevated);
color: var(--text);
padding: 12px 18px;
border-radius: 999px;
opacity: 0;
transition: .28s cubic-bezier(.2,.8,.2,1);
pointer-events: none;
max-width: min(420px, calc(100vw - 32px));
box-shadow: 0 10px 40px rgba(0,0,0,.45);
font-weight: 600;
font-size: 0.92rem;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.toast.ok { box-shadow: 0 0 0 1px color-mix(in srgb, var(--ok) 35%, transparent), 0 10px 40px rgba(0,0,0,.45); }
.toast.err { box-shadow: 0 0 0 1px color-mix(in srgb, var(--danger) 35%, transparent), 0 10px 40px rgba(0,0,0,.45); }
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
@media (max-width: 860px) {
.row { grid-template-columns: 1fr; }
.form-actions { display: grid; grid-template-columns: 1fr; }
.btn-primary, .btn-secondary { width: 100%; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<h1>SiteViewer</h1>
<p class="sub">Для смены по времени добавьте <b>несколько сайтов в одно и то же окно</b> с разными датами включения. Окно 1 и окно 2 — это два экрана, у каждого своё расписание.</p>
<p class="sub" id="accessInfo" style="margin-top:8px;font-size:0.92rem;"></p>
</header>
<section class="card">
<div class="card-pad">
<form id="siteForm" class="row">
<input type="hidden" id="editId" value="" />
<div>
<label for="title">Название</label>
<input id="title" name="title" placeholder="Например, Новости" required minlength="1" />
</div>
<div>
<label for="url">Адрес сайта</label>
<input id="url" name="url" type="url" placeholder="https://example.com" required minlength="1" />
</div>
<div>
<label for="enabledFrom">Дата включения</label>
<input id="enabledFrom" name="enabledFrom" type="datetime-local" required />
</div>
<div>
<label for="windowNumber">Окно</label>
<select id="windowNumber" name="windowNumber" required>
<option value="" disabled selected>Выберите окно</option>
<option value="1">Окно 1</option>
<option value="2">Окно 2</option>
</select>
</div>
<div class="form-actions">
<button class="btn-primary" id="submitBtn" type="submit">Добавить</button>
<button class="btn-secondary" id="cancelEditBtn" type="button" hidden>Отмена</button>
</div>
</form>
</div>
</section>
<section class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Название</th>
<th>Ссылка</th>
<th>Окно</th>
<th>Дата включения</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody id="list"></tbody>
</table>
</div>
<div id="empty" class="empty" hidden>Список пуст — добавьте первую ссылку.</div>
</section>
</div>
<div id="toast" class="toast"></div>
<script>
const listEl = document.getElementById('list');
const emptyEl = document.getElementById('empty');
const toastEl = document.getElementById('toast');
const enabledInput = document.getElementById('enabledFrom');
const editIdInput = document.getElementById('editId');
const submitBtn = document.getElementById('submitBtn');
const cancelEditBtn = document.getElementById('cancelEditBtn');
let sitesCache = [];
function toLocalInputValue(date) {
const d = (date instanceof Date) ? date : new Date(date);
const pad = (n) => String(n).padStart(2, '0');
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
enabledInput.value = toLocalInputValue(new Date());
function toast(msg, ok = true) {
toastEl.textContent = msg;
toastEl.className = 'toast show ' + (ok ? 'ok' : 'err');
setTimeout(() => toastEl.classList.remove('show'), 2200);
}
function formatDate(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString('ru-RU');
}
function statusOf(site) {
const now = Date.now();
const from = new Date(site.enabledFrom).getTime();
if (from <= now) {
return '<span class="badge on">Включена</span>';
}
return '<span class="badge wait">Ожидает</span>';
}
function resetForm() {
editIdInput.value = '';
document.getElementById('title').value = '';
document.getElementById('url').value = '';
document.getElementById('windowNumber').value = '';
enabledInput.value = toLocalInputValue(new Date());
submitBtn.textContent = 'Добавить';
cancelEditBtn.hidden = true;
}
function startEdit(site) {
editIdInput.value = site.id;
document.getElementById('title').value = site.title || '';
document.getElementById('url').value = site.url || '';
document.getElementById('windowNumber').value = String(site.windowNumber === 2 ? 2 : 1);
enabledInput.value = toLocalInputValue(site.enabledFrom);
submitBtn.textContent = 'Сохранить';
cancelEditBtn.hidden = false;
document.getElementById('title').focus();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function loadSites() {
const res = await fetch('/api/sites');
sitesCache = await res.json();
listEl.innerHTML = '';
emptyEl.hidden = sitesCache.length > 0;
for (const site of sitesCache) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(site.title || site.url)}</td>
<td><a href="${escapeAttr(site.url)}" target="_blank" rel="noopener">${escapeHtml(site.url)}</a></td>
<td>Окно ${site.windowNumber === 2 ? 2 : 1}</td>
<td>${escapeHtml(formatDate(site.enabledFrom))}</td>
<td>${statusOf(site)}</td>
<td class="actions">
<button class="btn-edit" data-edit="${site.id}" type="button">Изменить</button>
<button class="btn-danger" data-del="${site.id}" type="button">Удалить</button>
</td>`;
listEl.appendChild(tr);
}
listEl.querySelectorAll('button[data-edit]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-edit');
const site = sitesCache.find(s => s.id === id);
if (site) startEdit(site);
});
});
listEl.querySelectorAll('button[data-del]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-del');
if (!confirm('Удалить сайт?')) return;
const res = await fetch('/api/sites/' + id, { method: 'DELETE' });
if (!res.ok) {
toast('Не удалось удалить', false);
return;
}
if (editIdInput.value === id) resetForm();
toast('Удалено');
loadSites();
});
});
}
cancelEditBtn.addEventListener('click', () => resetForm());
document.getElementById('siteForm').addEventListener('submit', async (e) => {
e.preventDefault();
const title = document.getElementById('title').value.trim();
const url = document.getElementById('url').value.trim();
const enabledFrom = enabledInput.value;
const windowNumber = parseInt(document.getElementById('windowNumber').value, 10);
const editId = editIdInput.value;
if (!title) {
toast('Заполните название сайта', false);
document.getElementById('title').focus();
return;
}
if (!url) {
toast('Заполните адрес сайта', false);
document.getElementById('url').focus();
return;
}
if (windowNumber !== 1 && windowNumber !== 2) {
toast('Выберите окно', false);
document.getElementById('windowNumber').focus();
return;
}
const payload = { title, url, enabledFrom, windowNumber };
const isEdit = !!editId;
const res = await fetch(isEdit ? ('/api/sites/' + editId) : '/api/sites', {
method: isEdit ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Accept': 'application/json'
},
body: JSON.stringify(payload)
});
if (!res.ok) {
let msg = isEdit ? 'Ошибка сохранения' : 'Ошибка добавления';
try {
const err = await res.json();
if (err && err.error) msg = err.error;
} catch (_) {}
toast(msg, false);
return;
}
resetForm();
toast(isEdit ? 'Сохранено' : 'Сайт добавлен');
loadSites();
});
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function escapeAttr(s) {
return escapeHtml(s).replaceAll("'", '&#39;');
}
loadSites().catch(() => toast('Сервер недоступен', false));
async function loadAccessInfo() {
const el = document.getElementById('accessInfo');
if (!el) return;
try {
const res = await fetch('/api/info');
if (!res.ok) return;
const info = await res.json();
const lan = Array.isArray(info.lanUrls) ? info.lanUrls : [];
if (info.lanEnabled && lan.length) {
el.innerHTML = 'Доступ по LAN: ' + lan.map(u =>
'<a href="' + escapeAttr(u) + '" style="color:var(--accent)">' + escapeHtml(u) + '</a>'
).join(' · ');
} else if (lan.length) {
el.textContent = 'LAN пока недоступен. Запустите SiteViewer от имени администратора один раз, затем откройте: ' + lan.join(' · ');
} else {
el.textContent = 'Локально: ' + (info.localUrl || 'http://127.0.0.1:5055/');
}
} catch (_) {}
}
loadAccessInfo();
</script>
</body>
</html>