mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
6 Commits
v0.8.1.4
...
e1d5a0c6de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1d5a0c6de | ||
|
|
5fa2031ad6 | ||
|
|
0662565dca | ||
|
|
12a2f6729b | ||
|
|
5d2449fa8f | ||
|
|
00339f0ed0 |
165
LanMountainDesktop.PluginSdk/LICENSE
Normal file
165
LanMountainDesktop.PluginSdk/LICENSE
Normal file
@@ -0,0 +1,165 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version of
|
||||
the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
@@ -13,6 +13,8 @@
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
165
LanMountainDesktop.Shared.Contracts/LICENSE
Normal file
165
LanMountainDesktop.Shared.Contracts/LICENSE
Normal file
@@ -0,0 +1,165 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version of
|
||||
the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
@@ -13,6 +13,8 @@
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
|
||||
@@ -44,4 +44,6 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
|
||||
public const string DesktopFileManager = "DesktopFileManager";
|
||||
public const string DesktopNotificationBox = "DesktopNotificationBox";
|
||||
}
|
||||
|
||||
@@ -400,6 +400,26 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopFileManager,
|
||||
"文件管理",
|
||||
"Folder",
|
||||
"File",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
"消息盒子",
|
||||
"Inbox",
|
||||
"Info",
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free)
|
||||
};
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.22.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
|
||||
|
||||
@@ -1075,7 +1075,9 @@
|
||||
"zhijiaohub.settings.source": "Image Source",
|
||||
"zhijiaohub.settings.classisland": "ClassIsland Gallery",
|
||||
"zhijiaohub.settings.sectl": "SECTL Gallery",
|
||||
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community.",
|
||||
"zhijiaohub.settings.rinlit": "Rin's Gallery",
|
||||
"zhijiaohub.settings.jiangtokoto": "Jiangtokoto Memes",
|
||||
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community, Rin's Gallery contains content from Rin's community, Jiangtokoto Memes contains rich meme resources.",
|
||||
"zhijiaohub.settings.mirror_source": "Mirror Acceleration",
|
||||
"zhijiaohub.settings.mirror_direct": "Direct (GitHub)",
|
||||
"zhijiaohub.settings.mirror_ghproxy": "Mirror Acceleration (Recommended)",
|
||||
@@ -1085,5 +1087,23 @@
|
||||
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
|
||||
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
|
||||
"zhijiaohub.settings.about": "About",
|
||||
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally."
|
||||
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally.",
|
||||
"power.menu": "Power",
|
||||
"power.title": "Power",
|
||||
"power.back": "Back",
|
||||
"power.shutdown": "Shutdown",
|
||||
"power.restart": "Restart",
|
||||
"power.logout": "Log Out",
|
||||
"power.sleep": "Sleep",
|
||||
"power.lock_screen": "Lock Screen",
|
||||
"power.shutdown_confirm_title": "Shutdown Confirmation",
|
||||
"power.shutdown_confirm_message": "Are you sure you want to shut down this computer? Unsaved data may be lost.",
|
||||
"power.restart_confirm_title": "Restart Confirmation",
|
||||
"power.restart_confirm_message": "Are you sure you want to restart this computer? Unsaved data may be lost.",
|
||||
"power.logout_confirm_title": "Log Out Confirmation",
|
||||
"power.logout_confirm_message": "Are you sure you want to log out?",
|
||||
"power.sleep_confirm_title": "Sleep Confirmation",
|
||||
"power.sleep_confirm_message": "Are you sure you want to put the computer to sleep?",
|
||||
"power.confirm_yes": "Yes",
|
||||
"power.confirm_cancel": "Cancel"
|
||||
}
|
||||
|
||||
@@ -1069,7 +1069,9 @@
|
||||
"zhijiaohub.settings.source": "图片源",
|
||||
"zhijiaohub.settings.classisland": "ClassIsland 图库",
|
||||
"zhijiaohub.settings.sectl": "SECTL 图库",
|
||||
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。",
|
||||
"zhijiaohub.settings.rinlit": "Rin's 图库",
|
||||
"zhijiaohub.settings.jiangtokoto": "Jiangtokoto 表情包",
|
||||
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容,Jiangtokoto 表情包包含丰富的表情包资源。",
|
||||
"zhijiaohub.settings.mirror_source": "镜像加速",
|
||||
"zhijiaohub.settings.mirror_direct": "直连(GitHub)",
|
||||
"zhijiaohub.settings.mirror_ghproxy": "镜像加速(推荐)",
|
||||
@@ -1079,5 +1081,23 @@
|
||||
"zhijiaohub.settings.auto_refresh_desc": "定期自动刷新图片列表。",
|
||||
"zhijiaohub.settings.interval": "刷新间隔(分钟)",
|
||||
"zhijiaohub.settings.about": "关于",
|
||||
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。"
|
||||
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。",
|
||||
"power.menu": "电源",
|
||||
"power.title": "电源",
|
||||
"power.back": "返回",
|
||||
"power.shutdown": "关机",
|
||||
"power.restart": "重启",
|
||||
"power.logout": "注销",
|
||||
"power.sleep": "睡眠",
|
||||
"power.lock_screen": "锁定屏幕",
|
||||
"power.shutdown_confirm_title": "关机确认",
|
||||
"power.shutdown_confirm_message": "确定要关闭计算机吗?未保存的数据可能会丢失。",
|
||||
"power.restart_confirm_title": "重启确认",
|
||||
"power.restart_confirm_message": "确定要重启计算机吗?未保存的数据可能会丢失。",
|
||||
"power.logout_confirm_title": "注销确认",
|
||||
"power.logout_confirm_message": "确定要注销当前用户吗?",
|
||||
"power.sleep_confirm_title": "睡眠确认",
|
||||
"power.sleep_confirm_message": "确定要让计算机进入睡眠状态吗?",
|
||||
"power.confirm_yes": "确定",
|
||||
"power.confirm_cancel": "取消"
|
||||
}
|
||||
|
||||
@@ -200,6 +200,35 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
#endregion
|
||||
|
||||
#region Notification Box Settings (消息盒子全局设置)
|
||||
|
||||
/// <summary>
|
||||
/// 启用消息盒子功能(Windows通知监听)
|
||||
/// </summary>
|
||||
public bool NotificationBoxEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 隐私模式:开启后只显示"您有新的通知",不显示具体内容
|
||||
/// </summary>
|
||||
public bool NotificationBoxPrivacyMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 被屏蔽的应用列表(不接收这些应用的通知)
|
||||
/// </summary>
|
||||
public List<string> NotificationBoxBlockedApps { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 历史记录保留天数
|
||||
/// </summary>
|
||||
public int NotificationBoxHistoryRetentionDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// 最大存储通知数量(防止内存无限增长)
|
||||
/// </summary>
|
||||
public int NotificationBoxMaxStoredCount { get; set; } = 500;
|
||||
|
||||
#endregion
|
||||
|
||||
public AppSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
||||
@@ -213,6 +242,9 @@ public sealed class AppSettingsSnapshot
|
||||
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
|
||||
? new List<string>(DisabledPluginIds)
|
||||
: [];
|
||||
clone.NotificationBoxBlockedApps = NotificationBoxBlockedApps is { Count: > 0 }
|
||||
? new List<string>(NotificationBoxBlockedApps)
|
||||
: [];
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,45 @@ public sealed class ComponentSettingsSnapshot
|
||||
|
||||
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
|
||||
|
||||
#region Notification Box Component Settings (消息盒子组件设置)
|
||||
|
||||
/// <summary>
|
||||
/// 组件内最大显示通知数量
|
||||
/// </summary>
|
||||
public int NotificationBoxMaxDisplayCount { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// 排序方式:TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组)
|
||||
/// </summary>
|
||||
public string NotificationBoxSortOrder { get; set; } = "TimeDesc";
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示应用图标
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowAppIcon { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示时间戳
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowTimestamp { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 时间格式:Relative(相对时间,如"5分钟前"), Absolute(绝对时间)
|
||||
/// </summary>
|
||||
public string NotificationBoxTimeFormat { get; set; } = "Relative";
|
||||
|
||||
/// <summary>
|
||||
/// 是否按应用分组显示
|
||||
/// </summary>
|
||||
public bool NotificationBoxGroupByApp { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示清除按钮
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowClearButton { get; set; } = true;
|
||||
|
||||
#endregion
|
||||
|
||||
public ComponentSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||
@@ -125,6 +164,7 @@ public static class ZhiJiaoHubSources
|
||||
public const string ClassIsland = "classisland";
|
||||
public const string Sectl = "sectl";
|
||||
public const string RinLit = "rinlit";
|
||||
public const string Jiangtokoto = "jiangtokoto";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
@@ -132,6 +172,7 @@ public static class ZhiJiaoHubSources
|
||||
{
|
||||
"sectl" => Sectl,
|
||||
"rinlit" => RinLit,
|
||||
"jiangtokoto" => Jiangtokoto,
|
||||
_ => ClassIsland
|
||||
};
|
||||
}
|
||||
@@ -142,6 +183,7 @@ public static class ZhiJiaoHubSources
|
||||
{
|
||||
Sectl => "SECTL 图库",
|
||||
RinLit => "Rin's 图库",
|
||||
Jiangtokoto => "Jiangtokoto 表情包",
|
||||
_ => "ClassIsland 图库"
|
||||
};
|
||||
}
|
||||
@@ -154,8 +196,13 @@ public sealed class ZhiJiaoHubSourceConfig
|
||||
public string Repo { get; init; } = string.Empty;
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public bool UseJsonIndex { get; init; } = false;
|
||||
public string? JsonIndexPath { get; init; } = null;
|
||||
public string ApiUrl => $"https://api.github.com/repos/{Owner}/{Repo}/contents/{Path}";
|
||||
public string RawUrlTemplate => $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{Path}/{{0}}";
|
||||
public string? JsonIndexUrl => JsonIndexPath != null
|
||||
? $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{JsonIndexPath}"
|
||||
: null;
|
||||
|
||||
public static ZhiJiaoHubSourceConfig GetConfig(string source)
|
||||
{
|
||||
@@ -172,8 +219,17 @@ public sealed class ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "RinLit-233-shiroko",
|
||||
Repo = "Rin-sHub",
|
||||
Path = "assets/images",
|
||||
DisplayName = "Rin's 图库"
|
||||
Path = "updates/images",
|
||||
DisplayName = "Rin's 图库",
|
||||
UseJsonIndex = true,
|
||||
JsonIndexPath = "updates/images.json"
|
||||
},
|
||||
ZhiJiaoHubSources.Jiangtokoto => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "unDefFtr",
|
||||
Repo = "jiangtokoto-images",
|
||||
Path = "images",
|
||||
DisplayName = "Jiangtokoto 表情包"
|
||||
},
|
||||
_ => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
|
||||
87
LanMountainDesktop/Models/FileSystemItem.cs
Normal file
87
LanMountainDesktop/Models/FileSystemItem.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public enum FileSystemItemType
|
||||
{
|
||||
Drive,
|
||||
Directory,
|
||||
File
|
||||
}
|
||||
|
||||
public sealed class FileSystemItem
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string FullPath { get; init; } = string.Empty;
|
||||
public FileSystemItemType ItemType { get; init; }
|
||||
public long? Size { get; init; }
|
||||
public DateTime? LastModified { get; init; }
|
||||
public string? Extension { get; init; }
|
||||
|
||||
public bool IsDirectory => ItemType == FileSystemItemType.Directory || ItemType == FileSystemItemType.Drive;
|
||||
|
||||
public static FileSystemItem FromDriveInfo(DriveInfo drive)
|
||||
{
|
||||
string name;
|
||||
long? size = null;
|
||||
|
||||
try
|
||||
{
|
||||
var volumeLabel = drive.VolumeLabel;
|
||||
name = string.IsNullOrWhiteSpace(volumeLabel)
|
||||
? $"{drive.Name.TrimEnd('\\', '/')}"
|
||||
: $"{volumeLabel} ({drive.Name.TrimEnd('\\', '/').ToUpperInvariant()})";
|
||||
}
|
||||
catch
|
||||
{
|
||||
name = $"{drive.Name.TrimEnd('\\', '/')}";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var totalSize = drive.TotalSize;
|
||||
size = totalSize > 0 ? totalSize : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
size = null;
|
||||
}
|
||||
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = name,
|
||||
FullPath = drive.Name,
|
||||
ItemType = FileSystemItemType.Drive,
|
||||
Size = size,
|
||||
LastModified = null,
|
||||
Extension = null
|
||||
};
|
||||
}
|
||||
|
||||
public static FileSystemItem FromDirectoryInfo(DirectoryInfo directory)
|
||||
{
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = directory.Name,
|
||||
FullPath = directory.FullName,
|
||||
ItemType = FileSystemItemType.Directory,
|
||||
Size = null,
|
||||
LastModified = directory.LastWriteTime,
|
||||
Extension = null
|
||||
};
|
||||
}
|
||||
|
||||
public static FileSystemItem FromFileInfo(FileInfo file)
|
||||
{
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = file.Name,
|
||||
FullPath = file.FullName,
|
||||
ItemType = FileSystemItemType.File,
|
||||
Size = file.Length,
|
||||
LastModified = file.LastWriteTime,
|
||||
Extension = file.Extension
|
||||
};
|
||||
}
|
||||
}
|
||||
54
LanMountainDesktop/Models/NotificationItem.cs
Normal file
54
LanMountainDesktop/Models/NotificationItem.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 通知项数据模型
|
||||
/// </summary>
|
||||
public sealed class NotificationItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 唯一标识
|
||||
/// </summary>
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>
|
||||
/// 应用ID(如 WeChat, Outlook 等)
|
||||
/// </summary>
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 应用名称
|
||||
/// </summary>
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 应用图标路径或Base64
|
||||
/// </summary>
|
||||
public string? AppIconPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知标题
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 通知内容
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收时间
|
||||
/// </summary>
|
||||
public DateTime ReceivedTime { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 是否已读
|
||||
/// </summary>
|
||||
public bool IsRead { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 原始通知的额外数据(用于点击跳转)
|
||||
/// </summary>
|
||||
public string? LaunchArgs { get; set; }
|
||||
}
|
||||
@@ -267,6 +267,11 @@ public static class DesktopComponentEditorRegistryFactory
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
context => new ZhiJiaoHubComponentEditor(context),
|
||||
preferredWidth: 480d,
|
||||
preferredHeight: 520d),
|
||||
[BuiltInComponentIds.DesktopNotificationBox] = new(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
context => new NotificationBoxComponentEditor(context),
|
||||
preferredWidth: 480d,
|
||||
preferredHeight: 520d)
|
||||
};
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
if (_isEditMode) return;
|
||||
_isEditMode = true;
|
||||
|
||||
// 隐藏所有底层小窗口
|
||||
// 【修复问题3】不再隐藏窗口,而是将窗口内容转移到编辑模式覆盖层
|
||||
// 这样可以保持组件的运行状态(动画、输入等)
|
||||
foreach (var window in _widgetWindows.Values)
|
||||
{
|
||||
window.Hide();
|
||||
|
||||
@@ -1,214 +1,265 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
[SupportedOSPlatform("linux")]
|
||||
internal static class LinuxIconService
|
||||
{
|
||||
private static readonly string[] SupportedRasterExtensions =
|
||||
[
|
||||
".png",
|
||||
".ico"
|
||||
];
|
||||
private static readonly string[] IconThemePaths = {
|
||||
"/usr/share/icons",
|
||||
"/usr/share/pixmaps",
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local/share/icons"),
|
||||
"/var/lib/snapd/desktop/icons"
|
||||
};
|
||||
|
||||
private static readonly Regex SizeDirectoryRegex =
|
||||
new(@"(?<size>\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] IconSizes = { "512x512", "256x256", "128x128", "96x96", "64x64", "48x48", "32x32", "24x24", "16x16" };
|
||||
|
||||
private static readonly ConcurrentDictionary<string, string?> IconPathCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly string[] FolderIconNames = { "folder", "inode-directory", "folder-default" };
|
||||
private static readonly string[] DriveIconNames = { "drive-harddisk", "drive-removable-media", "media-removable" };
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string? iconKey, string? desktopFileDirectory = null)
|
||||
public static byte[]? TryGetIconPngBytes(string filePath)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() || string.IsNullOrWhiteSpace(iconKey))
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidatePath in ResolveIconCandidates(iconKey.Trim(), desktopFileDirectory))
|
||||
try
|
||||
{
|
||||
if (TryReadIconBytes(candidatePath, out var bytes))
|
||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
var iconName = GetIconNameForExtension(extension);
|
||||
|
||||
return TryGetThemeIcon(iconName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string iconName, string? searchDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Path.IsPathRooted(iconName) && File.Exists(iconName))
|
||||
{
|
||||
return bytes;
|
||||
if (iconName.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return File.ReadAllBytes(iconName);
|
||||
}
|
||||
|
||||
if (iconName.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (iconName.EndsWith(".xpm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var pngBytes = TryGetThemeIcon(iconName);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchDirectory))
|
||||
{
|
||||
var localIconPath = Path.Combine(searchDirectory, "icons", iconName + ".png");
|
||||
if (File.Exists(localIconPath))
|
||||
{
|
||||
return File.ReadAllBytes(localIconPath);
|
||||
}
|
||||
|
||||
localIconPath = Path.Combine(searchDirectory, iconName + ".png");
|
||||
if (File.Exists(localIconPath))
|
||||
{
|
||||
return File.ReadAllBytes(localIconPath);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetSystemFolderIconPngBytes()
|
||||
{
|
||||
foreach (var iconName in FolderIconNames)
|
||||
{
|
||||
var iconBytes = TryGetThemeIcon(iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
return iconBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ResolveIconCandidates(string iconKey, string? desktopFileDirectory)
|
||||
public static byte[]? TryGetDriveIconPngBytes()
|
||||
{
|
||||
if (Path.HasExtension(iconKey))
|
||||
foreach (var iconName in DriveIconNames)
|
||||
{
|
||||
var directPath = ExpandHome(iconKey);
|
||||
if (Path.IsPathRooted(directPath))
|
||||
var iconBytes = TryGetThemeIcon(iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
yield return directPath;
|
||||
return iconBytes;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(desktopFileDirectory))
|
||||
{
|
||||
yield return Path.GetFullPath(Path.Combine(desktopFileDirectory, directPath));
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
var resolvedThemePath = ResolveThemedIconPath(iconKey);
|
||||
if (!string.IsNullOrWhiteSpace(resolvedThemePath))
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetIconNameForExtension(string extension)
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
yield return resolvedThemePath;
|
||||
".txt" => "text-x-generic",
|
||||
".md" => "text-x-markdown",
|
||||
".pdf" => "application-pdf",
|
||||
".doc" or ".docx" => "application-msword",
|
||||
".xls" or ".xlsx" => "application-vnd.ms-excel",
|
||||
".ppt" or ".pptx" => "application-vnd.ms-powerpoint",
|
||||
".zip" or ".rar" or ".7z" or ".tar" or ".gz" => "application-x-archive",
|
||||
".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" => "audio-x-generic",
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" => "video-x-generic",
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".svg" => "image-x-generic",
|
||||
".cs" => "text-x-csharp",
|
||||
".js" or ".ts" => "text-x-javascript",
|
||||
".py" => "text-x-python",
|
||||
".java" => "text-x-java",
|
||||
".cpp" or ".c" or ".h" => "text-x-c++",
|
||||
".json" => "application-json",
|
||||
".xml" => "text-xml",
|
||||
".html" or ".htm" => "text-html",
|
||||
".css" => "text-css",
|
||||
".sh" or ".bash" => "text-x-script",
|
||||
".exe" or ".msi" => "application-x-executable",
|
||||
".deb" or ".rpm" => "application-x-package",
|
||||
".iso" or ".img" => "application-x-cd-image",
|
||||
_ => "text-x-generic"
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[]? TryGetThemeIcon(string iconName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveThemedIconPath(string iconName)
|
||||
{
|
||||
return IconPathCache.GetOrAdd(iconName, static key => FindBestMatchingIconPath(key));
|
||||
}
|
||||
|
||||
private static string? FindBestMatchingIconPath(string iconName)
|
||||
{
|
||||
var candidates = new List<(string Path, int Score)>();
|
||||
foreach (var iconRoot in EnumerateIconRoots())
|
||||
foreach (var themePath in IconThemePaths)
|
||||
{
|
||||
foreach (var extension in SupportedRasterExtensions)
|
||||
if (!Directory.Exists(themePath))
|
||||
{
|
||||
foreach (var candidatePath in EnumerateFilesSafe(iconRoot, iconName + extension))
|
||||
continue;
|
||||
}
|
||||
|
||||
var iconBytes = TryFindIconInTheme(themePath, iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
return iconBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconFromGtkTheme(iconName);
|
||||
}
|
||||
|
||||
private static byte[]? TryFindIconInTheme(string themePath, string iconName)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var sizeDir in IconSizes)
|
||||
{
|
||||
var iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "mimetypes", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
candidates.Add((candidatePath, ScoreIconPath(candidatePath)));
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "places", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "devices", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.ThenBy(candidate => candidate.Path.Length)
|
||||
.Select(candidate => candidate.Path)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateIconRoots()
|
||||
{
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
|
||||
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
dataHome = Path.Combine(homeDirectory, ".local", "share");
|
||||
}
|
||||
|
||||
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
|
||||
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var candidates = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(dataHome))
|
||||
{
|
||||
candidates.Add(Path.Combine(dataHome, "icons"));
|
||||
candidates.Add(Path.Combine(dataHome, "pixmaps"));
|
||||
}
|
||||
|
||||
foreach (var dataDir in dataDirs)
|
||||
{
|
||||
candidates.Add(Path.Combine(dataDir, "icons"));
|
||||
candidates.Add(Path.Combine(dataDir, "pixmaps"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
candidates.Add(Path.Combine(homeDirectory, ".icons"));
|
||||
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "icons"));
|
||||
}
|
||||
|
||||
candidates.Add("/var/lib/flatpak/exports/share/icons");
|
||||
candidates.Add("/var/lib/snapd/desktop/icons");
|
||||
|
||||
return candidates
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateFilesSafe(string rootPath, string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(rootPath, fileName, SearchOption.AllDirectories);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadIconBytes(string filePath, out byte[] bytes)
|
||||
{
|
||||
bytes = [];
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (!SupportedRasterExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||
!File.Exists(filePath))
|
||||
foreach (var sizeDir in IconSizes)
|
||||
{
|
||||
return false;
|
||||
var iconPath = Path.Combine(themePath, "hicolor", sizeDir, "mimetypes", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "hicolor", sizeDir, "places", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "hicolor", sizeDir, "devices", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
}
|
||||
|
||||
bytes = File.ReadAllBytes(filePath);
|
||||
return bytes.Length > 0;
|
||||
var directPath = Path.Combine(themePath, $"{iconName}.png");
|
||||
if (File.Exists(directPath))
|
||||
{
|
||||
return File.ReadAllBytes(directPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ScoreIconPath(string filePath)
|
||||
private static byte[]? TryGetIconFromGtkTheme(string iconName)
|
||||
{
|
||||
var score = 0;
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (extension.Equals(".png", StringComparison.OrdinalIgnoreCase))
|
||||
try
|
||||
{
|
||||
score += 4_000;
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "gtk3-icon-browser",
|
||||
Arguments = $"--icon={iconName}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
else if (extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
|
||||
catch
|
||||
{
|
||||
score += 2_000;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filePath.Contains($"{Path.DirectorySeparatorChar}hicolor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 8_000;
|
||||
}
|
||||
|
||||
if (filePath.Contains($"{Path.DirectorySeparatorChar}apps{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 1_000;
|
||||
}
|
||||
|
||||
var match = SizeDirectoryRegex.Match(filePath);
|
||||
if (match.Success &&
|
||||
int.TryParse(match.Groups["size"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var size))
|
||||
{
|
||||
score += Math.Min(size, 512);
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static string ExpandHome(string path)
|
||||
{
|
||||
if (!path.StartsWith("~", StringComparison.Ordinal))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path.Length == 1
|
||||
? homeDirectory
|
||||
: Path.Combine(homeDirectory, path[2..]);
|
||||
}
|
||||
}
|
||||
|
||||
216
LanMountainDesktop/Services/LinuxNotificationListener.cs
Normal file
216
LanMountainDesktop/Services/LinuxNotificationListener.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Linux平台通知监听器 - 通过DBus监听org.freedesktop.Notifications
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("linux")]
|
||||
internal sealed class LinuxNotificationListener : IDisposable
|
||||
{
|
||||
private readonly NotificationListenerService _parent;
|
||||
private CancellationTokenSource? _cts;
|
||||
private bool _isRunning;
|
||||
|
||||
public LinuxNotificationListener(NotificationListenerService parent)
|
||||
{
|
||||
_parent = parent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化并启动DBus监听
|
||||
/// </summary>
|
||||
public async Task<bool> InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检查DBus环境变量
|
||||
var dbusSessionBus = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS");
|
||||
if (string.IsNullOrEmpty(dbusSessionBus))
|
||||
{
|
||||
Console.WriteLine("[NotificationBox] DBus Session Bus 环境变量未设置");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查通知守护进程是否运行
|
||||
// 通过检查常见进程名
|
||||
var hasNotificationDaemon = await CheckNotificationDaemonAsync();
|
||||
if (!hasNotificationDaemon)
|
||||
{
|
||||
Console.WriteLine("[NotificationBox] 未检测到通知守护进程,消息盒子功能可能不可用");
|
||||
// 仍然返回true,因为守护进程可能在之后启动
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = StartListeningAsync(_cts.Token);
|
||||
|
||||
Console.WriteLine("[NotificationBox] Linux通知监听已启动");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] Linux通知监听初始化失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> CheckNotificationDaemonAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检查常见通知守护进程
|
||||
var processNames = new[] { "gnome-shell", "kded5", "dunst", "mako", "swaync" };
|
||||
foreach (var name in processNames)
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "pgrep",
|
||||
Arguments = $"-x {name}",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = System.Diagnostics.Process.Start(psi);
|
||||
if (process != null)
|
||||
{
|
||||
await process.WaitForExitAsync();
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartListeningAsync(CancellationToken ct)
|
||||
{
|
||||
_isRunning = true;
|
||||
|
||||
try
|
||||
{
|
||||
// 注意:Tmds.DBus.Protocol 是低层API
|
||||
// 这里使用简化方案,实际生产环境需要完整的DBus信号订阅实现
|
||||
// 当前版本为框架实现,后续可以完善DBus监听逻辑
|
||||
|
||||
while (!ct.IsCancellationRequested && _isRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理接收到的通知(供DBus信号处理器调用)
|
||||
/// </summary>
|
||||
public void HandleNotification(
|
||||
string appName,
|
||||
uint replacesId,
|
||||
string appIcon,
|
||||
string summary,
|
||||
string body,
|
||||
string[] actions,
|
||||
object hints,
|
||||
int expireTimeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
var notification = new NotificationItem
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
AppId = appName.ToLowerInvariant().Replace(" ", ""),
|
||||
AppName = appName,
|
||||
Title = summary,
|
||||
Content = StripHtmlTags(body),
|
||||
ReceivedTime = DateTime.Now,
|
||||
AppIconPath = ResolveIconPath(appIcon, appName)
|
||||
};
|
||||
|
||||
_parent.AddNotification(notification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] 处理通知失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析应用图标路径
|
||||
/// </summary>
|
||||
private static string? ResolveIconPath(string iconName, string appName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(iconName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果是绝对路径,直接使用
|
||||
if (File.Exists(iconName))
|
||||
{
|
||||
return iconName;
|
||||
}
|
||||
|
||||
// 尝试从图标主题中查找
|
||||
var iconPaths = new[]
|
||||
{
|
||||
$"/usr/share/icons/hicolor/48x48/apps/{iconName}.png",
|
||||
$"/usr/share/icons/hicolor/64x64/apps/{iconName}.png",
|
||||
$"/usr/share/pixmaps/{iconName}.png",
|
||||
$"/usr/share/pixmaps/{iconName}.svg",
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
$".local/share/icons/{iconName}.png")
|
||||
};
|
||||
|
||||
return iconPaths.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 去除HTML标签(通知内容可能包含HTML)
|
||||
/// </summary>
|
||||
private static string StripHtmlTags(string html)
|
||||
{
|
||||
if (string.IsNullOrEmpty(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// 简单的HTML标签去除
|
||||
var result = html;
|
||||
result = System.Text.RegularExpressions.Regex.Replace(result, "<[^>]+>", "");
|
||||
result = result.Replace("<", "<");
|
||||
result = result.Replace(">", ">");
|
||||
result = result.Replace("&", "&");
|
||||
result = result.Replace(""", "\"");
|
||||
return result.Trim();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_isRunning = false;
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
296
LanMountainDesktop/Services/MacIconService.cs
Normal file
296
LanMountainDesktop/Services/MacIconService.cs
Normal file
@@ -0,0 +1,296 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
[SupportedOSPlatform("macos")]
|
||||
internal static class MacIconService
|
||||
{
|
||||
private const int IconSize = 256;
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSWorkspace_sharedWorkspace();
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSWorkspace_iconForFile(IntPtr workspace, IntPtr filePath);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSImage_initWithContentsOfFile(IntPtr path);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern IntPtr CGImageDestinationCreateWithURL(IntPtr url, IntPtr type, uint count, IntPtr options);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern void CGImageDestinationAddImage(IntPtr dest, IntPtr image, IntPtr properties);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern bool CGImageDestinationFinalize(IntPtr dest);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSString_stringWithUTF8String(string str);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSURL_fileURLWithPath(IntPtr path);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern void CFRelease(IntPtr handle);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSTemporaryDirectory();
|
||||
|
||||
private static readonly string[] SystemFolderPaths =
|
||||
{
|
||||
"/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources",
|
||||
"/System/Library/Extensions",
|
||||
"/System/Library/PrivateFrameworks"
|
||||
};
|
||||
|
||||
private static readonly string[] FolderIconNames = { "GenericFolderIcon.icns", "SidebarDownloadsFolder.icns", "SidebarDocumentsFolder.icns" };
|
||||
private static readonly string[] DriveIconNames = { "GenericHardDiskIcon.icns", "ExternalDiskIcon.icns", "RemovableDiskIcon.icns" };
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return TryGetIconUsingNSWorkspace(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return TryGetIconForExtension(extension);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetSystemFolderIconPngBytes()
|
||||
{
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var iconName in FolderIconNames)
|
||||
{
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconUsingNSWorkspace("/System/Library/CoreServices");
|
||||
}
|
||||
|
||||
public static byte[]? TryGetDriveIconPngBytes()
|
||||
{
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var iconName in DriveIconNames)
|
||||
{
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconUsingNSWorkspace("/");
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconUsingNSWorkspace(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
var script = $@"
|
||||
tell application ""System Events""
|
||||
set theIcon to icon of file ""{filePath}""
|
||||
end tell
|
||||
";
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "osascript",
|
||||
Arguments = $"-e 'tell application \"Finder\" to get icon of file \"{filePath}\"'",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
return TryGetIconUsingSips(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconUsingSips(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "sips",
|
||||
Arguments = $"-s format png -z {IconSize} {IconSize} \"{filePath}\" --out \"{tempPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(tempPath);
|
||||
File.Delete(tempPath);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconForExtension(string extension)
|
||||
{
|
||||
var iconName = GetIconNameForExtension(extension);
|
||||
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetIconNameForExtension(string extension)
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
".txt" => "TextEdit.icns",
|
||||
".md" => "TextEdit.icns",
|
||||
".pdf" => "Preview.icns",
|
||||
".doc" or ".docx" => "Microsoft Word.icns",
|
||||
".xls" or ".xlsx" => "Microsoft Excel.icns",
|
||||
".ppt" or ".pptx" => "Microsoft PowerPoint.icns",
|
||||
".zip" or ".rar" or ".7z" => "Archive Utility.icns",
|
||||
".mp3" or ".wav" or ".flac" or ".aac" => "Music.icns",
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" => "QuickTime Player.icns",
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" => "Preview.icns",
|
||||
".cs" => "Visual Studio.icns",
|
||||
".js" or ".ts" => "Visual Studio Code.icns",
|
||||
".py" => "IDLE.icns",
|
||||
".json" => "TextEdit.icns",
|
||||
".xml" => "TextEdit.icns",
|
||||
".html" or ".htm" => "Safari.icns",
|
||||
".css" => "TextEdit.icns",
|
||||
".sh" => "Terminal.icns",
|
||||
".app" => "AppIcon.icns",
|
||||
".dmg" => "DiskImage.icns",
|
||||
_ => "GenericDocumentIcon.icns"
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[]? TryConvertIcnsToPng(string icnsPath)
|
||||
{
|
||||
if (!File.Exists(icnsPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "sips",
|
||||
Arguments = $"-s format png \"{icnsPath}\" --out \"{tempPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(tempPath);
|
||||
File.Delete(tempPath);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
201
LanMountainDesktop/Services/NotificationListenerService.cs
Normal file
201
LanMountainDesktop/Services/NotificationListenerService.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 跨平台通知监听服务
|
||||
/// </summary>
|
||||
public sealed class NotificationListenerService : IDisposable
|
||||
{
|
||||
private readonly List<NotificationItem> _notifications = [];
|
||||
private readonly object _lock = new();
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
// 平台特定的监听器
|
||||
private LinuxNotificationListener? _linuxListener;
|
||||
|
||||
public event EventHandler<NotificationItem>? NotificationReceived;
|
||||
public event EventHandler<string>? NotificationRemoved;
|
||||
|
||||
public NotificationListenerService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化并启动监听
|
||||
/// </summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// Windows: 使用 UserNotificationListener (需要Windows SDK)
|
||||
// 当前为模拟实现
|
||||
await InitializeWindowsAsync();
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
// Linux: 使用 DBus
|
||||
await InitializeLinuxAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// macOS 或其他平台:功能不可用
|
||||
Console.WriteLine("[NotificationBox] 当前平台不支持通知监听");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] 初始化失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeWindowsAsync()
|
||||
{
|
||||
// Windows通知监听实现
|
||||
// 实际项目中需要添加Windows SDK引用并使用UserNotificationListener
|
||||
// 由于需要UWP API,这里使用模拟实现
|
||||
await Task.CompletedTask;
|
||||
Console.WriteLine("[NotificationBox] Windows通知监听已启动(模拟模式)");
|
||||
}
|
||||
|
||||
private async Task InitializeLinuxAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_linuxListener = new LinuxNotificationListener(this);
|
||||
var success = await _linuxListener.InitializeAsync();
|
||||
|
||||
if (!success)
|
||||
{
|
||||
Console.WriteLine("[NotificationBox] Linux通知监听初始化失败,可能未运行通知守护进程");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加通知(供平台监听器调用)
|
||||
/// </summary>
|
||||
public void AddNotification(NotificationItem notification)
|
||||
{
|
||||
var settings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
|
||||
// 检查全局开关
|
||||
if (!settings.NotificationBoxEnabled)
|
||||
return;
|
||||
|
||||
// 检查是否在屏蔽列表中
|
||||
if (settings.NotificationBoxBlockedApps.Contains(notification.AppId, StringComparer.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_notifications.Add(notification);
|
||||
CleanupOldNotifications(settings);
|
||||
}
|
||||
|
||||
// 在UI线程触发事件
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
NotificationReceived?.Invoke(this, notification);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除通知
|
||||
/// </summary>
|
||||
public void RemoveNotification(string notificationId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var notification = _notifications.FirstOrDefault(n => n.Id == notificationId);
|
||||
if (notification != null)
|
||||
{
|
||||
_notifications.Remove(notification);
|
||||
}
|
||||
}
|
||||
|
||||
NotificationRemoved?.Invoke(this, notificationId);
|
||||
}
|
||||
|
||||
private void CleanupOldNotifications(AppSettingsSnapshot settings)
|
||||
{
|
||||
// 按数量清理
|
||||
var maxCount = settings.NotificationBoxMaxStoredCount;
|
||||
while (_notifications.Count > maxCount)
|
||||
{
|
||||
_notifications.RemoveAt(0);
|
||||
}
|
||||
|
||||
// 按时间清理
|
||||
var cutoffDate = DateTime.Now.AddDays(-settings.NotificationBoxHistoryRetentionDays);
|
||||
_notifications.RemoveAll(n => n.ReceivedTime < cutoffDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有通知
|
||||
/// </summary>
|
||||
public IReadOnlyList<NotificationItem> GetNotifications()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _notifications.ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空所有通知
|
||||
/// </summary>
|
||||
public void ClearAll()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_notifications.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记通知为已读
|
||||
/// </summary>
|
||||
public void MarkAsRead(string notificationId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var notification = _notifications.FirstOrDefault(n => n.Id == notificationId);
|
||||
if (notification != null)
|
||||
{
|
||||
notification.IsRead = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取未读通知数量
|
||||
/// </summary>
|
||||
public int GetUnreadCount()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _notifications.Count(n => !n.IsRead);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_linuxListener?.Dispose();
|
||||
ClearAll();
|
||||
}
|
||||
}
|
||||
253
LanMountainDesktop/Services/PowerManagementService.cs
Normal file
253
LanMountainDesktop/Services/PowerManagementService.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IPowerManagementService
|
||||
{
|
||||
bool IsShutdownSupported { get; }
|
||||
bool IsRestartSupported { get; }
|
||||
bool IsLogoutSupported { get; }
|
||||
bool IsLockSupported { get; }
|
||||
bool IsSleepSupported { get; }
|
||||
|
||||
Task ShutdownAsync();
|
||||
Task RestartAsync();
|
||||
Task LogoutAsync();
|
||||
Task LockAsync();
|
||||
Task SleepAsync();
|
||||
|
||||
void ShowNativePowerUI(PowerAction action);
|
||||
}
|
||||
|
||||
public enum PowerAction
|
||||
{
|
||||
Shutdown,
|
||||
Restart
|
||||
}
|
||||
|
||||
public static class PowerManagementServiceFactory
|
||||
{
|
||||
private static IPowerManagementService? _instance;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static IPowerManagementService GetOrCreate()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _instance ??= CreatePlatformService();
|
||||
}
|
||||
}
|
||||
|
||||
private static IPowerManagementService CreatePlatformService()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return new WindowsPowerManagementService();
|
||||
if (OperatingSystem.IsLinux())
|
||||
return new LinuxPowerManagementService();
|
||||
return new NullPowerManagementService();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WindowsPowerManagementService : IPowerManagementService
|
||||
{
|
||||
public bool IsShutdownSupported => true;
|
||||
public bool IsRestartSupported => true;
|
||||
public bool IsLogoutSupported => true;
|
||||
public bool IsLockSupported => true;
|
||||
public bool IsSleepSupported => true;
|
||||
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/s /t 0",
|
||||
UseShellExecute = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async Task RestartAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/r /t 0",
|
||||
UseShellExecute = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
ExitWindowsEx(0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LockAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
LockWorkStation();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SleepAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
SetSuspendState(false, false, false);
|
||||
});
|
||||
}
|
||||
|
||||
public void ShowNativePowerUI(PowerAction action)
|
||||
{
|
||||
var slideToShutDownPath = Environment.ExpandEnvironmentVariables(@"%windir%\System32\SlideToShutDown.exe");
|
||||
if (System.IO.File.Exists(slideToShutDownPath))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = slideToShutDownPath,
|
||||
UseShellExecute = true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case PowerAction.Shutdown:
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/s /t 5 /c \"LanMountainDesktop: Shutting down...\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
break;
|
||||
|
||||
case PowerAction.Restart:
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/r /t 5 /c \"LanMountainDesktop: Restarting...\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool ExitWindowsEx(uint uFlags, uint dwReason);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void LockWorkStation();
|
||||
|
||||
[DllImport("powrprof.dll", SetLastError = true)]
|
||||
private static extern bool SetSuspendState(bool hibernate, bool forceCritical, bool disableWakeEvent);
|
||||
}
|
||||
|
||||
internal sealed class LinuxPowerManagementService : IPowerManagementService
|
||||
{
|
||||
public bool IsShutdownSupported => true;
|
||||
public bool IsRestartSupported => true;
|
||||
public bool IsLogoutSupported => true;
|
||||
public bool IsLockSupported => true;
|
||||
public bool IsSleepSupported => true;
|
||||
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
await RunSystemctlCommand("poweroff -i");
|
||||
}
|
||||
|
||||
public async Task RestartAsync()
|
||||
{
|
||||
await RunSystemctlCommand("reboot -i");
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
await RunLoginctlCommand("terminate-session $XDG_SESSION_ID");
|
||||
}
|
||||
|
||||
public async Task LockAsync()
|
||||
{
|
||||
await RunLoginctlCommand("lock-session");
|
||||
}
|
||||
|
||||
public async Task SleepAsync()
|
||||
{
|
||||
await RunSystemctlCommand("suspend -i");
|
||||
}
|
||||
|
||||
public void ShowNativePowerUI(PowerAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case PowerAction.Shutdown:
|
||||
RunProcess("systemctl", "poweroff -i");
|
||||
break;
|
||||
case PowerAction.Restart:
|
||||
RunProcess("systemctl", "reboot -i");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunSystemctlCommand(string args)
|
||||
{
|
||||
await RunProcess("systemctl", args);
|
||||
}
|
||||
|
||||
private static async Task RunLoginctlCommand(string args)
|
||||
{
|
||||
await RunProcess("loginctl", args);
|
||||
}
|
||||
|
||||
private static async Task RunProcess(string command, string args)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = command,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
})?.WaitForExit(5000);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Error("LinuxPowerManagement", $"Failed to execute {command} {args}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NullPowerManagementService : IPowerManagementService
|
||||
{
|
||||
public bool IsShutdownSupported => false;
|
||||
public bool IsRestartSupported => false;
|
||||
public bool IsLogoutSupported => false;
|
||||
public bool IsLockSupported => false;
|
||||
public bool IsSleepSupported => false;
|
||||
|
||||
public Task ShutdownAsync() => Task.CompletedTask;
|
||||
public Task RestartAsync() => Task.CompletedTask;
|
||||
public Task LogoutAsync() => Task.CompletedTask;
|
||||
public Task LockAsync() => Task.CompletedTask;
|
||||
public Task SleepAsync() => Task.CompletedTask;
|
||||
|
||||
public void ShowNativePowerUI(PowerAction action) { }
|
||||
}
|
||||
@@ -3246,16 +3246,27 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
{
|
||||
var config = ZhiJiaoHubSourceConfig.GetConfig(source);
|
||||
|
||||
var contentsUrl = config.ApiUrl;
|
||||
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var images = await FetchImagesFromContentsApi(config, contentsUrl, mirrorSource, cancellationToken);
|
||||
List<ZhiJiaoHubImageItem> images;
|
||||
|
||||
// 如果使用JSON索引模式(Rin's Hub)
|
||||
if (config.UseJsonIndex && !string.IsNullOrEmpty(config.JsonIndexUrl))
|
||||
{
|
||||
images = await FetchImagesFromJsonIndex(config, mirrorSource, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 标准模式(ClassIsland/SECTL)
|
||||
var contentsUrl = config.ApiUrl;
|
||||
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
}
|
||||
|
||||
images = await FetchImagesFromContentsApi(config, contentsUrl, mirrorSource, cancellationToken);
|
||||
}
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
@@ -3380,6 +3391,85 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
return images;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从JSON索引文件获取图片列表(Rin's Hub专用)
|
||||
/// </summary>
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromJsonIndex(
|
||||
ZhiJiaoHubSourceConfig config,
|
||||
string mirrorSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var images = new List<ZhiJiaoHubImageItem>();
|
||||
|
||||
// 下载JSON索引文件
|
||||
var jsonUrl = config.JsonIndexUrl!;
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
jsonUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + jsonUrl;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, jsonUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var jsonText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(jsonText);
|
||||
var root = document.RootElement;
|
||||
|
||||
// 解析 hub_items 数组
|
||||
if (!root.TryGetProperty("hub_items", out var hubItems) || hubItems.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new InvalidOperationException($"JSON索引文件格式无效:缺少 hub_items 数组");
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
foreach (var item in hubItems.EnumerateArray())
|
||||
{
|
||||
// 获取图片路径
|
||||
if (!item.TryGetProperty("image", out var imageProp))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var imagePath = imageProp.GetString();
|
||||
if (string.IsNullOrWhiteSpace(imagePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取标题(用于显示名称)
|
||||
string title = string.Empty;
|
||||
if (item.TryGetProperty("title", out var titleProp))
|
||||
{
|
||||
title = titleProp.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
// 如果没有标题,使用文件名
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
title = Path.GetFileNameWithoutExtension(imagePath);
|
||||
}
|
||||
|
||||
// 构建完整的图片URL
|
||||
// imagePath 格式如: "Discord/姐姐好香.png"
|
||||
// 需要拼接为: https://raw.githubusercontent.com/.../updates/images/Discord/姐姐好香.png
|
||||
// 并对路径中的每个部分进行URL编码
|
||||
var pathParts = imagePath.Split('/');
|
||||
var encodedPath = string.Join("/", pathParts.Select(part => Uri.EscapeDataString(part)));
|
||||
var imageUrl = $"https://raw.githubusercontent.com/{config.Owner}/{config.Repo}/main/{config.Path}/{encodedPath}";
|
||||
|
||||
// 应用镜像加速
|
||||
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
|
||||
|
||||
images.Add(new ZhiJiaoHubImageItem(title, imageUrl, index));
|
||||
index++;
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
private bool TryGetZhiJiaoHubFromCache(string cacheKey, out ZhiJiaoHubSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
|
||||
@@ -93,6 +93,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
private const int WM_WINDOWPOSCHANGING = 0x0046;
|
||||
private const int WM_NCHITTEST = 0x0084;
|
||||
private const int WM_ACTIVATEAPP = 0x001C; // 【新增】应用激活消息
|
||||
private const int HTTRANSPARENT = -1;
|
||||
private const int HTCLIENT = 1;
|
||||
|
||||
@@ -105,6 +106,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
private static readonly Dictionary<IntPtr, Point> _windowScreenOrigins = new();
|
||||
private static readonly object _staticLock = new();
|
||||
|
||||
// 【修复问题1】静态持有委托引用,防止 GC 回收导致 CallbackOnCollectedDelegate 崩溃
|
||||
private static WndProcDelegate? _wndProcDelegate;
|
||||
|
||||
// 【修复问题2】记录每个窗口的 DPI 缩放比例
|
||||
private static readonly Dictionary<IntPtr, double> _windowDpiScales = new();
|
||||
|
||||
// 【修复问题5】Z 轴竞争优化 - 记录上次置底时间,避免频繁操作
|
||||
private static readonly Dictionary<IntPtr, long> _lastSendToBottomTime = new();
|
||||
private const long MinSendToBottomIntervalMs = 100; // 【修复置底问题】降低到 100ms,提高响应速度
|
||||
|
||||
// 【新增】定时器定期强制置底
|
||||
private static System.Timers.Timer? _keepBottomTimer;
|
||||
private static readonly object _timerLock = new();
|
||||
|
||||
public bool IsBottomMostSupported => true;
|
||||
|
||||
public void SetupBottomMost(Window window)
|
||||
@@ -130,6 +145,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
_bottomMostWindows[handle] = true;
|
||||
_interactiveRegions[handle] = [];
|
||||
UpdateWindowScreenOrigin(handle);
|
||||
UpdateWindowDpiScale(handle); // 【修复问题2】初始化 DPI 缩放
|
||||
}
|
||||
|
||||
// 注入消息钩子
|
||||
@@ -138,6 +154,9 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
// 初始置底
|
||||
SendToBottomInternal(handle);
|
||||
|
||||
// 【新增】启动定时器定期强制置底
|
||||
StartKeepBottomTimer();
|
||||
|
||||
AppLogger.Info("WindowBottomMost", $"Window setup as bottom-most: {handle}");
|
||||
};
|
||||
|
||||
@@ -152,6 +171,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
_originalWndProcs.Remove(handle);
|
||||
_interactiveRegions.Remove(handle);
|
||||
_windowScreenOrigins.Remove(handle);
|
||||
_windowDpiScales.Remove(handle); // 【修复问题2】清理 DPI 缩放记录
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -174,21 +194,113 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【新增】启动定时器定期强制置底所有窗口
|
||||
/// </summary>
|
||||
private static void StartKeepBottomTimer()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (_keepBottomTimer != null) return;
|
||||
|
||||
_keepBottomTimer = new System.Timers.Timer(200); // 每 200ms 检查一次
|
||||
_keepBottomTimer.Elapsed += (s, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
foreach (var kvp in _bottomMostWindows)
|
||||
{
|
||||
if (kvp.Value) // 如果标记为置底
|
||||
{
|
||||
SendToBottomInternal(kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略定时器错误
|
||||
}
|
||||
};
|
||||
_keepBottomTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【新增】停止定时器
|
||||
/// </summary>
|
||||
private static void StopKeepBottomTimer()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
_keepBottomTimer?.Stop();
|
||||
_keepBottomTimer?.Dispose();
|
||||
_keepBottomTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetAsDesktopChild(IntPtr handle)
|
||||
{
|
||||
// 【修复问题4】增强桌面挂载逻辑,支持 Wallpaper Engine 等动态壁纸软件
|
||||
|
||||
// 方案1: 尝试找到 WorkerW 层(Wallpaper Engine 创建的层)
|
||||
var workerW = IntPtr.Zero;
|
||||
var hDefView = IntPtr.Zero;
|
||||
|
||||
// 枚举所有顶层窗口
|
||||
var windowHandles = new ArrayList();
|
||||
EnumWindows(EnumWindowsCallback, windowHandles);
|
||||
|
||||
foreach (IntPtr h in windowHandles)
|
||||
{
|
||||
var hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
// 查找 WorkerW 窗口(Wallpaper Engine 创建)
|
||||
var className = GetWindowClassName(h);
|
||||
if (className == "WorkerW")
|
||||
{
|
||||
// 在 WorkerW 下查找 SHELLDLL_DefView
|
||||
var defView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
if (defView != IntPtr.Zero)
|
||||
{
|
||||
workerW = h;
|
||||
hDefView = defView;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到了 WorkerW 层,使用它作为父窗口
|
||||
if (workerW != IntPtr.Zero && hDefView != IntPtr.Zero)
|
||||
{
|
||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
||||
AppLogger.Info("WindowBottomMost", "Mounted to WorkerW layer (Wallpaper Engine detected)");
|
||||
return;
|
||||
}
|
||||
|
||||
// 方案2: 回退到传统方式,查找 Progman 下的 SHELLDLL_DefView
|
||||
foreach (IntPtr h in windowHandles)
|
||||
{
|
||||
hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
if (hDefView != IntPtr.Zero)
|
||||
{
|
||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
||||
AppLogger.Info("WindowBottomMost", "Mounted to traditional desktop layer");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题4】获取窗口类名
|
||||
/// </summary>
|
||||
private static string GetWindowClassName(IntPtr hWnd)
|
||||
{
|
||||
var buffer = new char[256];
|
||||
var length = GetClassName(hWnd, buffer, buffer.Length);
|
||||
return length > 0 ? new string(buffer, 0, length) : string.Empty;
|
||||
}
|
||||
|
||||
private static bool EnumWindowsCallback(IntPtr handle, ArrayList handles)
|
||||
{
|
||||
handles.Add(handle);
|
||||
@@ -203,13 +315,29 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
lock (_staticLock)
|
||||
{
|
||||
_originalWndProcs[handle] = originalWndProc;
|
||||
|
||||
// 【修复问题1】确保委托实例被静态引用持有,防止 GC 回收
|
||||
_wndProcDelegate ??= SubclassWndProc;
|
||||
}
|
||||
|
||||
SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate<WndProcDelegate>(SubclassWndProc));
|
||||
SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_wndProcDelegate));
|
||||
}
|
||||
|
||||
private static IntPtr SubclassWndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
// 【新增】处理应用激活消息 - 当其他应用激活时立即置底
|
||||
if (msg == WM_ACTIVATEAPP)
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
|
||||
{
|
||||
// 立即置底,不进行频率限制
|
||||
SendToBottomInternal(hWnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 WM_WINDOWPOSCHANGING - 保持置底
|
||||
if (msg == WM_WINDOWPOSCHANGING)
|
||||
{
|
||||
@@ -217,7 +345,19 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
{
|
||||
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
|
||||
{
|
||||
// 【修复问题5】优化 Z 轴竞争 - 限制置底操作频率
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
if (_lastSendToBottomTime.TryGetValue(hWnd, out var lastTime))
|
||||
{
|
||||
if (now - lastTime < MinSendToBottomIntervalMs)
|
||||
{
|
||||
// 跳过过于频繁的置底操作
|
||||
goto CallOriginal;
|
||||
}
|
||||
}
|
||||
|
||||
SendToBottomInternal(hWnd);
|
||||
_lastSendToBottomTime[hWnd] = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,11 +373,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
{
|
||||
if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0)
|
||||
{
|
||||
// 将屏幕坐标转为窗口相对坐标(_interactiveRegions 存的是窗口内坐标)
|
||||
// 【修复问题2】获取窗口原点和 DPI 缩放比例
|
||||
_windowScreenOrigins.TryGetValue(hWnd, out var origin);
|
||||
_windowDpiScales.TryGetValue(hWnd, out var dpiScale);
|
||||
if (dpiScale <= 0) dpiScale = 1.0; // 默认缩放为 1.0
|
||||
|
||||
// 将屏幕物理像素坐标转为窗口相对坐标
|
||||
var clientX = screenX - origin.X;
|
||||
var clientY = screenY - origin.Y;
|
||||
var point = new Point(clientX, clientY);
|
||||
|
||||
// 【修复问题2】将物理像素坐标转换为逻辑 DIP 坐标
|
||||
// _interactiveRegions 存储的是 Avalonia UI 的逻辑 DIP 坐标
|
||||
var logicalX = clientX / dpiScale;
|
||||
var logicalY = clientY / dpiScale;
|
||||
var point = new Point(logicalX, logicalY);
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
@@ -255,6 +404,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
}
|
||||
|
||||
// 调用原始窗口过程
|
||||
CallOriginal:
|
||||
IntPtr originalWndProc;
|
||||
lock (_staticLock)
|
||||
{
|
||||
@@ -277,6 +427,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
_interactiveRegions[handle] = regions;
|
||||
// 同步刷新屏幕原点(DPI 缩放可能影响坐标,每次更新区域时一并刷新)
|
||||
UpdateWindowScreenOrigin(handle);
|
||||
UpdateWindowDpiScale(handle); // 【修复问题2】同步更新 DPI 缩放
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +442,31 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题2】更新指定窗口的 DPI 缩放比例
|
||||
/// </summary>
|
||||
private static void UpdateWindowDpiScale(IntPtr handle)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取窗口所在的显示器 DPI
|
||||
var monitor = MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST);
|
||||
if (monitor != IntPtr.Zero)
|
||||
{
|
||||
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out var dpiX, out var _) == 0)
|
||||
{
|
||||
// DPI 缩放比例 = 当前 DPI / 96 (标准 DPI)
|
||||
_windowDpiScales[handle] = dpiX / 96.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果获取失败,使用默认缩放 1.0
|
||||
_windowDpiScales[handle] = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT { public int Left, Top, Right, Bottom; }
|
||||
|
||||
@@ -328,6 +504,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
// 【修复问题2】DPI 相关的 P/Invoke 声明
|
||||
private const int MONITOR_DEFAULTTONEAREST = 2;
|
||||
private const int MDT_EFFECTIVE_DPI = 0;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr MonitorFromWindow(IntPtr hWnd, int dwFlags);
|
||||
|
||||
[DllImport("shcore.dll")]
|
||||
private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
|
||||
|
||||
// 【修复问题4】获取窗口类名的 P/Invoke
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto)]
|
||||
private static extern int GetClassName(IntPtr hWnd, char[] lpClassName, int nMaxCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
@@ -696,18 +696,23 @@ internal static class WindowsIconService
|
||||
try
|
||||
{
|
||||
using var source = Image.FromHbitmap(bitmapHandle);
|
||||
using var bitmap = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb);
|
||||
var width = source.Width;
|
||||
var height = source.Height;
|
||||
|
||||
using var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.Transparent);
|
||||
graphics.CompositingMode = CompositingMode.SourceOver;
|
||||
graphics.CompositingMode = CompositingMode.SourceCopy;
|
||||
graphics.CompositingQuality = CompositingQuality.HighQuality;
|
||||
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
graphics.SmoothingMode = SmoothingMode.HighQuality;
|
||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
graphics.DrawImage(source, 0, 0, source.Width, source.Height);
|
||||
graphics.DrawImage(source, 0, 0, width, height);
|
||||
}
|
||||
|
||||
FixBitmapAlpha(bitmap);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
@@ -718,6 +723,47 @@ internal static class WindowsIconService
|
||||
}
|
||||
}
|
||||
|
||||
private static void FixBitmapAlpha(Bitmap bitmap)
|
||||
{
|
||||
var width = bitmap.Width;
|
||||
var height = bitmap.Height;
|
||||
var rect = new Rectangle(0, 0, width, height);
|
||||
var data = bitmap.LockBits(rect, ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Math.Abs(data.Stride) * height;
|
||||
var buffer = new byte[bytes];
|
||||
Marshal.Copy(data.Scan0, buffer, 0, bytes);
|
||||
|
||||
for (var i = 0; i < bytes; i += 4)
|
||||
{
|
||||
var b = buffer[i];
|
||||
var g = buffer[i + 1];
|
||||
var r = buffer[i + 2];
|
||||
var a = buffer[i + 3];
|
||||
|
||||
if (a == 0 && (r != 0 || g != 0 || b != 0))
|
||||
{
|
||||
a = (byte)Math.Max(r, Math.Max(g, b));
|
||||
buffer[i + 3] = a;
|
||||
}
|
||||
else if (a > 0 && a < 255)
|
||||
{
|
||||
buffer[i] = (byte)(b * 255 / a);
|
||||
buffer[i + 1] = (byte)(g * 255 / a);
|
||||
buffer[i + 2] = (byte)(r * 255 / a);
|
||||
}
|
||||
}
|
||||
|
||||
Marshal.Copy(buffer, 0, data.Scan0, bytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(data);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryInitializeCom(out bool shouldUninitialize)
|
||||
{
|
||||
shouldUninitialize = false;
|
||||
|
||||
115
LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs
Normal file
115
LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public sealed partial class NotificationBoxEditorViewModel : ViewModelBase
|
||||
{
|
||||
private readonly DesktopComponentEditorContext? _context;
|
||||
private bool _isInitializing;
|
||||
|
||||
public NotificationBoxEditorViewModel(DesktopComponentEditorContext? context)
|
||||
{
|
||||
_context = context;
|
||||
|
||||
MaxDisplayCountOptions = new ObservableCollection<SelectionOption>
|
||||
{
|
||||
new("20", "20条"),
|
||||
new("50", "50条"),
|
||||
new("100", "100条"),
|
||||
new("200", "200条")
|
||||
};
|
||||
|
||||
SortOrderOptions = new ObservableCollection<SelectionOption>
|
||||
{
|
||||
new("TimeDesc", "最新优先"),
|
||||
new("TimeAsc", "最早优先"),
|
||||
new("AppGroup", "按应用分组")
|
||||
};
|
||||
|
||||
TimeFormatOptions = new ObservableCollection<SelectionOption>
|
||||
{
|
||||
new("Relative", "相对时间(如:5分钟前)"),
|
||||
new("Absolute", "绝对时间(如:14:30)")
|
||||
};
|
||||
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>()
|
||||
?? new ComponentSettingsSnapshot();
|
||||
|
||||
_isInitializing = true;
|
||||
|
||||
var countValue = snapshot.NotificationBoxMaxDisplayCount.ToString();
|
||||
SelectedMaxDisplayCount = MaxDisplayCountOptions.FirstOrDefault(o => o.Value == countValue)
|
||||
?? MaxDisplayCountOptions[1]; // 默认50
|
||||
|
||||
SelectedSortOrder = SortOrderOptions.FirstOrDefault(o => o.Value == snapshot.NotificationBoxSortOrder)
|
||||
?? SortOrderOptions[0];
|
||||
ShowAppIcon = snapshot.NotificationBoxShowAppIcon;
|
||||
ShowTimestamp = snapshot.NotificationBoxShowTimestamp;
|
||||
SelectedTimeFormat = TimeFormatOptions.FirstOrDefault(o => o.Value == snapshot.NotificationBoxTimeFormat)
|
||||
?? TimeFormatOptions[0];
|
||||
GroupByApp = snapshot.NotificationBoxGroupByApp;
|
||||
ShowClearButton = snapshot.NotificationBoxShowClearButton;
|
||||
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
if (_isInitializing || _context == null) return;
|
||||
|
||||
var snapshot = _context.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
|
||||
snapshot.NotificationBoxMaxDisplayCount = int.TryParse(SelectedMaxDisplayCount?.Value, out var count) ? count : 50;
|
||||
snapshot.NotificationBoxSortOrder = SelectedSortOrder?.Value ?? "TimeDesc";
|
||||
snapshot.NotificationBoxShowAppIcon = ShowAppIcon;
|
||||
snapshot.NotificationBoxShowTimestamp = ShowTimestamp;
|
||||
snapshot.NotificationBoxTimeFormat = SelectedTimeFormat?.Value ?? "Relative";
|
||||
snapshot.NotificationBoxGroupByApp = GroupByApp;
|
||||
snapshot.NotificationBoxShowClearButton = ShowClearButton;
|
||||
|
||||
_context.ComponentSettingsAccessor.SaveSnapshot(snapshot);
|
||||
|
||||
_context.HostContext.RequestRefresh();
|
||||
}
|
||||
|
||||
[ObservableProperty] private string _descriptionText = "配置此消息盒子组件的显示方式。这些设置仅作用于当前组件实例。";
|
||||
[ObservableProperty] private string _maxDisplayCountLabel = "最大显示数量";
|
||||
[ObservableProperty] private string _maxDisplayCountDescription = "组件中最多显示的通知条数";
|
||||
[ObservableProperty] private string _sortOrderLabel = "排序方式";
|
||||
[ObservableProperty] private string _displayOptionsLabel = "显示选项";
|
||||
[ObservableProperty] private string _showAppIconLabel = "显示应用图标";
|
||||
[ObservableProperty] private string _showTimestampLabel = "显示时间戳";
|
||||
[ObservableProperty] private string _groupByAppLabel = "按应用分组显示";
|
||||
[ObservableProperty] private string _showClearButtonLabel = "显示清空按钮";
|
||||
[ObservableProperty] private string _timeFormatLabel = "时间格式";
|
||||
|
||||
[ObservableProperty] private SelectionOption? _selectedMaxDisplayCount;
|
||||
[ObservableProperty] private SelectionOption? _selectedSortOrder;
|
||||
[ObservableProperty] private bool _showAppIcon = true;
|
||||
[ObservableProperty] private bool _showTimestamp = true;
|
||||
[ObservableProperty] private SelectionOption? _selectedTimeFormat;
|
||||
[ObservableProperty] private bool _groupByApp = false;
|
||||
[ObservableProperty] private bool _showClearButton = true;
|
||||
|
||||
public ObservableCollection<SelectionOption> MaxDisplayCountOptions { get; }
|
||||
public ObservableCollection<SelectionOption> SortOrderOptions { get; }
|
||||
public ObservableCollection<SelectionOption> TimeFormatOptions { get; }
|
||||
|
||||
partial void OnSelectedMaxDisplayCountChanged(SelectionOption? value) => SaveSettings();
|
||||
partial void OnSelectedSortOrderChanged(SelectionOption? value) => SaveSettings();
|
||||
partial void OnShowAppIconChanged(bool value) => SaveSettings();
|
||||
partial void OnShowTimestampChanged(bool value) => SaveSettings();
|
||||
partial void OnSelectedTimeFormatChanged(SelectionOption? value) => SaveSettings();
|
||||
partial void OnGroupByAppChanged(bool value) => SaveSettings();
|
||||
partial void OnShowClearButtonChanged(bool value) => SaveSettings();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.NotificationBoxComponentEditor"
|
||||
x:DataType="vm:NotificationBoxEditorViewModel">
|
||||
|
||||
<StackPanel Spacing="16">
|
||||
<!-- 说明卡片 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<TextBlock Text="{Binding DescriptionText}"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!-- 最大显示数量 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding MaxDisplayCountLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<TextBlock Text="{Binding MaxDisplayCountDescription}"
|
||||
Classes="component-editor-secondary-text" />
|
||||
<ComboBox ItemsSource="{Binding MaxDisplayCountOptions}"
|
||||
SelectedItem="{Binding SelectedMaxDisplayCount}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 排序方式 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding SortOrderLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox ItemsSource="{Binding SortOrderOptions}"
|
||||
SelectedItem="{Binding SelectedSortOrder}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 显示选项 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Text="{Binding DisplayOptionsLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
|
||||
<CheckBox IsChecked="{Binding ShowAppIcon}"
|
||||
Content="{Binding ShowAppIconLabel}" />
|
||||
|
||||
<CheckBox IsChecked="{Binding ShowTimestamp}"
|
||||
Content="{Binding ShowTimestampLabel}" />
|
||||
|
||||
<CheckBox IsChecked="{Binding GroupByApp}"
|
||||
Content="{Binding GroupByAppLabel}" />
|
||||
|
||||
<CheckBox IsChecked="{Binding ShowClearButton}"
|
||||
Content="{Binding ShowClearButtonLabel}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 时间格式 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding TimeFormatLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox ItemsSource="{Binding TimeFormatOptions}"
|
||||
SelectedItem="{Binding SelectedTimeFormat}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,15 @@
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
public partial class NotificationBoxComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
public NotificationBoxComponentEditor(DesktopComponentEditorContext? context)
|
||||
: base(context)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new NotificationBoxEditorViewModel(context);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@
|
||||
<ComboBoxItem x:Name="RinLitItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="rinlit" />
|
||||
<ComboBoxItem x:Name="JiangtokotoItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="jiangtokoto" />
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="SourceDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
|
||||
@@ -30,10 +30,11 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
ClassIslandItem.Content = L("zhijiaohub.settings.classisland", "ClassIsland 图库");
|
||||
SectlItem.Content = L("zhijiaohub.settings.sectl", "SECTL 图库");
|
||||
RinLitItem.Content = L("zhijiaohub.settings.rinlit", "Rin's 图库");
|
||||
JiangtokotoItem.Content = L("zhijiaohub.settings.jiangtokoto", "Jiangtokoto 表情包");
|
||||
|
||||
// 数据源描述
|
||||
SourceDescriptionTextBlock.Text = L("zhijiaohub.settings.source_desc",
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容。");
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容,Jiangtokoto 表情包包含丰富的表情包资源。");
|
||||
|
||||
// 镜像加速源
|
||||
MirrorSourceLabelTextBlock.Text = L("zhijiaohub.settings.mirror_source", "镜像加速");
|
||||
@@ -67,6 +68,7 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => SectlItem,
|
||||
ZhiJiaoHubSources.RinLit => RinLitItem,
|
||||
ZhiJiaoHubSources.Jiangtokoto => JiangtokotoItem,
|
||||
_ => ClassIslandItem
|
||||
};
|
||||
|
||||
|
||||
@@ -475,7 +475,15 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
"component.zhijiao_hub",
|
||||
() => new ZhiJiaoHubWidget())
|
||||
() => new ZhiJiaoHubWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopFileManager,
|
||||
"component.file_manager",
|
||||
() => new FileManagerWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
"component.notification_box",
|
||||
() => new NotificationBoxWidget())
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
138
LanMountainDesktop/Views/Components/FileManagerWidget.axaml
Normal file
138
LanMountainDesktop/Views/Components/FileManagerWidget.axaml
Normal file
@@ -0,0 +1,138 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="320"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.FileManagerWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="12,10"
|
||||
ClipToBounds="True">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<!-- 导航栏 -->
|
||||
<Grid Grid.Row="0"
|
||||
ColumnDefinitions="Auto,Auto,*,Auto"
|
||||
ColumnSpacing="6">
|
||||
<!-- 返回按钮 -->
|
||||
<Button x:Name="BackButton"
|
||||
Grid.Column="0"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnBackButtonClick">
|
||||
<fi:SymbolIcon Symbol="ArrowLeft"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
|
||||
<!-- 主页/盘符按钮 -->
|
||||
<Button x:Name="HomeButton"
|
||||
Grid.Column="1"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnHomeButtonClick">
|
||||
<fi:SymbolIcon Symbol="Home"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
|
||||
<!-- 路径显示 -->
|
||||
<Border Grid.Column="2"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Padding="10,0"
|
||||
VerticalAlignment="Center"
|
||||
Height="32">
|
||||
<TextBlock x:Name="PathTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="13"
|
||||
FontWeight="Medium"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding $self.Text}"
|
||||
Text="此电脑" />
|
||||
</Border>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="3"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnRefreshButtonClick">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<Border Grid.Row="1"
|
||||
Height="1"
|
||||
Margin="0,10"
|
||||
Background="{DynamicResource AdaptiveDividerBrush}" />
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl x:Name="FileItemsControl">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<StackPanel x:Name="EmptyStatePanel"
|
||||
Grid.Row="2"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<fi:SymbolIcon Symbol="FolderOpen"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
<TextBlock x:Name="EmptyStateTextBlock"
|
||||
Text="文件夹为空"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<StackPanel x:Name="ErrorStatePanel"
|
||||
Grid.Row="2"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<fi:SymbolIcon Symbol="ErrorCircle"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource AdaptiveErrorBrush}" />
|
||||
<TextBlock x:Name="ErrorStateTextBlock"
|
||||
Text="无法访问此文件夹"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveErrorBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
819
LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs
Normal file
819
LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs
Normal file
@@ -0,0 +1,819 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using FluentIcons.Avalonia;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class FileManagerWidget : UserControl,
|
||||
IDesktopComponentWidget,
|
||||
IDesktopPageVisibilityAwareComponentWidget,
|
||||
IComponentPlacementContextAware,
|
||||
IDisposable
|
||||
{
|
||||
private readonly List<string> _navigationHistory = new();
|
||||
private int _currentHistoryIndex = -1;
|
||||
private string _currentPath = string.Empty;
|
||||
private string _componentId = BuiltInComponentIds.DesktopFileManager;
|
||||
private string _placementId = string.Empty;
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isOnActivePage;
|
||||
private bool _isEditMode;
|
||||
private bool _isAttached;
|
||||
private bool _isDisposed;
|
||||
|
||||
private const double TapMovementThreshold = 10;
|
||||
private const long TapTimeThresholdMs = 500;
|
||||
|
||||
private readonly Dictionary<int, PointerGestureState> _gestureStates = new();
|
||||
|
||||
private record PointerGestureState(
|
||||
Point StartPosition,
|
||||
long StartTime,
|
||||
FileSystemItem Item,
|
||||
Border Border
|
||||
);
|
||||
|
||||
public FileManagerWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
NavigateToDrives();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
|
||||
var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius();
|
||||
RootBorder.CornerRadius = mainRectangleCornerRadius;
|
||||
RootBorder.Padding = new Thickness(
|
||||
Math.Clamp(_currentCellSize * 0.25, 10, 20),
|
||||
Math.Clamp(_currentCellSize * 0.20, 8, 16));
|
||||
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_isOnActivePage = isOnActivePage;
|
||||
_isEditMode = isEditMode;
|
||||
|
||||
if (_isOnActivePage && _isAttached && !string.IsNullOrEmpty(_currentPath))
|
||||
{
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopFileManager
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
|
||||
_gestureStates.Clear();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
_isAttached = true;
|
||||
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
_isAttached = false;
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
private void ApplyLayoutMetrics()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 4;
|
||||
|
||||
var buttonSize = Math.Clamp(32 * scale, 28, 40);
|
||||
var iconSize = Math.Clamp(14 * scale, 12, 18);
|
||||
var pathFontSize = Math.Clamp(13 * scale, 11, 16);
|
||||
|
||||
BackButton.Width = buttonSize;
|
||||
BackButton.Height = buttonSize;
|
||||
BackButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
HomeButton.Width = buttonSize;
|
||||
HomeButton.Height = buttonSize;
|
||||
HomeButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
RefreshButton.Width = buttonSize;
|
||||
RefreshButton.Height = buttonSize;
|
||||
RefreshButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
PathTextBlock.FontSize = pathFontSize;
|
||||
|
||||
if (BackButton.Content is SymbolIcon backIcon)
|
||||
{
|
||||
backIcon.FontSize = iconSize;
|
||||
}
|
||||
|
||||
if (HomeButton.Content is SymbolIcon homeIcon)
|
||||
{
|
||||
homeIcon.FontSize = iconSize;
|
||||
}
|
||||
|
||||
if (RefreshButton.Content is SymbolIcon refreshIcon)
|
||||
{
|
||||
refreshIcon.FontSize = iconSize;
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2);
|
||||
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 280d, 0.72, 2.4) : 1;
|
||||
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 280d, 0.72, 2.4) : 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2);
|
||||
}
|
||||
|
||||
private void OnBackButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
if (_currentHistoryIndex > 0)
|
||||
{
|
||||
_currentHistoryIndex--;
|
||||
var path = _navigationHistory[_currentHistoryIndex];
|
||||
LoadDirectory(path, addToHistory: false);
|
||||
}
|
||||
else if (_currentHistoryIndex == 0 && _navigationHistory.Count > 0)
|
||||
{
|
||||
NavigateToDrives();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHomeButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
NavigateToDrives();
|
||||
}
|
||||
|
||||
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
|
||||
private void OnItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (sender is not Border border || border.DataContext is not FileSystemItem item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointer = e.GetCurrentPoint(border);
|
||||
var pointerId = e.Pointer.Id;
|
||||
var position = pointer.Position;
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
_gestureStates[pointerId] = new PointerGestureState(position, timestamp, item, border);
|
||||
|
||||
e.Pointer.Capture(border);
|
||||
}
|
||||
|
||||
private void OnItemPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (sender is not Border border)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.TryGetValue(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(border);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||
);
|
||||
|
||||
if (distance > TapMovementThreshold)
|
||||
{
|
||||
_gestureStates.Remove(pointerId);
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (sender is not Border border)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.Remove(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Pointer.Capture(null);
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(border);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||
);
|
||||
|
||||
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - state.StartTime;
|
||||
|
||||
if (distance <= TapMovementThreshold && elapsed <= TapTimeThresholdMs)
|
||||
{
|
||||
if (state.Item.IsDirectory)
|
||||
{
|
||||
LoadDirectory(state.Item.FullPath, addToHistory: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
OpenFile(state.Item.FullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void NavigateToDrives()
|
||||
{
|
||||
_navigationHistory.Clear();
|
||||
_currentHistoryIndex = -1;
|
||||
_currentPath = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var drives = new List<FileSystemItem>();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!drive.IsReady)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var item = FileSystemItem.FromDriveInfo(drive);
|
||||
drives.Add(item);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to access drive: {drive?.Name}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "根目录",
|
||||
FullPath = "/",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(homePath) && Directory.Exists(homePath))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "主目录",
|
||||
FullPath = homePath,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
|
||||
var linuxMountPoints = new[] { "/mnt", "/media", "/run/media" };
|
||||
foreach (var mount in linuxMountPoints)
|
||||
{
|
||||
if (Directory.Exists(mount))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = mount,
|
||||
FullPath = mount,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "根目录",
|
||||
FullPath = "/",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "用户",
|
||||
FullPath = "/Users",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "应用程序",
|
||||
FullPath = "/Applications",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(homePath) && Directory.Exists(homePath))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "个人",
|
||||
FullPath = homePath,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
|
||||
if (Directory.Exists("/Volumes"))
|
||||
{
|
||||
foreach (var volume in Directory.GetDirectories("/Volumes"))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = Path.GetFileName(volume),
|
||||
FullPath = volume,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RenderFileItems(drives);
|
||||
PathTextBlock.Text = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "此电脑" : "文件系统";
|
||||
|
||||
UpdateEmptyState(drives.Count == 0, "没有可用的位置");
|
||||
ErrorStatePanel.IsVisible = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", "Failed to load drives.", ex);
|
||||
ShowError("无法加载位置列表");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadDirectory(string path, bool addToHistory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
NavigateToDrives();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directoryInfo = new DirectoryInfo(path);
|
||||
|
||||
if (!directoryInfo.Exists)
|
||||
{
|
||||
ShowError("文件夹不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
var items = new List<FileSystemItem>();
|
||||
|
||||
// 添加子文件夹
|
||||
try
|
||||
{
|
||||
var directories = directoryInfo.GetDirectories()
|
||||
.Where(d => (d.Attributes & FileAttributes.Hidden) == 0)
|
||||
.OrderBy(d => d.Name)
|
||||
.Select(FileSystemItem.FromDirectoryInfo);
|
||||
items.AddRange(directories);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// 忽略无权限访问的文件夹
|
||||
}
|
||||
|
||||
// 添加文件
|
||||
try
|
||||
{
|
||||
var files = directoryInfo.GetFiles()
|
||||
.Where(f => (f.Attributes & FileAttributes.Hidden) == 0)
|
||||
.OrderBy(f => f.Name)
|
||||
.Select(FileSystemItem.FromFileInfo);
|
||||
items.AddRange(files);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// 忽略无权限访问的文件
|
||||
}
|
||||
|
||||
RenderFileItems(items);
|
||||
_currentPath = path;
|
||||
PathTextBlock.Text = FormatPathForDisplay(path);
|
||||
|
||||
if (addToHistory)
|
||||
{
|
||||
// 移除当前位置之后的历史记录
|
||||
if (_currentHistoryIndex < _navigationHistory.Count - 1)
|
||||
{
|
||||
_navigationHistory.RemoveRange(_currentHistoryIndex + 1, _navigationHistory.Count - _currentHistoryIndex - 1);
|
||||
}
|
||||
|
||||
_navigationHistory.Add(path);
|
||||
_currentHistoryIndex = _navigationHistory.Count - 1;
|
||||
}
|
||||
|
||||
UpdateEmptyState(items.Count == 0, "文件夹为空");
|
||||
ErrorStatePanel.IsVisible = false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
ShowError("没有权限访问此文件夹");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to load directory: {path}", ex);
|
||||
ShowError("无法加载文件夹内容");
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderFileItems(List<FileSystemItem> items)
|
||||
{
|
||||
FileItemsControl.ItemsSource = null;
|
||||
FileItemsControl.Items.Clear();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var itemControl = CreateFileItemControl(item);
|
||||
FileItemsControl.Items.Add(itemControl);
|
||||
}
|
||||
}
|
||||
|
||||
private Control CreateFileItemControl(FileSystemItem item)
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var itemWidth = Math.Clamp(72 * scale, 64, 96);
|
||||
var itemHeight = Math.Clamp(80 * scale, 72, 108);
|
||||
var iconSize = Math.Clamp(32 * scale, 24, 40);
|
||||
var fontSize = Math.Clamp(11 * scale, 10, 14);
|
||||
|
||||
var textBrush = this.FindResource("AdaptiveTextPrimaryBrush") as IBrush ?? new SolidColorBrush(Colors.White);
|
||||
|
||||
var border = new Border
|
||||
{
|
||||
Width = itemWidth,
|
||||
Height = itemHeight,
|
||||
Margin = new Thickness(4),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = new SolidColorBrush(Colors.Transparent),
|
||||
Cursor = new Cursor(StandardCursorType.Hand),
|
||||
DataContext = item
|
||||
};
|
||||
|
||||
var grid = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("*,Auto"),
|
||||
Margin = new Thickness(4)
|
||||
};
|
||||
|
||||
var iconImage = CreateSystemIconImage(item, iconSize);
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = item.Name,
|
||||
FontSize = fontSize,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = textBrush
|
||||
};
|
||||
|
||||
if (iconImage is not null)
|
||||
{
|
||||
grid.Children.Add(iconImage);
|
||||
Grid.SetRow(iconImage, 0);
|
||||
}
|
||||
|
||||
grid.Children.Add(textBlock);
|
||||
Grid.SetRow(textBlock, 1);
|
||||
|
||||
border.Child = grid;
|
||||
|
||||
ToolTip.SetTip(border, item.Name);
|
||||
|
||||
border.PointerPressed += OnItemPointerPressed;
|
||||
border.PointerMoved += OnItemPointerMoved;
|
||||
border.PointerReleased += OnItemPointerReleased;
|
||||
|
||||
return border;
|
||||
}
|
||||
|
||||
private Control? CreateSystemIconImage(FileSystemItem item, double iconSize)
|
||||
{
|
||||
byte[]? pngBytes = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => GetDriveIconBytes(item.FullPath),
|
||||
FileSystemItemType.Directory => WindowsIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => WindowsIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => LinuxIconService.TryGetDriveIconPngBytes(),
|
||||
FileSystemItemType.Directory => LinuxIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => LinuxIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => MacIconService.TryGetDriveIconPngBytes(),
|
||||
FileSystemItemType.Directory => MacIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => MacIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
pngBytes = null;
|
||||
}
|
||||
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(pngBytes);
|
||||
var bitmap = new Bitmap(stream);
|
||||
return new Image
|
||||
{
|
||||
Source = bitmap,
|
||||
Width = iconSize,
|
||||
Height = iconSize,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
Stretch = Stretch.Uniform
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return CreateFallbackIconImage(item, iconSize);
|
||||
}
|
||||
|
||||
private static byte[]? GetDriveIconBytes(string drivePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(drivePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (Directory.Exists(drivePath))
|
||||
{
|
||||
return WindowsIconService.TryGetIconPngBytes(drivePath);
|
||||
}
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return LinuxIconService.TryGetDriveIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return MacIconService.TryGetDriveIconPngBytes();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return WindowsIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return LinuxIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return MacIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Control CreateFallbackIconImage(FileSystemItem item, double iconSize)
|
||||
{
|
||||
var symbol = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => FluentIcons.Common.Symbol.HardDrive,
|
||||
FileSystemItemType.Directory => FluentIcons.Common.Symbol.Folder,
|
||||
_ => FluentIcons.Common.Symbol.Document
|
||||
};
|
||||
|
||||
var iconBrush = item.ItemType == FileSystemItemType.File
|
||||
? this.FindResource("AdaptiveTextSecondaryBrush") as IBrush ?? new SolidColorBrush(Colors.Gray)
|
||||
: this.FindResource("AdaptiveAccentBrush") as IBrush ?? new SolidColorBrush(Colors.DodgerBlue);
|
||||
|
||||
return new SymbolIcon
|
||||
{
|
||||
Symbol = symbol,
|
||||
FontSize = iconSize,
|
||||
Foreground = iconBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
}
|
||||
|
||||
private void RefreshCurrentDirectory()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_currentPath))
|
||||
{
|
||||
NavigateToDrives();
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadDirectory(_currentPath, addToHistory: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEmptyState(bool isEmpty, string message)
|
||||
{
|
||||
EmptyStatePanel.IsVisible = isEmpty;
|
||||
EmptyStateTextBlock.Text = message;
|
||||
FileItemsControl.IsVisible = !isEmpty;
|
||||
}
|
||||
|
||||
private void ShowError(string message)
|
||||
{
|
||||
ErrorStatePanel.IsVisible = true;
|
||||
ErrorStateTextBlock.Text = message;
|
||||
FileItemsControl.IsVisible = false;
|
||||
EmptyStatePanel.IsVisible = false;
|
||||
}
|
||||
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(filePath)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
Process.Start("xdg-open", filePath);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
Process.Start("open", filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to open file: {filePath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatPathForDisplay(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "此电脑" : "文件系统";
|
||||
}
|
||||
|
||||
var separator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/';
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
if (path.Length <= 3 && path.EndsWith(":\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var driveInfo = new DriveInfo(path.Substring(0, 1));
|
||||
if (!string.IsNullOrWhiteSpace(driveInfo.VolumeLabel))
|
||||
{
|
||||
return $"{driveInfo.VolumeLabel} ({path.Substring(0, 2)})";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (path == "/")
|
||||
{
|
||||
return "根目录";
|
||||
}
|
||||
|
||||
if (path == Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
||||
{
|
||||
return "主目录";
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
if (path == "/Applications")
|
||||
{
|
||||
return "应用程序";
|
||||
}
|
||||
|
||||
if (path == "/Users")
|
||||
{
|
||||
return "用户";
|
||||
}
|
||||
|
||||
if (path.StartsWith("/Volumes/"))
|
||||
{
|
||||
return Path.GetFileName(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parts = path.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length <= 3)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return $"{parts[0]}{separator}...{separator}{parts[^2]}{separator}{parts[^1]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
xmlns:symbol="using:FluentIcons.Common"
|
||||
x:Class="LanMountainDesktop.Views.Components.NotificationBoxWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<!-- 主卡片 -->
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="12,10">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- 头部 -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<fi:SymbolIcon x:Name="HeaderIcon" Symbol="{x:Static symbol:Symbol.MailInbox}" FontSize="16" />
|
||||
<TextBlock x:Name="HeaderTextBlock"
|
||||
Text="消息盒子"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold" />
|
||||
<Border x:Name="UnreadBadge"
|
||||
Background="#E24B2D"
|
||||
CornerRadius="8"
|
||||
Padding="5,2"
|
||||
IsVisible="False">
|
||||
<TextBlock x:Name="UnreadCountText"
|
||||
Foreground="White"
|
||||
FontSize="11"
|
||||
FontWeight="Bold" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Button x:Name="ClearButton"
|
||||
Grid.Column="1"
|
||||
IsVisible="False"
|
||||
Click="OnClearButtonClick"
|
||||
Padding="6"
|
||||
Background="Transparent"
|
||||
BorderThickness="0">
|
||||
<fi:SymbolIcon Symbol="{x:Static symbol:Symbol.Delete}" FontSize="14" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<ScrollViewer Grid.Row="1"
|
||||
Margin="0,8,0,0"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="NotificationListPanel" Spacing="6" />
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<TextBlock x:Name="EmptyStateText"
|
||||
Grid.Row="1"
|
||||
Text="暂无通知"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="#8B95A5"
|
||||
FontSize="13"
|
||||
IsVisible="False" />
|
||||
|
||||
<!-- 隐私模式遮罩 -->
|
||||
<Border x:Name="PrivacyOverlay"
|
||||
Grid.Row="1"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
IsVisible="False">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="6">
|
||||
<fi:SymbolIcon Symbol="{x:Static symbol:Symbol.EyeOff}" FontSize="24" Foreground="#8B95A5" />
|
||||
<TextBlock Text="您有新的通知"
|
||||
Foreground="#8B95A5"
|
||||
FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 底部状态 -->
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Grid.Row="2"
|
||||
FontSize="11"
|
||||
Foreground="#8B95A5"
|
||||
Margin="0,6,0,0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,529 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class NotificationBoxWidget : UserControl,
|
||||
IDesktopComponentWidget,
|
||||
IComponentSettingsContextAware,
|
||||
IComponentRuntimeContextAware
|
||||
{
|
||||
private readonly List<NotificationItemControl> _notificationControls = [];
|
||||
private NotificationListenerService? _notificationService;
|
||||
private IComponentInstanceSettingsStore _componentSettings = null!;
|
||||
private ISettingsService _appSettingsService = null!;
|
||||
private AppSettingsSnapshot _appSettings = new();
|
||||
private ComponentSettingsSnapshot _componentSettingsSnapshot = new();
|
||||
private bool _isAttached;
|
||||
private bool _isPrivacyMode;
|
||||
private bool _isNightVisual;
|
||||
private double _currentCellSize = 48d;
|
||||
|
||||
public NotificationBoxWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
PointerPressed += (_, e) =>
|
||||
{
|
||||
if (e.Source == NotificationListPanel)
|
||||
{
|
||||
ClearSelection();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
|
||||
{
|
||||
_componentSettings = context.ComponentSettingsStore;
|
||||
LoadSettings();
|
||||
RefreshUI();
|
||||
}
|
||||
|
||||
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
|
||||
{
|
||||
_notificationService = NotificationListenerServiceProvider.GetOrCreate(_appSettingsService);
|
||||
if (_notificationService != null)
|
||||
{
|
||||
_notificationService.NotificationReceived += OnNotificationReceived;
|
||||
_notificationService.NotificationRemoved += OnNotificationRemoved;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
_isNightVisual = ResolveNightMode();
|
||||
LoadSettings();
|
||||
RefreshUI();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
|
||||
if (_notificationService != null)
|
||||
{
|
||||
_notificationService.NotificationReceived -= OnNotificationReceived;
|
||||
_notificationService.NotificationRemoved -= OnNotificationRemoved;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_isNightVisual = ResolveNightMode();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private bool ResolveNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush brush)
|
||||
{
|
||||
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double CalculateRelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
var r = ToLinear(color.R / 255d);
|
||||
var g = ToLinear(color.G / 255d);
|
||||
var b = ToLinear(color.B / 255d);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var appSettingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_appSettingsService = appSettingsFacade.Settings;
|
||||
_appSettings = _appSettingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
_isPrivacyMode = _appSettings.NotificationBoxPrivacyMode;
|
||||
|
||||
_componentSettingsSnapshot = _componentSettings?.Load()
|
||||
?? new ComponentSettingsSnapshot();
|
||||
}
|
||||
|
||||
private void RefreshUI()
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
|
||||
var hasNotifications = _notificationService?.GetNotifications().Count > 0;
|
||||
PrivacyOverlay.IsVisible = _isPrivacyMode && hasNotifications && _notificationService?.GetUnreadCount() > 0;
|
||||
NotificationListPanel.IsVisible = !PrivacyOverlay.IsVisible;
|
||||
|
||||
var unreadCount = _notificationService?.GetUnreadCount() ?? 0;
|
||||
UnreadBadge.IsVisible = unreadCount > 0;
|
||||
UnreadCountText.Text = unreadCount.ToString();
|
||||
|
||||
ClearButton.IsVisible = _componentSettingsSnapshot.NotificationBoxShowClearButton
|
||||
&& hasNotifications;
|
||||
|
||||
UpdateStatusText();
|
||||
RenderNotifications();
|
||||
}
|
||||
|
||||
private void RenderNotifications()
|
||||
{
|
||||
NotificationListPanel.Children.Clear();
|
||||
_notificationControls.Clear();
|
||||
|
||||
if (_notificationService == null)
|
||||
{
|
||||
EmptyStateText.IsVisible = true;
|
||||
EmptyStateText.Text = "通知服务未启动";
|
||||
return;
|
||||
}
|
||||
|
||||
var notifications = _notificationService.GetNotifications();
|
||||
|
||||
if (notifications.Count == 0)
|
||||
{
|
||||
EmptyStateText.IsVisible = true;
|
||||
EmptyStateText.Text = "暂无通知";
|
||||
return;
|
||||
}
|
||||
|
||||
EmptyStateText.IsVisible = false;
|
||||
|
||||
notifications = ApplySorting(notifications);
|
||||
|
||||
var maxCount = _componentSettingsSnapshot.NotificationBoxMaxDisplayCount;
|
||||
notifications = notifications.Take(maxCount).ToList();
|
||||
|
||||
if (_componentSettingsSnapshot.NotificationBoxGroupByApp)
|
||||
{
|
||||
RenderGroupedNotifications(notifications);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderFlatNotifications(notifications);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<NotificationItem> ApplySorting(IReadOnlyList<NotificationItem> notifications)
|
||||
{
|
||||
return _componentSettingsSnapshot.NotificationBoxSortOrder switch
|
||||
{
|
||||
"TimeAsc" => notifications.OrderBy(n => n.ReceivedTime).ToList(),
|
||||
"AppGroup" => notifications.OrderBy(n => n.AppName).ThenByDescending(n => n.ReceivedTime).ToList(),
|
||||
_ => notifications.OrderByDescending(n => n.ReceivedTime).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private void RenderFlatNotifications(IReadOnlyList<NotificationItem> notifications)
|
||||
{
|
||||
foreach (var notification in notifications)
|
||||
{
|
||||
var control = CreateNotificationControl(notification);
|
||||
NotificationListPanel.Children.Add(control);
|
||||
_notificationControls.Add(control);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderGroupedNotifications(IReadOnlyList<NotificationItem> notifications)
|
||||
{
|
||||
var grouped = notifications.GroupBy(n => n.AppName).ToList();
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var groupHeader = new TextBlock
|
||||
{
|
||||
Text = group.Key,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#8B95A5")),
|
||||
Margin = new Thickness(0, 6, 0, 3)
|
||||
};
|
||||
NotificationListPanel.Children.Add(groupHeader);
|
||||
|
||||
foreach (var notification in group)
|
||||
{
|
||||
var control = CreateNotificationControl(notification);
|
||||
NotificationListPanel.Children.Add(control);
|
||||
_notificationControls.Add(control);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private NotificationItemControl CreateNotificationControl(NotificationItem notification)
|
||||
{
|
||||
var control = new NotificationItemControl(notification, _componentSettingsSnapshot, _isNightVisual);
|
||||
control.Clicked += OnNotificationClicked;
|
||||
control.MarkAsRead += OnMarkAsRead;
|
||||
return control;
|
||||
}
|
||||
|
||||
private void OnNotificationReceived(object? sender, NotificationItem notification)
|
||||
{
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
RefreshUI();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnNotificationRemoved(object? sender, string notificationId)
|
||||
{
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
RefreshUI();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnNotificationClicked(object? sender, NotificationItem notification)
|
||||
{
|
||||
}
|
||||
|
||||
private void OnMarkAsRead(object? sender, NotificationItem notification)
|
||||
{
|
||||
_notificationService?.MarkAsRead(notification.Id);
|
||||
RefreshUI();
|
||||
}
|
||||
|
||||
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_notificationService?.ClearAll();
|
||||
RefreshUI();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void UpdateStatusText()
|
||||
{
|
||||
var total = _notificationService?.GetNotifications().Count ?? 0;
|
||||
var max = _componentSettingsSnapshot.NotificationBoxMaxDisplayCount;
|
||||
StatusTextBlock.Text = $"共 {total} 条" + (total > max ? $"(显{max})" : "");
|
||||
}
|
||||
|
||||
private void ClearSelection()
|
||||
{
|
||||
foreach (var control in _notificationControls)
|
||||
{
|
||||
control.IsSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = Math.Clamp(_currentCellSize / 48.0, 0.7, 1.8);
|
||||
var fontScale = Math.Clamp(scale, 0.8, 1.4);
|
||||
|
||||
var cornerRadius = ResolveUnifiedMainRadiusValue();
|
||||
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
|
||||
CardBorder.CornerRadius = new CornerRadius(cornerRadius);
|
||||
|
||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
|
||||
|
||||
HeaderTextBlock.FontSize = 15 * fontScale;
|
||||
HeaderIcon.FontSize = 16 * fontScale;
|
||||
UnreadCountText.FontSize = 11 * fontScale;
|
||||
EmptyStateText.FontSize = 13 * fontScale;
|
||||
StatusTextBlock.FontSize = 11 * fontScale;
|
||||
|
||||
var padding = Math.Clamp(12 * scale, 8, 20);
|
||||
var verticalPadding = Math.Clamp(10 * scale, 6, 16);
|
||||
CardBorder.Padding = new Thickness(padding, verticalPadding);
|
||||
|
||||
foreach (var control in _notificationControls)
|
||||
{
|
||||
control.UpdateTheme(_isNightVisual, fontScale);
|
||||
}
|
||||
}
|
||||
|
||||
private static double ResolveUnifiedMainRadiusValue() =>
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||
}
|
||||
|
||||
public class NotificationItemControl : Border
|
||||
{
|
||||
private readonly NotificationItem _item;
|
||||
private readonly ComponentSettingsSnapshot _settings;
|
||||
private bool _isPointerPressed;
|
||||
private Point _pointerPressedPosition;
|
||||
private bool _isNightVisual;
|
||||
|
||||
public NotificationItemControl(NotificationItem item, ComponentSettingsSnapshot settings, bool isNightVisual)
|
||||
{
|
||||
_item = item;
|
||||
_settings = settings;
|
||||
_isNightVisual = isNightVisual;
|
||||
|
||||
Background = _item.IsRead
|
||||
? new SolidColorBrush(isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5"))
|
||||
: new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF"));
|
||||
CornerRadius = new CornerRadius(6);
|
||||
Padding = new Thickness(10, 6);
|
||||
Cursor = new Cursor(StandardCursorType.Hand);
|
||||
BorderBrush = _item.IsRead
|
||||
? new SolidColorBrush(Colors.Transparent)
|
||||
: new SolidColorBrush(Color.Parse("#E24B2D"));
|
||||
BorderThickness = _item.IsRead ? new Thickness(0) : new Thickness(2, 0, 0, 0);
|
||||
|
||||
BuildUI();
|
||||
|
||||
PointerPressed += OnPointerPressed;
|
||||
PointerReleased += OnPointerReleased;
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var grid = new Grid { ColumnDefinitions = ColumnDefinitions.Parse("Auto,*,Auto") };
|
||||
|
||||
if (_settings.NotificationBoxShowAppIcon)
|
||||
{
|
||||
var iconBorder = new Border
|
||||
{
|
||||
Width = 28,
|
||||
Height = 28,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#4D5560") : Color.Parse("#E8EAED")),
|
||||
Margin = new Thickness(0, 0, 8, 0)
|
||||
};
|
||||
var iconText = new TextBlock
|
||||
{
|
||||
Text = _item.AppName.Length > 0 ? _item.AppName[0].ToString() : "?",
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"))
|
||||
};
|
||||
iconBorder.Child = iconText;
|
||||
grid.Children.Add(iconBorder);
|
||||
}
|
||||
|
||||
var contentPanel = new StackPanel { Spacing = 1 };
|
||||
Grid.SetColumn(contentPanel, 1);
|
||||
|
||||
var titleBlock = new TextBlock
|
||||
{
|
||||
Text = _item.Title,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
FontSize = 12,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 1,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"))
|
||||
};
|
||||
|
||||
var contentBlock = new TextBlock
|
||||
{
|
||||
Text = _item.Content,
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671")),
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
contentPanel.Children.Add(titleBlock);
|
||||
if (!string.IsNullOrWhiteSpace(_item.Content))
|
||||
{
|
||||
contentPanel.Children.Add(contentBlock);
|
||||
}
|
||||
grid.Children.Add(contentPanel);
|
||||
|
||||
if (_settings.NotificationBoxShowTimestamp)
|
||||
{
|
||||
var timeText = _settings.NotificationBoxTimeFormat == "Relative"
|
||||
? GetRelativeTime(_item.ReceivedTime)
|
||||
: _item.ReceivedTime.ToString("HH:mm");
|
||||
|
||||
var timeBlock = new TextBlock
|
||||
{
|
||||
Text = timeText,
|
||||
FontSize = 10,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#8B95A5")),
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
|
||||
Margin = new Thickness(6, 0, 0, 0)
|
||||
};
|
||||
Grid.SetColumn(timeBlock, 2);
|
||||
grid.Children.Add(timeBlock);
|
||||
}
|
||||
|
||||
Child = grid;
|
||||
}
|
||||
|
||||
public void UpdateTheme(bool isNightVisual, double fontScale)
|
||||
{
|
||||
_isNightVisual = isNightVisual;
|
||||
Background = _item.IsRead
|
||||
? new SolidColorBrush(isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5"))
|
||||
: new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF"));
|
||||
|
||||
if (Child is Grid grid)
|
||||
{
|
||||
foreach (var child in grid.Children)
|
||||
{
|
||||
if (child is StackPanel panel)
|
||||
{
|
||||
foreach (var textBlock in panel.Children.OfType<TextBlock>())
|
||||
{
|
||||
textBlock.FontSize *= fontScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
_isPointerPressed = true;
|
||||
_pointerPressedPosition = e.GetPosition(this);
|
||||
IsSelected = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (!_isPointerPressed) return;
|
||||
|
||||
_isPointerPressed = false;
|
||||
var releasePosition = e.GetPosition(this);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(releasePosition.X - _pointerPressedPosition.X, 2) +
|
||||
Math.Pow(releasePosition.Y - _pointerPressedPosition.Y, 2));
|
||||
|
||||
if (distance < 5)
|
||||
{
|
||||
Clicked?.Invoke(this, _item);
|
||||
MarkAsRead?.Invoke(this, _item);
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static string GetRelativeTime(DateTime time)
|
||||
{
|
||||
var diff = DateTime.Now - time;
|
||||
|
||||
if (diff.TotalMinutes < 1) return "刚刚";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}分前";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}小时前";
|
||||
return $"{(int)diff.TotalDays}天前";
|
||||
}
|
||||
|
||||
public bool IsSelected { get; set; }
|
||||
|
||||
public event EventHandler<NotificationItem>? Clicked;
|
||||
public event EventHandler<NotificationItem>? MarkAsRead;
|
||||
}
|
||||
|
||||
public static class NotificationListenerServiceProvider
|
||||
{
|
||||
private static NotificationListenerService? _instance;
|
||||
|
||||
public static NotificationListenerService GetOrCreate(ISettingsService settingsService)
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new NotificationListenerService(settingsService);
|
||||
_instance.InitializeAsync().ConfigureAwait(false);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,10 @@ public partial class MainWindow
|
||||
Color PressedColor,
|
||||
Color DividerColor);
|
||||
|
||||
private readonly IPowerManagementService _powerService = PowerManagementServiceFactory.GetOrCreate();
|
||||
private bool _isPowerMenuOpen;
|
||||
private bool _isPowerMenuAnimating;
|
||||
|
||||
private void InitializeTaskbarProfileFlyout()
|
||||
{
|
||||
if (TaskbarProfileButton is null || TaskbarProfilePopup is null)
|
||||
@@ -98,6 +102,16 @@ public partial class MainWindow
|
||||
TaskbarProfileDisplayNameTextBlock.Text = profile.DisplayName;
|
||||
TaskbarProfileSettingsActionTextBlock.Text = L("tooltip.open_settings", "Settings");
|
||||
TaskbarProfileDesktopEditActionTextBlock.Text = L("button.component_library", "Edit Desktop");
|
||||
TaskbarProfilePowerActionTextBlock.Text = L("power.menu", "Power");
|
||||
TaskbarPowerTitleTextBlock.Text = L("power.title", "Power");
|
||||
TaskbarPowerBackTextBlock.Text = L("power.back", "Back");
|
||||
PowerShutdownTextBlock.Text = L("power.shutdown", "Shutdown");
|
||||
PowerRestartTextBlock.Text = L("power.restart", "Restart");
|
||||
PowerLogoutTextBlock.Text = L("power.logout", "Log Out");
|
||||
PowerSleepTextBlock.Text = L("power.sleep", "Sleep");
|
||||
PowerLockTextBlock.Text = L("power.lock_screen", "Lock Screen");
|
||||
|
||||
UpdatePowerMenuVisibility();
|
||||
ApplyTaskbarProfilePopupTheme(_appearanceThemeService.GetCurrent());
|
||||
|
||||
ToolTip.SetTip(TaskbarProfileButton, profile.DisplayName);
|
||||
@@ -216,6 +230,7 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
ResetPowerMenuState();
|
||||
RefreshTaskbarProfilePresentation();
|
||||
TaskbarProfilePopup.IsOpen = true;
|
||||
}
|
||||
@@ -279,6 +294,202 @@ public partial class MainWindow
|
||||
app?.OpenIndependentSettingsModule("MainWindowTaskbar");
|
||||
}
|
||||
|
||||
private void OnPowerMenuEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
EnterPowerMenu();
|
||||
}
|
||||
|
||||
private void OnPowerMenuBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ExitPowerMenu();
|
||||
}
|
||||
|
||||
private void ResetPowerMenuState()
|
||||
{
|
||||
_isPowerMenuOpen = false;
|
||||
_isPowerMenuAnimating = false;
|
||||
|
||||
if (TaskbarProfileMainPanel is not null)
|
||||
{
|
||||
TaskbarProfileMainPanel.IsVisible = true;
|
||||
TaskbarProfileMainPanel.Opacity = 1d;
|
||||
}
|
||||
|
||||
if (TaskbarProfilePowerPanel is not null)
|
||||
{
|
||||
TaskbarProfilePowerPanel.IsVisible = false;
|
||||
TaskbarProfilePowerPanel.Opacity = 0d;
|
||||
var transform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
|
||||
if (transform is not null) transform.X = 340d;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePowerMenuVisibility()
|
||||
{
|
||||
var supported = _powerService.IsShutdownSupported ||
|
||||
_powerService.IsRestartSupported ||
|
||||
_powerService.IsLogoutSupported ||
|
||||
_powerService.IsSleepSupported ||
|
||||
_powerService.IsLockSupported;
|
||||
|
||||
if (TaskbarProfilePowerActionButton is not null)
|
||||
{
|
||||
TaskbarProfilePowerActionButton.IsVisible = supported;
|
||||
}
|
||||
}
|
||||
|
||||
private async void EnterPowerMenu()
|
||||
{
|
||||
if (_isPowerMenuAnimating || _isPowerMenuOpen || TaskbarProfileMainPanel is null || TaskbarProfilePowerPanel is null)
|
||||
return;
|
||||
|
||||
_isPowerMenuAnimating = true;
|
||||
|
||||
TaskbarProfilePowerPanel.IsVisible = true;
|
||||
TaskbarProfilePowerPanel.Opacity = 0d;
|
||||
var powerTransform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
|
||||
if (powerTransform is not null) powerTransform.X = 340d;
|
||||
|
||||
await Task.Delay(16);
|
||||
|
||||
TaskbarProfileMainPanel.Opacity = 0d;
|
||||
TaskbarProfilePowerPanel.Opacity = 1d;
|
||||
if (powerTransform is not null) powerTransform.X = 0d;
|
||||
|
||||
await Task.Delay(280);
|
||||
|
||||
TaskbarProfileMainPanel.IsVisible = false;
|
||||
_isPowerMenuOpen = true;
|
||||
_isPowerMenuAnimating = false;
|
||||
}
|
||||
|
||||
private async void ExitPowerMenu()
|
||||
{
|
||||
if (_isPowerMenuAnimating || !_isPowerMenuOpen || TaskbarProfileMainPanel is null || TaskbarProfilePowerPanel is null)
|
||||
return;
|
||||
|
||||
_isPowerMenuAnimating = true;
|
||||
|
||||
TaskbarProfileMainPanel.IsVisible = true;
|
||||
TaskbarProfileMainPanel.Opacity = 0d;
|
||||
var powerTransform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
|
||||
if (powerTransform is not null) powerTransform.X = 0d;
|
||||
|
||||
await Task.Delay(16);
|
||||
|
||||
TaskbarProfileMainPanel.Opacity = 1d;
|
||||
TaskbarProfilePowerPanel.Opacity = 0d;
|
||||
if (powerTransform is not null) powerTransform.X = 340d;
|
||||
|
||||
await Task.Delay(280);
|
||||
|
||||
TaskbarProfilePowerPanel.IsVisible = false;
|
||||
_isPowerMenuOpen = false;
|
||||
_isPowerMenuAnimating = false;
|
||||
}
|
||||
|
||||
private async void OnPowerShutdownClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_powerService.ShowNativePowerUI(PowerAction.Shutdown);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowPowerConfirmDialogAsync(L("power.shutdown_confirm_title", "Shutdown"),
|
||||
L("power.shutdown_confirm_message", "Are you sure you want to shut down this computer?"),
|
||||
() => _powerService.ShutdownAsync());
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnPowerRestartClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_powerService.ShowNativePowerUI(PowerAction.Restart);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowPowerConfirmDialogAsync(L("power.restart_confirm_title", "Restart"),
|
||||
L("power.restart_confirm_message", "Are you sure you want to restart this computer?"),
|
||||
() => _powerService.RestartAsync());
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnPowerLogoutClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
await ShowPowerConfirmDialogAsync(L("power.logout_confirm_title", "Log Out"),
|
||||
L("power.logout_confirm_message", "Are you sure you want to log out?"),
|
||||
() => _powerService.LogoutAsync());
|
||||
}
|
||||
|
||||
private async void OnPowerSleepClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
await ShowPowerConfirmDialogAsync(L("power.sleep_confirm_title", "Sleep"),
|
||||
L("power.sleep_confirm_message", "Are you sure you want to put the computer to sleep?"),
|
||||
() => _powerService.SleepAsync());
|
||||
}
|
||||
|
||||
private async void OnPowerLockClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
await _powerService.LockAsync();
|
||||
}
|
||||
|
||||
private async Task ShowPowerConfirmDialogAsync(string title, string message, Func<Task> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = title,
|
||||
Content = message,
|
||||
PrimaryButtonText = L("power.confirm_yes", "Yes"),
|
||||
SecondaryButtonText = L("power.confirm_cancel", "Cancel")
|
||||
};
|
||||
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
await action();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Error("PowerMenu", $"Dialog error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ClosePopupIfOpen()
|
||||
{
|
||||
if (TaskbarProfilePopup is not null && TaskbarProfilePopup.IsOpen)
|
||||
{
|
||||
TaskbarProfilePopup.IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCloseComponentLibraryClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_componentLibraryWindowService.Close(this);
|
||||
|
||||
@@ -398,69 +398,215 @@
|
||||
<Border x:Name="TaskbarProfilePopupPanel"
|
||||
Classes="taskbar-profile-popup-panel"
|
||||
Margin="0,0,0,10">
|
||||
<StackPanel Width="280"
|
||||
Spacing="12">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<Border x:Name="TaskbarProfileHeaderAvatarBorder"
|
||||
Classes="taskbar-profile-popup-avatar"
|
||||
Width="44"
|
||||
Height="44"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image x:Name="TaskbarProfileHeaderAvatarImage"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="False" />
|
||||
<TextBlock x:Name="TaskbarProfileHeaderAvatarFallbackText"
|
||||
Classes="taskbar-profile-popup-primary"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="U" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Width="340">
|
||||
<Grid x:Name="TaskbarProfileMainPanel"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
<StackPanel Spacing="12">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<Border x:Name="TaskbarProfileHeaderAvatarBorder"
|
||||
Classes="taskbar-profile-popup-avatar"
|
||||
Width="44"
|
||||
Height="44"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image x:Name="TaskbarProfileHeaderAvatarImage"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="False" />
|
||||
<TextBlock x:Name="TaskbarProfileHeaderAvatarFallbackText"
|
||||
Classes="taskbar-profile-popup-primary"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="U" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="TaskbarProfileDisplayNameTextBlock"
|
||||
Classes="taskbar-profile-popup-title"
|
||||
Text="User" />
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="TaskbarProfileDisplayNameTextBlock"
|
||||
Classes="taskbar-profile-popup-title"
|
||||
Text="User" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="TaskbarProfilePopupDivider"
|
||||
Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
||||
|
||||
<Button x:Name="TaskbarProfileSettingsActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenSettingsClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Settings" />
|
||||
<TextBlock x:Name="TaskbarProfileSettingsActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Settings" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="TaskbarProfileDesktopEditActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenComponentLibraryClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Pencil" />
|
||||
<TextBlock x:Name="TaskbarProfileDesktopEditActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Edit Desktop" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="TaskbarProfilePowerActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerMenuEnterClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Power" />
|
||||
<TextBlock x:Name="TaskbarProfilePowerActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Power" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="TaskbarProfilePopupDivider"
|
||||
Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
||||
<Grid x:Name="TaskbarProfilePowerPanel"
|
||||
IsVisible="False"
|
||||
Opacity="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X"
|
||||
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
|
||||
Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
</Grid.RenderTransform>
|
||||
<StackPanel Spacing="8">
|
||||
<Button x:Name="TaskbarPowerBackButton"
|
||||
Padding="4,6"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
HorizontalAlignment="Left"
|
||||
Click="OnPowerMenuBackClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Classes="icon-s"
|
||||
Symbol="ArrowLeft"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock x:Name="TaskbarPowerBackTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Text="Back" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="TaskbarProfileSettingsActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenSettingsClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Settings" />
|
||||
<TextBlock x:Name="TaskbarProfileSettingsActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Settings" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<TextBlock x:Name="TaskbarPowerTitleTextBlock"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TaskbarProfilePopupTextBrush}"
|
||||
Margin="2,6,0,0"
|
||||
Text="Power" />
|
||||
|
||||
<Button x:Name="TaskbarProfileDesktopEditActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenComponentLibraryClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Pencil" />
|
||||
<TextBlock x:Name="TaskbarProfileDesktopEditActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Edit Desktop" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<Border Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}"
|
||||
Margin="0,4" />
|
||||
|
||||
<Button x:Name="PowerShutdownButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerShutdownClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Power" />
|
||||
<TextBlock x:Name="PowerShutdownTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Shutdown" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerRestartButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerRestartClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Refresh" />
|
||||
<TextBlock x:Name="PowerRestartTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Restart" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerLogoutButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerLogoutClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="ExitToApp" />
|
||||
<TextBlock x:Name="PowerLogoutTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Log Out" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerSleepButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerSleepClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="WeatherNight" />
|
||||
<TextBlock x:Name="PowerSleepTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Sleep" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerLockButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerLockClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Lock" />
|
||||
<TextBlock x:Name="PowerLockTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Lock Screen" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
|
||||
@@ -258,18 +258,43 @@ public partial class TransparentOverlayWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
var control = descriptor.CreateControl(
|
||||
_currentDesktopCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
// 【修复问题3】尝试从现有窗口中获取组件实例,避免重新创建导致状态丢失
|
||||
var control = TryGetExistingControl(placement.PlacementId);
|
||||
if (control is null)
|
||||
{
|
||||
// 如果没有现有实例,才创建新的
|
||||
control = descriptor.CreateControl(
|
||||
_currentDesktopCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
}
|
||||
|
||||
RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题3】尝试从现有的小窗口中获取组件控件实例
|
||||
/// </summary>
|
||||
private Control? TryGetExistingControl(string placementId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manager = FusedDesktopManagerServiceFactory.GetOrCreate();
|
||||
// 通过反射或公共 API 获取现有窗口中的控件
|
||||
// 这里需要 FusedDesktopManagerService 提供获取控件的方法
|
||||
// 暂时返回 null,后续需要扩展接口
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除组件
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user