feat(app): 初始化本地通知桌面应用
搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。 实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。 补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。 Initial-Commit: true
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
.vs/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
7
App.xaml
Normal file
7
App.xaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Application x:Class="OmniNotify.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
ShutdownMode="OnExplicitShutdown">
|
||||||
|
<Application.Resources>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
152
App.xaml.cs
Normal file
152
App.xaml.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Windows;
|
||||||
|
using Forms = System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace OmniNotify;
|
||||||
|
|
||||||
|
public partial class App : System.Windows.Application
|
||||||
|
{
|
||||||
|
private const string SingleInstanceMutexName = @"Local\OmniNotify.SingleInstance";
|
||||||
|
private const string ActivateEventName = @"Local\OmniNotify.Activate";
|
||||||
|
private Forms.NotifyIcon? _notifyIcon;
|
||||||
|
private AppStore? _store;
|
||||||
|
private LocalHttpServer? _server;
|
||||||
|
private Mutex? _singleInstanceMutex;
|
||||||
|
private bool _ownsSingleInstanceMutex;
|
||||||
|
private EventWaitHandle? _activateEvent;
|
||||||
|
private RegisteredWaitHandle? _activateWait;
|
||||||
|
|
||||||
|
public AppState State { get; private set; } = new();
|
||||||
|
public NotificationRouter Router { get; private set; } = null!;
|
||||||
|
public PopupCoordinator PopupCoordinator { get; private set; } = null!;
|
||||||
|
public string ListenUrl => _server?.Url ?? "";
|
||||||
|
public string? ServerError => _server?.LastError;
|
||||||
|
|
||||||
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
|
{
|
||||||
|
_singleInstanceMutex = new Mutex(true, SingleInstanceMutexName, out var createdNew);
|
||||||
|
_ownsSingleInstanceMutex = createdNew;
|
||||||
|
_activateEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivateEventName);
|
||||||
|
if (!createdNew)
|
||||||
|
{
|
||||||
|
_activateEvent.Set();
|
||||||
|
Shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterActivationListener();
|
||||||
|
base.OnStartup(e);
|
||||||
|
|
||||||
|
_store = new AppStore();
|
||||||
|
State = _store.Load();
|
||||||
|
PopupCoordinator = new PopupCoordinator();
|
||||||
|
Router = new NotificationRouter(State, _store, PopupCoordinator);
|
||||||
|
Router.StateChanged += UpdateTrayMenu;
|
||||||
|
|
||||||
|
_server = new LocalHttpServer(Router);
|
||||||
|
_server.Start(State.Settings.LocalPort);
|
||||||
|
|
||||||
|
CreateTrayIcon();
|
||||||
|
ShowMainWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveState()
|
||||||
|
{
|
||||||
|
_store?.Save(State);
|
||||||
|
UpdateTrayMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowMainWindow()
|
||||||
|
{
|
||||||
|
if (MainWindow is null)
|
||||||
|
{
|
||||||
|
MainWindow = new MainWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
MainWindow.Show();
|
||||||
|
MainWindow.WindowState = WindowState.Normal;
|
||||||
|
MainWindow.Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterActivationListener()
|
||||||
|
{
|
||||||
|
if (_activateEvent is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activateWait = ThreadPool.RegisterWaitForSingleObject(
|
||||||
|
_activateEvent,
|
||||||
|
(_, _) => Dispatcher.Invoke(ShowMainWindow),
|
||||||
|
null,
|
||||||
|
Timeout.InfiniteTimeSpan,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateTrayIcon()
|
||||||
|
{
|
||||||
|
var iconPath = Path.Combine(AppContext.BaseDirectory, "app.ico");
|
||||||
|
_notifyIcon = new Forms.NotifyIcon
|
||||||
|
{
|
||||||
|
Icon = new System.Drawing.Icon(iconPath),
|
||||||
|
Text = "Omni-Notify",
|
||||||
|
Visible = true
|
||||||
|
};
|
||||||
|
_notifyIcon.MouseClick += (_, args) =>
|
||||||
|
{
|
||||||
|
if (args.Button == Forms.MouseButtons.Left)
|
||||||
|
{
|
||||||
|
Dispatcher.Invoke(ShowMainWindow);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
UpdateTrayMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateTrayMenu()
|
||||||
|
{
|
||||||
|
if (_notifyIcon is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var menu = new Forms.ContextMenuStrip();
|
||||||
|
menu.Items.Add("打开主控制面板", null, (_, _) => Dispatcher.Invoke(ShowMainWindow));
|
||||||
|
menu.Items.Add("全局设置", null, (_, _) => Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
ShowMainWindow();
|
||||||
|
if (MainWindow is MainWindow window)
|
||||||
|
{
|
||||||
|
window.FocusSettingsTab();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
menu.Items.Add(State.Settings.DndEnabled ? "关闭免打扰" : "开启免打扰", null, (_, _) => Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
State.Settings.DndEnabled = !State.Settings.DndEnabled;
|
||||||
|
SaveState();
|
||||||
|
}));
|
||||||
|
|
||||||
|
var resetBreaker = menu.Items.Add("解除熔断", null, (_, _) => Dispatcher.Invoke(() => Router.ResetCircuitBreaker()));
|
||||||
|
resetBreaker.Enabled = State.Settings.CircuitBreakerOpen;
|
||||||
|
resetBreaker.ForeColor = State.Settings.CircuitBreakerOpen ? System.Drawing.Color.Firebrick : System.Drawing.SystemColors.ControlText;
|
||||||
|
|
||||||
|
menu.Items.Add(new Forms.ToolStripSeparator());
|
||||||
|
menu.Items.Add("退出软件", null, (_, _) => Dispatcher.Invoke(Shutdown));
|
||||||
|
_notifyIcon.ContextMenuStrip = menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnExit(ExitEventArgs e)
|
||||||
|
{
|
||||||
|
_activateWait?.Unregister(null);
|
||||||
|
_activateEvent?.Dispose();
|
||||||
|
if (_ownsSingleInstanceMutex)
|
||||||
|
{
|
||||||
|
_singleInstanceMutex?.ReleaseMutex();
|
||||||
|
}
|
||||||
|
_singleInstanceMutex?.Dispose();
|
||||||
|
_notifyIcon?.Dispose();
|
||||||
|
_server?.Dispose();
|
||||||
|
_store?.Save(State);
|
||||||
|
base.OnExit(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
AssemblyInfo.cs
Normal file
10
AssemblyInfo.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
[assembly:ThemeInfo(
|
||||||
|
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// or application resource dictionaries)
|
||||||
|
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// app, or any theme specific resource dictionaries)
|
||||||
|
)]
|
||||||
206
MainWindow.xaml
Normal file
206
MainWindow.xaml
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<Window x:Class="OmniNotify.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
Title="Omni-Notify"
|
||||||
|
Icon="app.ico"
|
||||||
|
MinHeight="680"
|
||||||
|
MinWidth="1040"
|
||||||
|
Height="760"
|
||||||
|
Width="1180"
|
||||||
|
Background="#F5F7FA">
|
||||||
|
<Grid Margin="18">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<DockPanel Margin="0,0,0,14">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Omni-Notify" FontSize="26" FontWeight="SemiBold" Foreground="#1D2733" />
|
||||||
|
<TextBlock x:Name="ServerStatusText" Margin="0,4,0,0" Foreground="#5C6673" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right" HorizontalAlignment="Right">
|
||||||
|
<Button Content="发送测试" Width="104" Height="34" Margin="0,0,8,0" Click="SendTest_Click" />
|
||||||
|
<Button Content="保存配置" Width="104" Height="34" Click="Save_Click" />
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<TabControl x:Name="MainTabs" Grid.Row="1">
|
||||||
|
<TabItem Header="频道">
|
||||||
|
<Grid Margin="0,14,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="280" />
|
||||||
|
<ColumnDefinition Width="14" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Border Background="White" BorderBrush="#D8DEE8" BorderThickness="1" CornerRadius="6" Padding="12">
|
||||||
|
<DockPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
|
||||||
|
<Button Content="新建频道" Height="32" Margin="0,0,0,8" Click="AddChannel_Click" />
|
||||||
|
<Button Content="删除频道" Height="32" Click="DeleteChannel_Click" />
|
||||||
|
</StackPanel>
|
||||||
|
<ListBox x:Name="ChannelList" DisplayMemberPath="Name" SelectionChanged="ChannelList_SelectionChanged" />
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
|
||||||
|
<Border Background="White" BorderBrush="#D8DEE8" BorderThickness="1" CornerRadius="6" Padding="18">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<GroupBox Header="频道标识" Grid.ColumnSpan="2" Margin="0,0,0,12">
|
||||||
|
<Grid Margin="12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="120" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="频道名" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="ChannelNameBox" Grid.Column="1" />
|
||||||
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<GroupBox Header="多屏与位置" Grid.Row="1" Margin="0,0,8,12">
|
||||||
|
<UniformGrid Columns="2" Margin="12">
|
||||||
|
<TextBlock Text="显示器序号" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="ScreenIndexBox" />
|
||||||
|
<TextBlock Text="九宫格位置" VerticalAlignment="Center" />
|
||||||
|
<ComboBox x:Name="AnchorBox" DisplayMemberPath="Label" SelectedValuePath="Value" />
|
||||||
|
<TextBlock Text="横向边距" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="MarginXBox" />
|
||||||
|
<TextBlock Text="纵向边距" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="MarginYBox" />
|
||||||
|
</UniformGrid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<GroupBox Header="尺寸与排版" Grid.Row="1" Grid.Column="1" Margin="8,0,0,12">
|
||||||
|
<UniformGrid Columns="2" Margin="12">
|
||||||
|
<TextBlock Text="宽度" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="WidthBox" />
|
||||||
|
<TextBlock Text="最大高度" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="MaxHeightBox" />
|
||||||
|
<TextBlock Text="内边距" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="PaddingBox" />
|
||||||
|
<TextBlock Text="字体" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="FontFamilyBox" />
|
||||||
|
</UniformGrid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<GroupBox Header="视觉样式" Grid.Row="2" Margin="0,0,8,12">
|
||||||
|
<UniformGrid Columns="2" Margin="12">
|
||||||
|
<TextBlock Text="标题字号" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="TitleFontSizeBox" />
|
||||||
|
<TextBlock Text="正文字号" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="BodyFontSizeBox" />
|
||||||
|
<TextBlock Text="文字颜色" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="TextColorBox" />
|
||||||
|
<TextBlock Text="背景颜色" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="BackgroundColorBox" />
|
||||||
|
<TextBlock Text="背景透明度" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="BackgroundOpacityBox" />
|
||||||
|
<TextBlock Text="边框颜色" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="BorderColorBox" />
|
||||||
|
<TextBlock Text="边框透明度" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="BorderOpacityBox" />
|
||||||
|
<TextBlock Text="整体透明度" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="OverallOpacityBox" />
|
||||||
|
</UniformGrid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<GroupBox Header="时间、动画与高级行为" Grid.Row="2" Grid.Column="1" Margin="8,0,0,12">
|
||||||
|
<UniformGrid Columns="2" Margin="12">
|
||||||
|
<TextBlock Text="存在时间(秒)" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="LifetimeBox" />
|
||||||
|
<TextBlock Text="出现动画" VerticalAlignment="Center" />
|
||||||
|
<ComboBox x:Name="EnterAnimationBox" DisplayMemberPath="Label" SelectedValuePath="Value" />
|
||||||
|
<TextBlock Text="消失动画" VerticalAlignment="Center" />
|
||||||
|
<ComboBox x:Name="ExitAnimationBox" DisplayMemberPath="Label" SelectedValuePath="Value" />
|
||||||
|
<TextBlock Text="堆叠模式" VerticalAlignment="Center" />
|
||||||
|
<ComboBox x:Name="StackModeBox" DisplayMemberPath="Label" SelectedValuePath="Value" />
|
||||||
|
<TextBlock Text="溢出模式" VerticalAlignment="Center" />
|
||||||
|
<ComboBox x:Name="OverflowModeBox" DisplayMemberPath="Label" SelectedValuePath="Value" />
|
||||||
|
<TextBlock Text="分割间隔(秒)" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="SplitIntervalBox" />
|
||||||
|
<TextBlock Text="滚动停留(秒)" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="VerticalScrollHoldBox" />
|
||||||
|
<TextBlock Text="滚动速度(px/秒)" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="VerticalScrollSpeedBox" />
|
||||||
|
</UniformGrid>
|
||||||
|
</GroupBox>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem Header="历史">
|
||||||
|
<Grid Margin="0,14,0,0">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Border Background="White" BorderBrush="#D8DEE8" BorderThickness="1" CornerRadius="6" Padding="12" Margin="0,0,0,12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="160" />
|
||||||
|
<ColumnDefinition Width="160" />
|
||||||
|
<ColumnDefinition Width="120" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox x:Name="SearchBox" Height="32" Margin="0,0,8,0" TextChanged="HistoryFilter_Changed" />
|
||||||
|
<ComboBox x:Name="HistoryChannelFilter" Grid.Column="1" Margin="0,0,8,0" SelectionChanged="HistoryFilter_Changed" />
|
||||||
|
<ComboBox x:Name="HistoryStatusFilter" Grid.Column="2" Margin="0,0,8,0" SelectionChanged="HistoryFilter_Changed" />
|
||||||
|
<Button Content="清空历史" Grid.Column="3" Click="ClearHistory_Click" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<DataGrid x:Name="HistoryGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True" SelectionMode="Single">
|
||||||
|
<DataGrid.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="复制完整内容" Click="CopyHistory_Click" />
|
||||||
|
<MenuItem Header="重新显示该弹窗" Click="ReplayHistory_Click" />
|
||||||
|
</ContextMenu>
|
||||||
|
</DataGrid.ContextMenu>
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="接收时间" Binding="{Binding ReceivedAt}" Width="170" />
|
||||||
|
<DataGridTextColumn Header="频道来源" Binding="{Binding Channel}" Width="140" />
|
||||||
|
<DataGridTextColumn Header="状态" Binding="{Binding Status}" Width="130" />
|
||||||
|
<DataGridTextColumn Header="标题" Binding="{Binding Title}" Width="220" />
|
||||||
|
<DataGridTextColumn Header="正文" Binding="{Binding Body}" Width="*" />
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Grid>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem Header="全局设置">
|
||||||
|
<Border Background="White" BorderBrush="#D8DEE8" BorderThickness="1" CornerRadius="6" Padding="18" Margin="0,14,0,0">
|
||||||
|
<StackPanel Width="520" HorizontalAlignment="Left">
|
||||||
|
<CheckBox x:Name="StartWithWindowsBox" Content="开机自启" Margin="0,0,0,14" />
|
||||||
|
<CheckBox x:Name="DndBox" Content="免打扰模式:接收但不弹窗" Margin="0,0,0,14" />
|
||||||
|
<UniformGrid Columns="2">
|
||||||
|
<TextBlock Text="每秒最多处理消息数" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="RateLimitBox" />
|
||||||
|
<TextBlock Text="历史保留天数" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="RetainDaysBox" />
|
||||||
|
<TextBlock Text="历史最多保留条数" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="RetainCountBox" />
|
||||||
|
<TextBlock Text="本地监听端口" VerticalAlignment="Center" />
|
||||||
|
<TextBox x:Name="PortBox" />
|
||||||
|
</UniformGrid>
|
||||||
|
<Button x:Name="ResetBreakerButton" Content="解除熔断" Height="34" Margin="0,18,0,0" Click="ResetBreaker_Click" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
372
MainWindow.xaml.cs
Normal file
372
MainWindow.xaml.cs
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using Clipboard = System.Windows.Clipboard;
|
||||||
|
using MessageBox = System.Windows.MessageBox;
|
||||||
|
|
||||||
|
namespace OmniNotify;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private const string AllChannelsLabel = "全部频道";
|
||||||
|
private const string AllStatusesLabel = "全部状态";
|
||||||
|
private readonly App _app;
|
||||||
|
private readonly AppState _state;
|
||||||
|
private readonly ICollectionView _historyView;
|
||||||
|
private bool _loading;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_app = (App)System.Windows.Application.Current;
|
||||||
|
_state = _app.State;
|
||||||
|
|
||||||
|
AnchorBox.ItemsSource = LabeledValues(
|
||||||
|
(ScreenAnchor.TopLeft, "左上"),
|
||||||
|
(ScreenAnchor.TopCenter, "顶部居中"),
|
||||||
|
(ScreenAnchor.TopRight, "右上"),
|
||||||
|
(ScreenAnchor.MiddleLeft, "左侧居中"),
|
||||||
|
(ScreenAnchor.Center, "屏幕中央"),
|
||||||
|
(ScreenAnchor.MiddleRight, "右侧居中"),
|
||||||
|
(ScreenAnchor.BottomLeft, "左下"),
|
||||||
|
(ScreenAnchor.BottomCenter, "底部居中"),
|
||||||
|
(ScreenAnchor.BottomRight, "右下"));
|
||||||
|
EnterAnimationBox.ItemsSource = LabeledValues(
|
||||||
|
(PopupAnimation.Fade, "淡入淡出"),
|
||||||
|
(PopupAnimation.Slide, "滑入滑出"),
|
||||||
|
(PopupAnimation.Zoom, "缩放"));
|
||||||
|
ExitAnimationBox.ItemsSource = EnterAnimationBox.ItemsSource;
|
||||||
|
StackModeBox.ItemsSource = LabeledValues(
|
||||||
|
(StackMode.Queue, "排队显示"),
|
||||||
|
(StackMode.Push, "并排堆叠"),
|
||||||
|
(StackMode.Replace, "替换旧弹窗"));
|
||||||
|
OverflowModeBox.ItemsSource = LabeledValues(
|
||||||
|
(OverflowMode.Truncate, "截断省略"),
|
||||||
|
(OverflowMode.VerticalScroll, "纵向滚动"),
|
||||||
|
(OverflowMode.Split, "分割显示"));
|
||||||
|
|
||||||
|
ChannelList.ItemsSource = _state.Channels;
|
||||||
|
HistoryGrid.ItemsSource = _state.History;
|
||||||
|
_historyView = CollectionViewSource.GetDefaultView(_state.History);
|
||||||
|
_historyView.Filter = FilterHistory;
|
||||||
|
|
||||||
|
_app.Router.StateChanged += RefreshAll;
|
||||||
|
Loaded += (_, _) => RefreshAll();
|
||||||
|
Closing += MainWindow_Closing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FocusSettingsTab()
|
||||||
|
{
|
||||||
|
MainTabs.SelectedIndex = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshAll()
|
||||||
|
{
|
||||||
|
Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
ServerStatusText.Text = string.IsNullOrWhiteSpace(_app.ServerError)
|
||||||
|
? $"本地接收地址:{_app.ListenUrl}notify"
|
||||||
|
: $"本地监听失败:{_app.ServerError}";
|
||||||
|
|
||||||
|
RefreshHistoryFilters();
|
||||||
|
LoadSettings();
|
||||||
|
LoadSelectedChannel();
|
||||||
|
ResetBreakerButton.IsEnabled = _state.Settings.CircuitBreakerOpen;
|
||||||
|
_historyView.Refresh();
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MainWindow_Closing(object? sender, CancelEventArgs e)
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddChannel_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
SaveSelectedChannel();
|
||||||
|
var baseName = "channel";
|
||||||
|
var index = 1;
|
||||||
|
while (_state.Channels.Any(channel => channel.Name == $"{baseName}-{index}"))
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = new Channel { Name = $"{baseName}-{index}" };
|
||||||
|
_state.Channels.Add(item);
|
||||||
|
ChannelList.SelectedItem = item;
|
||||||
|
_app.SaveState();
|
||||||
|
RefreshHistoryFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteChannel_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (ChannelList.SelectedItem is not Channel channel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MessageBox.Show($"确认删除频道 {channel.Name}?", "删除频道", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.Channels.Remove(channel);
|
||||||
|
ChannelList.SelectedIndex = _state.Channels.Count > 0 ? 0 : -1;
|
||||||
|
_app.SaveState();
|
||||||
|
RefreshHistoryFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ChannelList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_loading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadSelectedChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
SaveSelectedChannel();
|
||||||
|
SaveSettings();
|
||||||
|
_app.SaveState();
|
||||||
|
MessageBox.Show("配置已保存。", "Omni-Notify", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendTest_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
SaveSelectedChannel();
|
||||||
|
var channel = ChannelList.SelectedItem as Channel ?? _state.Channels.FirstOrDefault();
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
MessageBox.Show("请先创建频道。", "Omni-Notify", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_app.Router.Receive(new IncomingMessage
|
||||||
|
{
|
||||||
|
Channel = channel.Name,
|
||||||
|
Title = "Omni-Notify 测试消息",
|
||||||
|
Body = "这是一条按当前频道样式展示的纯文本弹窗,用于验证位置、尺寸、透明度、动画、历史记录,以及在内容较长时的截断、纵向滚动或分割显示效果。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadSelectedChannel()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
if (ChannelList.SelectedItem is null && _state.Channels.Count > 0)
|
||||||
|
{
|
||||||
|
ChannelList.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ChannelList.SelectedItem is Channel channel)
|
||||||
|
{
|
||||||
|
var profile = channel.Profile;
|
||||||
|
ChannelNameBox.Text = channel.Name;
|
||||||
|
ScreenIndexBox.Text = profile.ScreenIndex.ToString();
|
||||||
|
AnchorBox.SelectedValue = profile.Anchor;
|
||||||
|
MarginXBox.Text = profile.MarginX.ToString("0.##");
|
||||||
|
MarginYBox.Text = profile.MarginY.ToString("0.##");
|
||||||
|
WidthBox.Text = profile.Width.ToString("0.##");
|
||||||
|
MaxHeightBox.Text = profile.MaxHeight.ToString("0.##");
|
||||||
|
PaddingBox.Text = profile.Padding.ToString("0.##");
|
||||||
|
FontFamilyBox.Text = profile.FontFamily;
|
||||||
|
TitleFontSizeBox.Text = profile.TitleFontSize.ToString("0.##");
|
||||||
|
BodyFontSizeBox.Text = profile.BodyFontSize.ToString("0.##");
|
||||||
|
TextColorBox.Text = profile.TextColor;
|
||||||
|
BackgroundColorBox.Text = profile.BackgroundColor;
|
||||||
|
BackgroundOpacityBox.Text = profile.BackgroundOpacity.ToString("0.##");
|
||||||
|
BorderColorBox.Text = profile.BorderColor;
|
||||||
|
BorderOpacityBox.Text = profile.BorderOpacity.ToString("0.##");
|
||||||
|
OverallOpacityBox.Text = profile.OverallOpacity.ToString("0.##");
|
||||||
|
LifetimeBox.Text = profile.LifetimeSeconds.ToString("0.##");
|
||||||
|
EnterAnimationBox.SelectedValue = profile.EnterAnimation;
|
||||||
|
ExitAnimationBox.SelectedValue = profile.ExitAnimation;
|
||||||
|
StackModeBox.SelectedValue = profile.StackMode;
|
||||||
|
OverflowModeBox.SelectedValue = profile.OverflowMode;
|
||||||
|
SplitIntervalBox.Text = profile.SplitIntervalSeconds.ToString("0.##");
|
||||||
|
VerticalScrollHoldBox.Text = profile.VerticalScrollHoldSeconds.ToString("0.##");
|
||||||
|
VerticalScrollSpeedBox.Text = profile.VerticalScrollSpeed.ToString("0.##");
|
||||||
|
}
|
||||||
|
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveSelectedChannel()
|
||||||
|
{
|
||||||
|
if (ChannelList.SelectedItem is not Channel channel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newName = ChannelNameBox.Text.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(newName))
|
||||||
|
{
|
||||||
|
newName = channel.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_state.Channels.Any(candidate => candidate != channel && candidate.Name == newName))
|
||||||
|
{
|
||||||
|
MessageBox.Show("频道名必须唯一。", "Omni-Notify", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.Name = newName;
|
||||||
|
var profile = channel.Profile;
|
||||||
|
profile.ScreenIndex = ReadInt(ScreenIndexBox, profile.ScreenIndex);
|
||||||
|
profile.Anchor = AnchorBox.SelectedValue as ScreenAnchor? ?? profile.Anchor;
|
||||||
|
profile.MarginX = ReadDouble(MarginXBox, profile.MarginX);
|
||||||
|
profile.MarginY = ReadDouble(MarginYBox, profile.MarginY);
|
||||||
|
profile.Width = ReadDouble(WidthBox, profile.Width);
|
||||||
|
profile.MaxHeight = ReadDouble(MaxHeightBox, profile.MaxHeight);
|
||||||
|
profile.Padding = ReadDouble(PaddingBox, profile.Padding);
|
||||||
|
profile.FontFamily = FontFamilyBox.Text.Trim();
|
||||||
|
profile.TitleFontSize = ReadDouble(TitleFontSizeBox, profile.TitleFontSize);
|
||||||
|
profile.BodyFontSize = ReadDouble(BodyFontSizeBox, profile.BodyFontSize);
|
||||||
|
profile.TextColor = TextColorBox.Text.Trim();
|
||||||
|
profile.BackgroundColor = BackgroundColorBox.Text.Trim();
|
||||||
|
profile.BackgroundOpacity = ReadDouble(BackgroundOpacityBox, profile.BackgroundOpacity);
|
||||||
|
profile.BorderColor = BorderColorBox.Text.Trim();
|
||||||
|
profile.BorderOpacity = ReadDouble(BorderOpacityBox, profile.BorderOpacity);
|
||||||
|
profile.OverallOpacity = ReadDouble(OverallOpacityBox, profile.OverallOpacity);
|
||||||
|
profile.LifetimeSeconds = ReadDouble(LifetimeBox, profile.LifetimeSeconds);
|
||||||
|
profile.EnterAnimation = EnterAnimationBox.SelectedValue as PopupAnimation? ?? profile.EnterAnimation;
|
||||||
|
profile.ExitAnimation = ExitAnimationBox.SelectedValue as PopupAnimation? ?? profile.ExitAnimation;
|
||||||
|
profile.StackMode = StackModeBox.SelectedValue as StackMode? ?? profile.StackMode;
|
||||||
|
profile.OverflowMode = OverflowModeBox.SelectedValue as OverflowMode? ?? profile.OverflowMode;
|
||||||
|
profile.SplitIntervalSeconds = ReadDouble(SplitIntervalBox, profile.SplitIntervalSeconds);
|
||||||
|
profile.VerticalScrollHoldSeconds = ReadDouble(VerticalScrollHoldBox, profile.VerticalScrollHoldSeconds);
|
||||||
|
profile.VerticalScrollSpeed = ReadDouble(VerticalScrollSpeedBox, profile.VerticalScrollSpeed);
|
||||||
|
ChannelList.Items.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadSettings()
|
||||||
|
{
|
||||||
|
StartWithWindowsBox.IsChecked = _state.Settings.StartWithWindows;
|
||||||
|
DndBox.IsChecked = _state.Settings.DndEnabled;
|
||||||
|
RateLimitBox.Text = _state.Settings.MaxMessagesPerSecond.ToString();
|
||||||
|
RetainDaysBox.Text = _state.Settings.RetainDays.ToString();
|
||||||
|
RetainCountBox.Text = _state.Settings.RetainCount.ToString();
|
||||||
|
PortBox.Text = _state.Settings.LocalPort.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveSettings()
|
||||||
|
{
|
||||||
|
_state.Settings.StartWithWindows = StartWithWindowsBox.IsChecked == true;
|
||||||
|
_state.Settings.DndEnabled = DndBox.IsChecked == true;
|
||||||
|
_state.Settings.MaxMessagesPerSecond = ReadInt(RateLimitBox, _state.Settings.MaxMessagesPerSecond);
|
||||||
|
_state.Settings.RetainDays = ReadInt(RetainDaysBox, _state.Settings.RetainDays);
|
||||||
|
_state.Settings.RetainCount = ReadInt(RetainCountBox, _state.Settings.RetainCount);
|
||||||
|
_state.Settings.LocalPort = ReadInt(PortBox, _state.Settings.LocalPort);
|
||||||
|
StartupManager.Apply(_state.Settings.StartWithWindows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshHistoryFilters()
|
||||||
|
{
|
||||||
|
var selectedChannel = HistoryChannelFilter.SelectedItem as string ?? AllChannelsLabel;
|
||||||
|
HistoryChannelFilter.ItemsSource = new[] { AllChannelsLabel }.Concat(_state.Channels.Select(channel => channel.Name)).ToList();
|
||||||
|
HistoryChannelFilter.SelectedItem = HistoryChannelFilter.Items.Contains(selectedChannel) ? selectedChannel : AllChannelsLabel;
|
||||||
|
|
||||||
|
var selectedStatus = HistoryStatusFilter.SelectedItem as string ?? AllStatusesLabel;
|
||||||
|
HistoryStatusFilter.ItemsSource = new[] { AllStatusesLabel }.Concat(Enum.GetValues<NotificationStatus>().Select(StatusLabel)).ToList();
|
||||||
|
HistoryStatusFilter.SelectedItem = HistoryStatusFilter.Items.Contains(selectedStatus) ? selectedStatus : AllStatusesLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool FilterHistory(object item)
|
||||||
|
{
|
||||||
|
if (item is not HistoryItem history)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyword = SearchBox.Text.Trim();
|
||||||
|
var channel = HistoryChannelFilter.SelectedItem as string;
|
||||||
|
var status = HistoryStatusFilter.SelectedItem as string;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword) &&
|
||||||
|
!history.Title.Contains(keyword, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!history.Body.Contains(keyword, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!history.Channel.Contains(keyword, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(channel) && channel != AllChannelsLabel && history.Channel != channel)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(status) || status == AllStatusesLabel || StatusLabel(history.Status) == status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HistoryFilter_Changed(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_loading)
|
||||||
|
{
|
||||||
|
_historyView.Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearHistory_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (MessageBox.Show("确认清空所有历史记录?", "清空历史", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.History.Clear();
|
||||||
|
_app.SaveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopyHistory_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (HistoryGrid.SelectedItem is HistoryItem item)
|
||||||
|
{
|
||||||
|
Clipboard.SetText($"{item.Title}{Environment.NewLine}{item.Body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReplayHistory_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (HistoryGrid.SelectedItem is HistoryItem item)
|
||||||
|
{
|
||||||
|
_app.Router.Replay(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetBreaker_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_app.Router.ResetCircuitBreaker();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadInt(System.Windows.Controls.TextBox box, int fallback)
|
||||||
|
{
|
||||||
|
return int.TryParse(box.Text, out var value) ? Math.Max(0, value) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ReadDouble(System.Windows.Controls.TextBox box, double fallback)
|
||||||
|
{
|
||||||
|
return double.TryParse(box.Text, out var value) ? Math.Max(0, value) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<OptionItem<T>> LabeledValues<T>(params (T Value, string Label)[] items) where T : struct, Enum
|
||||||
|
{
|
||||||
|
return items.Select(item => new OptionItem<T>(item.Value, item.Label)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StatusLabel(NotificationStatus status) => status switch
|
||||||
|
{
|
||||||
|
NotificationStatus.Displayed => "已显示",
|
||||||
|
NotificationStatus.DndMuted => "免打扰",
|
||||||
|
NotificationStatus.IllegalChannel => "无效频道",
|
||||||
|
NotificationStatus.System => "系统",
|
||||||
|
_ => status.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed record OptionItem<T>(T Value, string Label) where T : struct, Enum;
|
||||||
|
}
|
||||||
119
Models.cs
Normal file
119
Models.cs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace OmniNotify;
|
||||||
|
|
||||||
|
public enum NotificationStatus
|
||||||
|
{
|
||||||
|
Displayed,
|
||||||
|
DndMuted,
|
||||||
|
IllegalChannel,
|
||||||
|
System
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ScreenAnchor
|
||||||
|
{
|
||||||
|
TopLeft,
|
||||||
|
TopCenter,
|
||||||
|
TopRight,
|
||||||
|
MiddleLeft,
|
||||||
|
Center,
|
||||||
|
MiddleRight,
|
||||||
|
BottomLeft,
|
||||||
|
BottomCenter,
|
||||||
|
BottomRight
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum StackMode
|
||||||
|
{
|
||||||
|
Queue,
|
||||||
|
Push,
|
||||||
|
Replace
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OverflowMode
|
||||||
|
{
|
||||||
|
Truncate,
|
||||||
|
VerticalScroll,
|
||||||
|
Split
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PopupAnimation
|
||||||
|
{
|
||||||
|
Fade,
|
||||||
|
Slide,
|
||||||
|
Zoom
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AppState
|
||||||
|
{
|
||||||
|
public GlobalSettings Settings { get; set; } = new();
|
||||||
|
public ObservableCollection<Channel> Channels { get; set; } = [];
|
||||||
|
public ObservableCollection<HistoryItem> History { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GlobalSettings
|
||||||
|
{
|
||||||
|
public bool StartWithWindows { get; set; }
|
||||||
|
public bool DndEnabled { get; set; }
|
||||||
|
public bool CircuitBreakerOpen { get; set; }
|
||||||
|
public int MaxMessagesPerSecond { get; set; } = 8;
|
||||||
|
public int RetainDays { get; set; } = 30;
|
||||||
|
public int RetainCount { get; set; } = 1000;
|
||||||
|
public int LocalPort { get; set; } = 19845;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Channel
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public string Name { get; set; } = "default";
|
||||||
|
public NotificationProfile Profile { get; set; } = NotificationProfile.CreateDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NotificationProfile
|
||||||
|
{
|
||||||
|
public int ScreenIndex { get; set; }
|
||||||
|
public ScreenAnchor Anchor { get; set; } = ScreenAnchor.TopRight;
|
||||||
|
public double MarginX { get; set; } = 24;
|
||||||
|
public double MarginY { get; set; } = 24;
|
||||||
|
public double Width { get; set; } = 360;
|
||||||
|
public double MaxHeight { get; set; } = 220;
|
||||||
|
public double Padding { get; set; } = 18;
|
||||||
|
public string FontFamily { get; set; } = "Microsoft YaHei UI";
|
||||||
|
public double TitleFontSize { get; set; } = 16;
|
||||||
|
public double BodyFontSize { get; set; } = 13;
|
||||||
|
public string TextColor { get; set; } = "#F8FAFC";
|
||||||
|
public string BackgroundColor { get; set; } = "#242934";
|
||||||
|
public double BackgroundOpacity { get; set; } = 0.94;
|
||||||
|
public string BorderColor { get; set; } = "#5B6B84";
|
||||||
|
public double BorderOpacity { get; set; } = 0.65;
|
||||||
|
public double OverallOpacity { get; set; } = 1;
|
||||||
|
public double LifetimeSeconds { get; set; } = 4.5;
|
||||||
|
public PopupAnimation EnterAnimation { get; set; } = PopupAnimation.Slide;
|
||||||
|
public PopupAnimation ExitAnimation { get; set; } = PopupAnimation.Fade;
|
||||||
|
public StackMode StackMode { get; set; } = StackMode.Queue;
|
||||||
|
public OverflowMode OverflowMode { get; set; } = OverflowMode.Truncate;
|
||||||
|
public double SplitIntervalSeconds { get; set; } = 0.6;
|
||||||
|
[JsonPropertyName("MarqueeHoldSeconds")]
|
||||||
|
public double VerticalScrollHoldSeconds { get; set; } = 1;
|
||||||
|
public double VerticalScrollSpeed { get; set; } = 30;
|
||||||
|
|
||||||
|
public static NotificationProfile CreateDefault() => new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HistoryItem
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public DateTime ReceivedAt { get; set; } = DateTime.Now;
|
||||||
|
public string Channel { get; set; } = "";
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string Body { get; set; } = "";
|
||||||
|
public NotificationStatus Status { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IncomingMessage
|
||||||
|
{
|
||||||
|
public string Channel { get; set; } = "";
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string Body { get; set; } = "";
|
||||||
|
}
|
||||||
17
OmniNotify.csproj
Normal file
17
OmniNotify.csproj
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<ApplicationIcon>app.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="app.ico" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
67
PRD.md
Normal file
67
PRD.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 📦 Omni-Notify 产品需求与交互说明书 (PRD)
|
||||||
|
|
||||||
|
## 一、 产品定位与全局原则
|
||||||
|
* **产品名称:** Omni-Notify
|
||||||
|
* **核心功能:** 接收本机其他应用的格式化信息,进行高度自定义的视觉弹窗展示。
|
||||||
|
* **适用系统:** windows10以上系统。
|
||||||
|
* **全局核心原则:**
|
||||||
|
1. **绝对零交互:** 弹窗纯视觉展示,绝对不抢夺焦点,不可点击,不可交互。
|
||||||
|
2. **扁平化频道制:** 弹窗样式与“频道(Channel)”一对一强绑定。
|
||||||
|
3. **性能优先:** 具备完善的日志清理与全局熔断保护机制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、 核心模块拆解与业务逻辑
|
||||||
|
|
||||||
|
### 模块 1:全局设置与托盘 (Tray & Global Settings)
|
||||||
|
* **系统托盘入口:**
|
||||||
|
* 左键:呼出主控面板。
|
||||||
|
* 右键菜单:打开主控面板、全局设置、免打扰(DND)开关、**解除熔断(动态显示,仅熔断时高亮)**、退出软件。
|
||||||
|
* **全局设置面板:**
|
||||||
|
* **基础设置:** 开机自启。
|
||||||
|
* **历史清理策略:** 设定阈值(如保留最近 X 天,或最多保留 X 条),超限自动静默清理。
|
||||||
|
* **免打扰模式 (DND):** 开启后,所有接收到的消息不触发弹窗,但正常静默写入“历史面板”。
|
||||||
|
* **防刷屏熔断机制(Rate Limit):**
|
||||||
|
* **设定:** 全局阈值(如“每秒最多处理 X 条信息”)。
|
||||||
|
* **触发表现:** 超限瞬间,系统直接抛弃后续所有新消息(不弹窗、不进历史面板)。
|
||||||
|
* **熔断通知:** 触发瞬间,系统生成一条不受熔断限制的“系统级弹窗”(固定文案:检测到大量垃圾消息,已开启熔断保护)。
|
||||||
|
* **解除方式:** 必须由用户手动在“托盘右键菜单”或“全局设置面板”点击【解除熔断】方可恢复。
|
||||||
|
|
||||||
|
### 模块 2:频道管理 (Channel Management)
|
||||||
|
* **信息接收匹配逻辑:**
|
||||||
|
* 采用**“主动创建,严格匹配”**模式。
|
||||||
|
* 用户需在主控面板新建频道,自定义“频道名”(唯一标识符)。其他应用发来的信息必须包含该频道名。
|
||||||
|
* **异常处理(严格模式):** 收到未注册/拼写错误的频道信息,直接丢弃不弹窗,并在历史面板记录一条报错日志(标记为:非法来源/未匹配频道)。
|
||||||
|
* **主控面板列表:** 展示所有已建频道,提供新建、删除、重命名、编辑弹窗样式等基础管理功能。
|
||||||
|
|
||||||
|
### 模块 3:弹窗样式设计器 (Profile Designer) - 核心
|
||||||
|
本模块为频道提供独立的样式配置表单。无需“所见即所得”预览,依赖外部发送测试信息验证。
|
||||||
|
|
||||||
|
**3.1 弹窗内容构成**
|
||||||
|
* 仅支持纯文本,严格划分为:**标题(Title)** 与 **正文(Body)** 两个区块。不支持图标等非文本元素。
|
||||||
|
|
||||||
|
**3.2 UI 配置参数分组**
|
||||||
|
1. **多屏与位置:** 指定显示器(主屏/副屏1/副屏2...)、屏幕九宫格位置(左上、上中、右下等)、屏幕边距(位置在边中央时为一个值,在角落时为两个值,在屏幕正中央时无需值)。
|
||||||
|
2. **尺寸与排版:** 宽度、最大高度。
|
||||||
|
3. **视觉样式:** 内边距、字体类型、字号(标题/正文可分设)、文字颜色、背景颜色与透明度、边框样式/颜色/透明度、整体透明度。
|
||||||
|
4. **时间与动画:** 存在时间(X秒)、出现动画(淡入/滑入/放大等)、消失动画。
|
||||||
|
|
||||||
|
**3.3 高级行为规则:多消息堆叠模式(3选1)**
|
||||||
|
1. **队列排队:** 同一位置只显 1 条。新消息入队,上一条消失后,下一条按原动画出现。
|
||||||
|
2. **推挤平移:** 新消息出现将老消息推挤开。**核心细节:**如果新消息先消失,老消息需重新滑回原位置。**边界规则:**无视屏幕物理边界,超限的老消息在屏幕外继续维持其存在时间与坐标逻辑,不强制销毁。
|
||||||
|
3. **直接覆盖:** 新消息直接替换当前弹窗的标题与正文,并重新开始倒计时。
|
||||||
|
|
||||||
|
**3.4 高级行为规则:溢出处理模式(3选1)**
|
||||||
|
*前提:当正文内容过多,达到设定的“最大高度”时触发。*
|
||||||
|
1. **截断:** 超出最大高度的内容隐藏,末尾显示“...”。
|
||||||
|
2. **跑马灯:** 文本在弹窗内匀速滚动。**核心细节(时间动态补偿):**
|
||||||
|
* 若设定存在时间 < 滚动显示完所需时间:滚动完毕后,停留 X 秒(用户设定)再消失。
|
||||||
|
* 若设定存在时间 > 滚动显示完所需时间:等待原有存在时间耗尽后再消失。
|
||||||
|
3. **分割信息:** 严格按“最大高度所能容纳的最大行数”为标准切断。切割后的多段信息,以一定间隔时间(用户设定)依次作为独立弹窗展示。
|
||||||
|
|
||||||
|
### 模块 4:历史信息面板 (History Panel)
|
||||||
|
* **数据展示:** 列表形态,字段包含:接收时间、频道来源、状态(成功展示 / 免打扰静默 / 非法拦截)。
|
||||||
|
* **查询检索:** 支持按时间段、按频道来源、按状态进行高级筛选;支持关键字搜索。
|
||||||
|
* **交互操作(右键菜单):**
|
||||||
|
1. **复制完整内容:** 将该条信息的 Title 和 Body 复制到剪贴板。
|
||||||
|
2. **重新显示该弹窗(复播):** 纯视觉回放,按照该频道当前的样式重新走一遍弹窗流程,**不**在历史面板生成新的接收记录。
|
||||||
278
PopupCoordinator.cs
Normal file
278
PopupCoordinator.cs
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace OmniNotify;
|
||||||
|
|
||||||
|
public sealed class PopupCoordinator
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, Queue<(Channel Channel, IncomingMessage Message)>> _queues = [];
|
||||||
|
private readonly Dictionary<string, List<PopupWindow>> _visible = [];
|
||||||
|
|
||||||
|
public void Show(Channel channel, IncomingMessage message)
|
||||||
|
{
|
||||||
|
var key = Key(channel.Profile);
|
||||||
|
if (channel.Profile.OverflowMode == OverflowMode.Split)
|
||||||
|
{
|
||||||
|
var parts = SplitBody(message.Body, message.Title, channel.Profile).ToList();
|
||||||
|
if (parts.Count > 1)
|
||||||
|
{
|
||||||
|
var splitChannel = CloneForSplit(channel);
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
Enqueue(splitChannel, new IncomingMessage { Channel = message.Channel, Title = message.Title, Body = part }, key, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Enqueue(channel, message, key, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowSystem(string title, string body)
|
||||||
|
{
|
||||||
|
var channel = new Channel
|
||||||
|
{
|
||||||
|
Name = "system",
|
||||||
|
Profile = NotificationProfile.CreateDefault()
|
||||||
|
};
|
||||||
|
channel.Profile.BackgroundColor = "#7F1D1D";
|
||||||
|
channel.Profile.BorderColor = "#FCA5A5";
|
||||||
|
Show(channel, new IncomingMessage { Channel = "system", Title = title, Body = body });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Enqueue(Channel channel, IncomingMessage message, string key, bool forceQueue)
|
||||||
|
{
|
||||||
|
if (!_visible.TryGetValue(key, out var windows))
|
||||||
|
{
|
||||||
|
windows = [];
|
||||||
|
_visible[key] = windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceQueue)
|
||||||
|
{
|
||||||
|
if (windows.Count == 0)
|
||||||
|
{
|
||||||
|
ShowNow(channel, message, key, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_queues.ContainsKey(key))
|
||||||
|
{
|
||||||
|
_queues[key] = new Queue<(Channel, IncomingMessage)>();
|
||||||
|
}
|
||||||
|
|
||||||
|
_queues[key].Enqueue((channel, message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (channel.Profile.StackMode)
|
||||||
|
{
|
||||||
|
case StackMode.Replace:
|
||||||
|
foreach (var popup in windows.ToList())
|
||||||
|
{
|
||||||
|
popup.CloseImmediately();
|
||||||
|
}
|
||||||
|
windows.Clear();
|
||||||
|
ShowNow(channel, message, key, 0);
|
||||||
|
break;
|
||||||
|
case StackMode.Push:
|
||||||
|
ShowNow(channel, message, key, windows.Count);
|
||||||
|
Relayout(key);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (windows.Count == 0)
|
||||||
|
{
|
||||||
|
ShowNow(channel, message, key, 0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!_queues.ContainsKey(key))
|
||||||
|
{
|
||||||
|
_queues[key] = new Queue<(Channel, IncomingMessage)>();
|
||||||
|
}
|
||||||
|
_queues[key].Enqueue((channel, message));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowNow(Channel channel, IncomingMessage message, string key, int offsetIndex)
|
||||||
|
{
|
||||||
|
var popup = new PopupWindow(channel.Profile, message, offsetIndex);
|
||||||
|
_visible[key].Add(popup);
|
||||||
|
popup.Closed += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_visible.TryGetValue(key, out var windows))
|
||||||
|
{
|
||||||
|
windows.Remove(popup);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.Profile.StackMode == StackMode.Queue && _queues.TryGetValue(key, out var queue) && queue.Count > 0)
|
||||||
|
{
|
||||||
|
var next = queue.Dequeue();
|
||||||
|
ShowNow(next.Channel, next.Message, key, 0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Relayout(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
popup.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Relayout(string key)
|
||||||
|
{
|
||||||
|
if (!_visible.TryGetValue(key, out var windows))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < windows.Count; i++)
|
||||||
|
{
|
||||||
|
windows[i].MoveToSlot(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Key(NotificationProfile profile) => $"{profile.ScreenIndex}:{profile.Anchor}";
|
||||||
|
|
||||||
|
private static IEnumerable<string> SplitBody(string body, string title, NotificationProfile profile)
|
||||||
|
{
|
||||||
|
var contentWidth = PopupWindow.GetBodyContentWidth(profile);
|
||||||
|
var titleHeight = MeasureTextHeight(
|
||||||
|
title,
|
||||||
|
profile,
|
||||||
|
contentWidth,
|
||||||
|
profile.TitleFontSize,
|
||||||
|
null,
|
||||||
|
FontWeights.SemiBold);
|
||||||
|
var bodyViewportHeight = PopupWindow.GetBodyViewportHeight(profile, titleHeight);
|
||||||
|
var textElements = TextElements(body.Replace("\r\n", "\n")).ToList();
|
||||||
|
|
||||||
|
for (var start = 0; start < textElements.Count;)
|
||||||
|
{
|
||||||
|
var remaining = textElements.Count - start;
|
||||||
|
var low = 1;
|
||||||
|
var high = remaining;
|
||||||
|
var best = 1;
|
||||||
|
|
||||||
|
while (low <= high)
|
||||||
|
{
|
||||||
|
var middle = low + (high - low) / 2;
|
||||||
|
var candidate = string.Concat(textElements.Skip(start).Take(middle));
|
||||||
|
if (FitsBodyViewport(candidate, profile, contentWidth, bodyViewportHeight))
|
||||||
|
{
|
||||||
|
best = middle;
|
||||||
|
low = middle + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
high = middle - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = string.Concat(textElements.Skip(start).Take(best)).Trim('\r', '\n');
|
||||||
|
if (!string.IsNullOrEmpty(page))
|
||||||
|
{
|
||||||
|
yield return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
start += best;
|
||||||
|
while (start < textElements.Count && IsLineBreak(textElements[start]))
|
||||||
|
{
|
||||||
|
start++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FitsBodyViewport(string text, NotificationProfile profile, double width, double bodyViewportHeight)
|
||||||
|
{
|
||||||
|
var measuredHeight = MeasureTextHeight(
|
||||||
|
text,
|
||||||
|
profile,
|
||||||
|
width,
|
||||||
|
profile.BodyFontSize,
|
||||||
|
PopupWindow.GetBodyLineHeight(profile),
|
||||||
|
FontWeights.Normal);
|
||||||
|
return measuredHeight <= Math.Floor(bodyViewportHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double MeasureTextHeight(
|
||||||
|
string text,
|
||||||
|
NotificationProfile profile,
|
||||||
|
double width,
|
||||||
|
double fontSize,
|
||||||
|
double? lineHeight,
|
||||||
|
FontWeight fontWeight)
|
||||||
|
{
|
||||||
|
var textBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
FontFamily = new System.Windows.Media.FontFamily(profile.FontFamily),
|
||||||
|
FontSize = fontSize,
|
||||||
|
FontWeight = fontWeight
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lineHeight is not null)
|
||||||
|
{
|
||||||
|
textBlock.LineStackingStrategy = LineStackingStrategy.BlockLineHeight;
|
||||||
|
textBlock.LineHeight = lineHeight.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
textBlock.Measure(new System.Windows.Size(width, double.PositiveInfinity));
|
||||||
|
return Math.Ceiling(textBlock.DesiredSize.Height + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> TextElements(string text)
|
||||||
|
{
|
||||||
|
var enumerator = StringInfo.GetTextElementEnumerator(text);
|
||||||
|
while (enumerator.MoveNext())
|
||||||
|
{
|
||||||
|
yield return enumerator.GetTextElement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLineBreak(string textElement)
|
||||||
|
{
|
||||||
|
return textElement is "\r" or "\n" or "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Channel CloneForSplit(Channel channel)
|
||||||
|
{
|
||||||
|
var profile = channel.Profile;
|
||||||
|
return new Channel
|
||||||
|
{
|
||||||
|
Id = channel.Id,
|
||||||
|
Name = channel.Name,
|
||||||
|
Profile = new NotificationProfile
|
||||||
|
{
|
||||||
|
ScreenIndex = profile.ScreenIndex,
|
||||||
|
Anchor = profile.Anchor,
|
||||||
|
MarginX = profile.MarginX,
|
||||||
|
MarginY = profile.MarginY,
|
||||||
|
Width = profile.Width,
|
||||||
|
MaxHeight = profile.MaxHeight,
|
||||||
|
Padding = profile.Padding,
|
||||||
|
FontFamily = profile.FontFamily,
|
||||||
|
TitleFontSize = profile.TitleFontSize,
|
||||||
|
BodyFontSize = profile.BodyFontSize,
|
||||||
|
TextColor = profile.TextColor,
|
||||||
|
BackgroundColor = profile.BackgroundColor,
|
||||||
|
BackgroundOpacity = profile.BackgroundOpacity,
|
||||||
|
BorderColor = profile.BorderColor,
|
||||||
|
BorderOpacity = profile.BorderOpacity,
|
||||||
|
OverallOpacity = profile.OverallOpacity,
|
||||||
|
LifetimeSeconds = Math.Max(0.5, profile.SplitIntervalSeconds),
|
||||||
|
EnterAnimation = profile.EnterAnimation,
|
||||||
|
ExitAnimation = profile.ExitAnimation,
|
||||||
|
StackMode = StackMode.Queue,
|
||||||
|
OverflowMode = OverflowMode.Split,
|
||||||
|
SplitIntervalSeconds = profile.SplitIntervalSeconds,
|
||||||
|
VerticalScrollHoldSeconds = profile.VerticalScrollHoldSeconds,
|
||||||
|
VerticalScrollSpeed = profile.VerticalScrollSpeed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
30
PopupWindow.xaml
Normal file
30
PopupWindow.xaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<Window x:Class="OmniNotify.PopupWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
WindowStyle="None"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
Background="Transparent"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
ShowActivated="False"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
Topmost="True"
|
||||||
|
SizeToContent="Height">
|
||||||
|
<Border x:Name="Shell"
|
||||||
|
CornerRadius="8"
|
||||||
|
BorderThickness="1">
|
||||||
|
<StackPanel x:Name="ContentPanel">
|
||||||
|
<TextBlock x:Name="TitleText"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<Border x:Name="BodyViewport"
|
||||||
|
Margin="0,8,0,0"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Canvas x:Name="BodyCanvas"
|
||||||
|
ClipToBounds="False">
|
||||||
|
<TextBlock x:Name="BodyText"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Canvas>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
312
PopupWindow.xaml.cs
Normal file
312
PopupWindow.xaml.cs
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace OmniNotify;
|
||||||
|
|
||||||
|
public partial class PopupWindow : Window
|
||||||
|
{
|
||||||
|
private const double ShellBorderThickness = 1;
|
||||||
|
private const double BodyTopMargin = 8;
|
||||||
|
private const int GwlExStyle = -20;
|
||||||
|
private const int WsExTransparent = 0x20;
|
||||||
|
private const int WsExNoActivate = 0x08000000;
|
||||||
|
private const int WsExToolWindow = 0x00000080;
|
||||||
|
private readonly NotificationProfile _profile;
|
||||||
|
private readonly DispatcherTimer _timer = new();
|
||||||
|
private double _bodyViewportHeight;
|
||||||
|
private int _slot;
|
||||||
|
|
||||||
|
public PopupWindow(NotificationProfile profile, IncomingMessage message, int slot)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_profile = profile;
|
||||||
|
_slot = slot;
|
||||||
|
Width = profile.Width;
|
||||||
|
MaxHeight = profile.MaxHeight;
|
||||||
|
Opacity = profile.OverallOpacity;
|
||||||
|
TitleText.Text = message.Title;
|
||||||
|
BodyText.Text = message.Body;
|
||||||
|
ApplyProfile();
|
||||||
|
_timer.Interval = TimeSpan.FromSeconds(Math.Max(0.5, GetInitialLifetimeSeconds(profile)));
|
||||||
|
Loaded += (_, _) =>
|
||||||
|
{
|
||||||
|
ApplyClickThrough();
|
||||||
|
LayoutBodyText();
|
||||||
|
StartOverflowBehavior();
|
||||||
|
MoveToSlot(_slot, false);
|
||||||
|
AnimateIn();
|
||||||
|
};
|
||||||
|
_timer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
AnimateOut();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MoveToSlot(int slot, bool animated = true)
|
||||||
|
{
|
||||||
|
_slot = slot;
|
||||||
|
var (left, top) = CalculatePosition(slot);
|
||||||
|
if (!animated)
|
||||||
|
{
|
||||||
|
Left = left;
|
||||||
|
Top = top;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BeginAnimation(LeftProperty, new DoubleAnimation(left, TimeSpan.FromMilliseconds(180)) { EasingFunction = new CubicEase() });
|
||||||
|
BeginAnimation(TopProperty, new DoubleAnimation(top, TimeSpan.FromMilliseconds(180)) { EasingFunction = new CubicEase() });
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CloseImmediately()
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyProfile()
|
||||||
|
{
|
||||||
|
Shell.BorderThickness = new Thickness(ShellBorderThickness);
|
||||||
|
Shell.Padding = new Thickness(_profile.Padding);
|
||||||
|
Shell.Background = Brush(_profile.BackgroundColor, _profile.BackgroundOpacity);
|
||||||
|
Shell.BorderBrush = Brush(_profile.BorderColor, _profile.BorderOpacity);
|
||||||
|
Shell.ClipToBounds = true;
|
||||||
|
ContentPanel.ClipToBounds = true;
|
||||||
|
ContentPanel.MaxHeight = Math.Max(80, GetPanelMaxHeight(_profile));
|
||||||
|
TitleText.Foreground = Brush(_profile.TextColor, 1);
|
||||||
|
BodyText.Foreground = Brush(_profile.TextColor, 0.9);
|
||||||
|
TitleText.FontFamily = new System.Windows.Media.FontFamily(_profile.FontFamily);
|
||||||
|
BodyText.FontFamily = new System.Windows.Media.FontFamily(_profile.FontFamily);
|
||||||
|
TitleText.FontSize = _profile.TitleFontSize;
|
||||||
|
BodyText.FontSize = _profile.BodyFontSize;
|
||||||
|
BodyText.LineStackingStrategy = LineStackingStrategy.BlockLineHeight;
|
||||||
|
BodyText.LineHeight = GetBodyLineHeight(_profile);
|
||||||
|
_bodyViewportHeight = GetBodyViewportHeight(_profile);
|
||||||
|
BodyText.RenderTransform = null;
|
||||||
|
BodyCanvas.RenderTransform = null;
|
||||||
|
|
||||||
|
switch (_profile.OverflowMode)
|
||||||
|
{
|
||||||
|
case OverflowMode.VerticalScroll:
|
||||||
|
BodyViewport.Height = _bodyViewportHeight;
|
||||||
|
BodyViewport.MaxHeight = _bodyViewportHeight;
|
||||||
|
BodyText.TextWrapping = TextWrapping.Wrap;
|
||||||
|
BodyText.TextTrimming = TextTrimming.None;
|
||||||
|
break;
|
||||||
|
case OverflowMode.Split:
|
||||||
|
BodyViewport.Height = _bodyViewportHeight;
|
||||||
|
BodyViewport.MaxHeight = _bodyViewportHeight;
|
||||||
|
BodyText.TextWrapping = TextWrapping.Wrap;
|
||||||
|
BodyText.TextTrimming = TextTrimming.None;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
BodyViewport.Height = Math.Max(_profile.BodyFontSize * 1.6, _profile.BodyFontSize + 4);
|
||||||
|
BodyViewport.MaxHeight = BodyViewport.Height;
|
||||||
|
BodyText.TextWrapping = TextWrapping.NoWrap;
|
||||||
|
BodyText.TextTrimming = TextTrimming.CharacterEllipsis;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartOverflowBehavior()
|
||||||
|
{
|
||||||
|
if (_profile.OverflowMode != OverflowMode.VerticalScroll)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var overflow = BodyText.DesiredSize.Height - BodyViewport.ActualHeight;
|
||||||
|
var hold = TimeSpan.FromSeconds(Math.Max(0, _profile.VerticalScrollHoldSeconds));
|
||||||
|
if (overflow <= 1)
|
||||||
|
{
|
||||||
|
_timer.Interval = ClampTimerInterval(hold + hold);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var transform = new TranslateTransform();
|
||||||
|
BodyCanvas.RenderTransform = transform;
|
||||||
|
var speed = Math.Max(1, _profile.VerticalScrollSpeed);
|
||||||
|
var duration = TimeSpan.FromSeconds(overflow / speed);
|
||||||
|
_timer.Interval = ClampTimerInterval(hold + duration + hold);
|
||||||
|
|
||||||
|
var animation = new DoubleAnimation
|
||||||
|
{
|
||||||
|
From = 0,
|
||||||
|
To = -overflow,
|
||||||
|
BeginTime = hold,
|
||||||
|
Duration = new Duration(duration),
|
||||||
|
FillBehavior = FillBehavior.HoldEnd
|
||||||
|
};
|
||||||
|
transform.BeginAnimation(TranslateTransform.YProperty, animation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LayoutBodyText()
|
||||||
|
{
|
||||||
|
var viewportWidth = Math.Max(1, BodyViewport.ActualWidth);
|
||||||
|
if (viewportWidth <= 1)
|
||||||
|
{
|
||||||
|
viewportWidth = GetBodyContentWidth(_profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
_bodyViewportHeight = GetBodyViewportHeight(_profile, TitleText.ActualHeight);
|
||||||
|
BodyText.Width = viewportWidth;
|
||||||
|
BodyCanvas.Width = viewportWidth;
|
||||||
|
BodyText.Measure(new System.Windows.Size(viewportWidth, double.PositiveInfinity));
|
||||||
|
|
||||||
|
var textHeight = Math.Ceiling(BodyText.DesiredSize.Height + 2);
|
||||||
|
if (_profile.OverflowMode == OverflowMode.Split)
|
||||||
|
{
|
||||||
|
BodyViewport.Height = Math.Min(_bodyViewportHeight, textHeight);
|
||||||
|
BodyViewport.MaxHeight = BodyViewport.Height;
|
||||||
|
BodyCanvas.Height = textHeight;
|
||||||
|
}
|
||||||
|
else if (_profile.OverflowMode == OverflowMode.VerticalScroll)
|
||||||
|
{
|
||||||
|
BodyViewport.Height = _bodyViewportHeight;
|
||||||
|
BodyViewport.MaxHeight = _bodyViewportHeight;
|
||||||
|
BodyCanvas.Height = textHeight;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
BodyCanvas.Height = Math.Max(BodyViewport.ActualHeight, textHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
BodyText.Arrange(new Rect(0, 0, viewportWidth, textHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AnimateIn()
|
||||||
|
{
|
||||||
|
switch (_profile.EnterAnimation)
|
||||||
|
{
|
||||||
|
case PopupAnimation.Zoom:
|
||||||
|
RenderTransformOrigin = new System.Windows.Point(0.5, 0.5);
|
||||||
|
RenderTransform = new ScaleTransform(0.92, 0.92);
|
||||||
|
RenderTransform.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1, TimeSpan.FromMilliseconds(180)));
|
||||||
|
RenderTransform.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1, TimeSpan.FromMilliseconds(180)));
|
||||||
|
break;
|
||||||
|
case PopupAnimation.Slide:
|
||||||
|
Left += 18;
|
||||||
|
BeginAnimation(LeftProperty, new DoubleAnimation(Left - 18, TimeSpan.FromMilliseconds(180)) { EasingFunction = new CubicEase() });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
BeginAnimation(OpacityProperty, new DoubleAnimation(0, _profile.OverallOpacity, TimeSpan.FromMilliseconds(160)));
|
||||||
|
_timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AnimateOut()
|
||||||
|
{
|
||||||
|
var animation = new DoubleAnimation(0, TimeSpan.FromMilliseconds(160));
|
||||||
|
animation.Completed += (_, _) => Close();
|
||||||
|
BeginAnimation(OpacityProperty, animation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (double Left, double Top) CalculatePosition(int slot)
|
||||||
|
{
|
||||||
|
var screens = Screen.AllScreens;
|
||||||
|
var screen = GetWorkingAreaInDips(screens[Math.Clamp(_profile.ScreenIndex, 0, screens.Length - 1)]);
|
||||||
|
var height = ActualHeight > 1 ? ActualHeight : _profile.MaxHeight;
|
||||||
|
var gap = 12;
|
||||||
|
var step = height + gap;
|
||||||
|
var x = _profile.Anchor switch
|
||||||
|
{
|
||||||
|
ScreenAnchor.TopLeft or ScreenAnchor.MiddleLeft or ScreenAnchor.BottomLeft => screen.Left + _profile.MarginX,
|
||||||
|
ScreenAnchor.TopCenter or ScreenAnchor.Center or ScreenAnchor.BottomCenter => screen.Left + (screen.Width - Width) / 2,
|
||||||
|
_ => screen.Right - Width - _profile.MarginX
|
||||||
|
};
|
||||||
|
var y = _profile.Anchor switch
|
||||||
|
{
|
||||||
|
ScreenAnchor.TopLeft or ScreenAnchor.TopCenter or ScreenAnchor.TopRight => screen.Top + _profile.MarginY + slot * step,
|
||||||
|
ScreenAnchor.MiddleLeft or ScreenAnchor.Center or ScreenAnchor.MiddleRight => screen.Top + (screen.Height - height) / 2 + slot * step,
|
||||||
|
_ => screen.Bottom - height - _profile.MarginY - slot * step
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.Clamp(x, screen.Left, Math.Max(screen.Left, screen.Right - Width)),
|
||||||
|
Math.Clamp(y, screen.Top, Math.Max(screen.Top, screen.Bottom - height))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rect GetWorkingAreaInDips(Screen screen)
|
||||||
|
{
|
||||||
|
var source = PresentationSource.FromVisual(this);
|
||||||
|
var transform = source?.CompositionTarget?.TransformFromDevice ?? Matrix.Identity;
|
||||||
|
var area = screen.WorkingArea;
|
||||||
|
var topLeft = transform.Transform(new System.Windows.Point(area.Left, area.Top));
|
||||||
|
var bottomRight = transform.Transform(new System.Windows.Point(area.Right, area.Bottom));
|
||||||
|
return new Rect(topLeft, bottomRight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static System.Windows.Media.Brush Brush(string color, double opacity)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var brush = (SolidColorBrush)new BrushConverter().ConvertFromString(color)!;
|
||||||
|
brush.Opacity = Math.Clamp(opacity, 0, 1);
|
||||||
|
return brush;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new SolidColorBrush(Colors.White) { Opacity = Math.Clamp(opacity, 0, 1) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double GetBodyViewportHeight(NotificationProfile profile)
|
||||||
|
{
|
||||||
|
return GetBodyViewportHeight(profile, profile.TitleFontSize * 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double GetBodyViewportHeight(NotificationProfile profile, double titleHeight)
|
||||||
|
{
|
||||||
|
return Math.Max(
|
||||||
|
profile.BodyFontSize * 1.8,
|
||||||
|
profile.MaxHeight - (ShellBorderThickness * 2) - (profile.Padding * 2) - titleHeight - BodyTopMargin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double GetBodyLineHeight(NotificationProfile profile)
|
||||||
|
{
|
||||||
|
return Math.Ceiling(profile.BodyFontSize * 1.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double GetBodyContentWidth(NotificationProfile profile)
|
||||||
|
{
|
||||||
|
return Math.Max(1, profile.Width - (ShellBorderThickness * 2) - (profile.Padding * 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double GetPanelMaxHeight(NotificationProfile profile)
|
||||||
|
{
|
||||||
|
return profile.MaxHeight - (ShellBorderThickness * 2) - (profile.Padding * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double GetInitialLifetimeSeconds(NotificationProfile profile)
|
||||||
|
{
|
||||||
|
return profile.OverflowMode == OverflowMode.VerticalScroll
|
||||||
|
? profile.VerticalScrollHoldSeconds * 2
|
||||||
|
: profile.LifetimeSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan ClampTimerInterval(TimeSpan interval)
|
||||||
|
{
|
||||||
|
return interval < TimeSpan.FromMilliseconds(500)
|
||||||
|
? TimeSpan.FromMilliseconds(500)
|
||||||
|
: interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyClickThrough()
|
||||||
|
{
|
||||||
|
var handle = new WindowInteropHelper(this).Handle;
|
||||||
|
var style = GetWindowLong(handle, GwlExStyle);
|
||||||
|
SetWindowLong(handle, GwlExStyle, style | WsExTransparent | WsExNoActivate | WsExToolWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int GetWindowLong(IntPtr hwnd, int index);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int SetWindowLong(IntPtr hwnd, int index, int value);
|
||||||
|
}
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Omni-Notify
|
||||||
|
|
||||||
|
Omni-Notify is a Windows 10+ WPF desktop app for receiving local structured messages and showing configurable, non-interactive visual popups.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- .NET 8 WPF for the desktop UI and popup windows
|
||||||
|
- Windows Forms `NotifyIcon` for the system tray entry
|
||||||
|
- Built-in `HttpListener` for local message intake
|
||||||
|
- JSON persistence under `%LOCALAPPDATA%\OmniNotify\state.json`
|
||||||
|
|
||||||
|
## Local Message API
|
||||||
|
|
||||||
|
When the app is running, it listens by default on:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:19845/notify
|
||||||
|
```
|
||||||
|
|
||||||
|
Send a POST request with UTF-8 JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel": "default",
|
||||||
|
"title": "Build finished",
|
||||||
|
"body": "The nightly job completed successfully."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Channels must be created in the control panel first. Unknown channels are blocked and recorded in history as `IllegalChannel`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build
|
||||||
|
```
|
||||||
298
Services.cs
Normal file
298
Services.cs
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace OmniNotify;
|
||||||
|
|
||||||
|
public sealed class AppStore
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||||
|
private readonly string _statePath;
|
||||||
|
|
||||||
|
public AppStore()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OmniNotify");
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
_statePath = Path.Combine(root, "state.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppState Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_statePath))
|
||||||
|
{
|
||||||
|
var state = new AppState();
|
||||||
|
state.Channels.Add(new Channel { Name = "default" });
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<AppState>(File.ReadAllText(_statePath), JsonOptions) ?? new AppState();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new AppState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(AppState state)
|
||||||
|
{
|
||||||
|
CleanupHistory(state);
|
||||||
|
File.WriteAllText(_statePath, JsonSerializer.Serialize(state, JsonOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CleanupHistory(AppState state)
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.Now.AddDays(-Math.Max(1, state.Settings.RetainDays));
|
||||||
|
var survivors = state.History.Where(item => item.ReceivedAt >= cutoff)
|
||||||
|
.OrderByDescending(item => item.ReceivedAt)
|
||||||
|
.Take(Math.Max(1, state.Settings.RetainCount))
|
||||||
|
.OrderBy(item => item.ReceivedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
state.History.Clear();
|
||||||
|
foreach (var item in survivors)
|
||||||
|
{
|
||||||
|
state.History.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NotificationRouter
|
||||||
|
{
|
||||||
|
private readonly AppState _state;
|
||||||
|
private readonly AppStore _store;
|
||||||
|
private readonly PopupCoordinator _popupCoordinator;
|
||||||
|
private readonly Queue<DateTime> _rateWindow = new();
|
||||||
|
|
||||||
|
public event Action? StateChanged;
|
||||||
|
|
||||||
|
public NotificationRouter(AppState state, AppStore store, PopupCoordinator popupCoordinator)
|
||||||
|
{
|
||||||
|
_state = state;
|
||||||
|
_store = store;
|
||||||
|
_popupCoordinator = popupCoordinator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Receive(IncomingMessage message, bool recordHistory = true)
|
||||||
|
{
|
||||||
|
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
if (_state.Settings.CircuitBreakerOpen)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsRateLimited())
|
||||||
|
{
|
||||||
|
_state.Settings.CircuitBreakerOpen = true;
|
||||||
|
_popupCoordinator.ShowSystem("熔断保护", "检测到大量消息,已开启熔断保护。");
|
||||||
|
SaveAndNotify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var channel = _state.Channels.FirstOrDefault(item =>
|
||||||
|
string.Equals(item.Name, message.Channel, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
AddHistory(message, NotificationStatus.IllegalChannel);
|
||||||
|
SaveAndNotify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_state.Settings.DndEnabled)
|
||||||
|
{
|
||||||
|
AddHistory(message, NotificationStatus.DndMuted);
|
||||||
|
SaveAndNotify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordHistory)
|
||||||
|
{
|
||||||
|
AddHistory(message, NotificationStatus.Displayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
_popupCoordinator.Show(channel, message);
|
||||||
|
SaveAndNotify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Replay(HistoryItem item)
|
||||||
|
{
|
||||||
|
var channel = _state.Channels.FirstOrDefault(candidate =>
|
||||||
|
string.Equals(candidate.Name, item.Channel, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
if (channel is not null)
|
||||||
|
{
|
||||||
|
_popupCoordinator.Show(channel, new IncomingMessage { Channel = item.Channel, Title = item.Title, Body = item.Body });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetCircuitBreaker()
|
||||||
|
{
|
||||||
|
_state.Settings.CircuitBreakerOpen = false;
|
||||||
|
_rateWindow.Clear();
|
||||||
|
SaveAndNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsRateLimited()
|
||||||
|
{
|
||||||
|
var now = DateTime.Now;
|
||||||
|
while (_rateWindow.Count > 0 && (now - _rateWindow.Peek()).TotalSeconds > 1)
|
||||||
|
{
|
||||||
|
_rateWindow.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
_rateWindow.Enqueue(now);
|
||||||
|
return _rateWindow.Count > Math.Max(1, _state.Settings.MaxMessagesPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddHistory(IncomingMessage message, NotificationStatus status)
|
||||||
|
{
|
||||||
|
_state.History.Add(new HistoryItem
|
||||||
|
{
|
||||||
|
Channel = message.Channel,
|
||||||
|
Title = message.Title,
|
||||||
|
Body = message.Body,
|
||||||
|
Status = status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveAndNotify()
|
||||||
|
{
|
||||||
|
_store.Save(_state);
|
||||||
|
StateChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LocalHttpServer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly NotificationRouter _router;
|
||||||
|
private HttpListener? _listener;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
public LocalHttpServer(NotificationRouter router)
|
||||||
|
{
|
||||||
|
_router = router;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Url { get; private set; }
|
||||||
|
public string? LastError { get; private set; }
|
||||||
|
|
||||||
|
public void Start(int port)
|
||||||
|
{
|
||||||
|
Url = $"http://127.0.0.1:{port}/";
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_listener = new HttpListener();
|
||||||
|
_listener.Prefixes.Add(Url);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listener.Start();
|
||||||
|
_ = ListenAsync(_cts.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LastError = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ListenAsync(CancellationToken token)
|
||||||
|
{
|
||||||
|
while (!token.IsCancellationRequested && _listener?.IsListening == true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = await _listener.GetContextAsync();
|
||||||
|
_ = Task.Run(() => HandleAsync(context), token);
|
||||||
|
}
|
||||||
|
catch when (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LastError = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleAsync(HttpListenerContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (context.Request.HttpMethod == "GET")
|
||||||
|
{
|
||||||
|
await WriteAsync(context, 200, "Omni-Notify is listening. POST /notify with JSON: {\"channel\":\"default\",\"title\":\"Hi\",\"body\":\"Text\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.Request.HttpMethod != "POST" || context.Request.Url?.AbsolutePath != "/notify")
|
||||||
|
{
|
||||||
|
await WriteAsync(context, 404, "Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
|
||||||
|
var body = await reader.ReadToEndAsync();
|
||||||
|
var message = JsonSerializer.Deserialize<IncomingMessage>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
|
||||||
|
if (message is null || string.IsNullOrWhiteSpace(message.Channel))
|
||||||
|
{
|
||||||
|
await WriteAsync(context, 400, "Invalid message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_router.Receive(message);
|
||||||
|
await WriteAsync(context, 202, "Accepted");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await WriteAsync(context, 500, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteAsync(HttpListenerContext context, int code, string text)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(text);
|
||||||
|
context.Response.StatusCode = code;
|
||||||
|
context.Response.ContentType = "text/plain; charset=utf-8";
|
||||||
|
context.Response.ContentLength64 = bytes.Length;
|
||||||
|
await context.Response.OutputStream.WriteAsync(bytes);
|
||||||
|
context.Response.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
_listener?.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StartupManager
|
||||||
|
{
|
||||||
|
private const string AppName = "OmniNotify";
|
||||||
|
|
||||||
|
public static void Apply(bool enabled)
|
||||||
|
{
|
||||||
|
using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run", true);
|
||||||
|
if (key is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
key.SetValue(AppName, Process.GetCurrentProcess().MainModule?.FileName ?? "");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
key.DeleteValue(AppName, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user