feat(app): 初始化本地通知桌面应用

搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。
实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。
补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。

Initial-Commit: true
This commit is contained in:
2026-05-19 01:32:41 +08:00
commit c353845fad
15 changed files with 1909 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
bin/
obj/
.vs/
*.user
*.suo

7
App.xaml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}
}

BIN
app.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB