mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-29 22:24:26 +08:00
Compare commits
4 Commits
5d2449fa8f
...
v0.8.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1d5a0c6de | ||
|
|
5fa2031ad6 | ||
|
|
0662565dca | ||
|
|
12a2f6729b |
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>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||||
<RepositoryType>git</RepositoryType>
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||||
|
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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>
|
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||||
<RepositoryType>git</RepositoryType>
|
<RepositoryType>git</RepositoryType>
|
||||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||||
|
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||||
|
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
|
|||||||
@@ -44,4 +44,6 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||||
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||||
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
|
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,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true,
|
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)
|
ResizeMode: DesktopComponentResizeMode.Free)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
|
<PackageReference Include="Tmds.DBus.Protocol" Version="0.22.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
|
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
|
||||||
|
|||||||
@@ -1087,5 +1087,23 @@
|
|||||||
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
|
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
|
||||||
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
|
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
|
||||||
"zhijiaohub.settings.about": "About",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1081,5 +1081,23 @@
|
|||||||
"zhijiaohub.settings.auto_refresh_desc": "定期自动刷新图片列表。",
|
"zhijiaohub.settings.auto_refresh_desc": "定期自动刷新图片列表。",
|
||||||
"zhijiaohub.settings.interval": "刷新间隔(分钟)",
|
"zhijiaohub.settings.interval": "刷新间隔(分钟)",
|
||||||
"zhijiaohub.settings.about": "关于",
|
"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
|
#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()
|
public AppSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
||||||
@@ -213,6 +242,9 @@ public sealed class AppSettingsSnapshot
|
|||||||
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
|
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
|
||||||
? new List<string>(DisabledPluginIds)
|
? new List<string>(DisabledPluginIds)
|
||||||
: [];
|
: [];
|
||||||
|
clone.NotificationBoxBlockedApps = NotificationBoxBlockedApps is { Count: > 0 }
|
||||||
|
? new List<string>(NotificationBoxBlockedApps)
|
||||||
|
: [];
|
||||||
|
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,45 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
|
|
||||||
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
|
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()
|
public ComponentSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||||
|
|||||||
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,
|
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||||
context => new ZhiJiaoHubComponentEditor(context),
|
context => new ZhiJiaoHubComponentEditor(context),
|
||||||
preferredWidth: 480d,
|
preferredWidth: 480d,
|
||||||
|
preferredHeight: 520d),
|
||||||
|
[BuiltInComponentIds.DesktopNotificationBox] = new(
|
||||||
|
BuiltInComponentIds.DesktopNotificationBox,
|
||||||
|
context => new NotificationBoxComponentEditor(context),
|
||||||
|
preferredWidth: 480d,
|
||||||
preferredHeight: 520d)
|
preferredHeight: 520d)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
|||||||
if (_isEditMode) return;
|
if (_isEditMode) return;
|
||||||
_isEditMode = true;
|
_isEditMode = true;
|
||||||
|
|
||||||
// 隐藏所有底层小窗口
|
// 【修复问题3】不再隐藏窗口,而是将窗口内容转移到编辑模式覆盖层
|
||||||
|
// 这样可以保持组件的运行状态(动画、输入等)
|
||||||
foreach (var window in _widgetWindows.Values)
|
foreach (var window in _widgetWindows.Values)
|
||||||
{
|
{
|
||||||
window.Hide();
|
window.Hide();
|
||||||
|
|||||||
@@ -1,214 +1,265 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Diagnostics;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Runtime.Versioning;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services;
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
internal static class LinuxIconService
|
internal static class LinuxIconService
|
||||||
{
|
{
|
||||||
private static readonly string[] SupportedRasterExtensions =
|
private static readonly string[] IconThemePaths = {
|
||||||
[
|
"/usr/share/icons",
|
||||||
".png",
|
"/usr/share/pixmaps",
|
||||||
".ico"
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local/share/icons"),
|
||||||
];
|
"/var/lib/snapd/desktop/icons"
|
||||||
|
};
|
||||||
|
|
||||||
private static readonly Regex SizeDirectoryRegex =
|
private static readonly string[] IconSizes = { "512x512", "256x256", "128x128", "96x96", "64x64", "48x48", "32x32", "24x24", "16x16" };
|
||||||
new(@"(?<size>\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
|
||||||
|
|
||||||
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;
|
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;
|
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);
|
var iconBytes = TryGetThemeIcon(iconName);
|
||||||
if (Path.IsPathRooted(directPath))
|
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);
|
return null;
|
||||||
if (!string.IsNullOrWhiteSpace(resolvedThemePath))
|
}
|
||||||
|
|
||||||
|
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)
|
foreach (var themePath in IconThemePaths)
|
||||||
{
|
|
||||||
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 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
|
foreach (var sizeDir in IconSizes)
|
||||||
.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))
|
|
||||||
{
|
{
|
||||||
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);
|
var directPath = Path.Combine(themePath, $"{iconName}.png");
|
||||||
return bytes.Length > 0;
|
if (File.Exists(directPath))
|
||||||
|
{
|
||||||
|
return File.ReadAllBytes(directPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int ScoreIconPath(string filePath)
|
private static byte[]? TryGetIconFromGtkTheme(string iconName)
|
||||||
{
|
{
|
||||||
var score = 0;
|
try
|
||||||
var extension = Path.GetExtension(filePath);
|
|
||||||
if (extension.Equals(".png", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
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) { }
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
private const uint SWP_NOACTIVATE = 0x0010;
|
private const uint SWP_NOACTIVATE = 0x0010;
|
||||||
private const int WM_WINDOWPOSCHANGING = 0x0046;
|
private const int WM_WINDOWPOSCHANGING = 0x0046;
|
||||||
private const int WM_NCHITTEST = 0x0084;
|
private const int WM_NCHITTEST = 0x0084;
|
||||||
|
private const int WM_ACTIVATEAPP = 0x001C; // 【新增】应用激活消息
|
||||||
private const int HTTRANSPARENT = -1;
|
private const int HTTRANSPARENT = -1;
|
||||||
private const int HTCLIENT = 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 Dictionary<IntPtr, Point> _windowScreenOrigins = new();
|
||||||
private static readonly object _staticLock = 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 bool IsBottomMostSupported => true;
|
||||||
|
|
||||||
public void SetupBottomMost(Window window)
|
public void SetupBottomMost(Window window)
|
||||||
@@ -130,6 +145,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
_bottomMostWindows[handle] = true;
|
_bottomMostWindows[handle] = true;
|
||||||
_interactiveRegions[handle] = [];
|
_interactiveRegions[handle] = [];
|
||||||
UpdateWindowScreenOrigin(handle);
|
UpdateWindowScreenOrigin(handle);
|
||||||
|
UpdateWindowDpiScale(handle); // 【修复问题2】初始化 DPI 缩放
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注入消息钩子
|
// 注入消息钩子
|
||||||
@@ -138,6 +154,9 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
// 初始置底
|
// 初始置底
|
||||||
SendToBottomInternal(handle);
|
SendToBottomInternal(handle);
|
||||||
|
|
||||||
|
// 【新增】启动定时器定期强制置底
|
||||||
|
StartKeepBottomTimer();
|
||||||
|
|
||||||
AppLogger.Info("WindowBottomMost", $"Window setup as bottom-most: {handle}");
|
AppLogger.Info("WindowBottomMost", $"Window setup as bottom-most: {handle}");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,6 +171,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
_originalWndProcs.Remove(handle);
|
_originalWndProcs.Remove(handle);
|
||||||
_interactiveRegions.Remove(handle);
|
_interactiveRegions.Remove(handle);
|
||||||
_windowScreenOrigins.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);
|
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)
|
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();
|
var windowHandles = new ArrayList();
|
||||||
EnumWindows(EnumWindowsCallback, windowHandles);
|
EnumWindows(EnumWindowsCallback, windowHandles);
|
||||||
|
|
||||||
foreach (IntPtr h in 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)
|
if (hDefView != IntPtr.Zero)
|
||||||
{
|
{
|
||||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
||||||
|
AppLogger.Info("WindowBottomMost", "Mounted to traditional desktop layer");
|
||||||
break;
|
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)
|
private static bool EnumWindowsCallback(IntPtr handle, ArrayList handles)
|
||||||
{
|
{
|
||||||
handles.Add(handle);
|
handles.Add(handle);
|
||||||
@@ -203,13 +315,29 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
lock (_staticLock)
|
lock (_staticLock)
|
||||||
{
|
{
|
||||||
_originalWndProcs[handle] = originalWndProc;
|
_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)
|
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 - 保持置底
|
// 处理 WM_WINDOWPOSCHANGING - 保持置底
|
||||||
if (msg == WM_WINDOWPOSCHANGING)
|
if (msg == WM_WINDOWPOSCHANGING)
|
||||||
{
|
{
|
||||||
@@ -217,7 +345,19 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
{
|
{
|
||||||
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
|
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);
|
SendToBottomInternal(hWnd);
|
||||||
|
_lastSendToBottomTime[hWnd] = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,11 +373,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
{
|
{
|
||||||
if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0)
|
if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0)
|
||||||
{
|
{
|
||||||
// 将屏幕坐标转为窗口相对坐标(_interactiveRegions 存的是窗口内坐标)
|
// 【修复问题2】获取窗口原点和 DPI 缩放比例
|
||||||
_windowScreenOrigins.TryGetValue(hWnd, out var origin);
|
_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 clientX = screenX - origin.X;
|
||||||
var clientY = screenY - origin.Y;
|
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)
|
foreach (var region in regions)
|
||||||
{
|
{
|
||||||
@@ -255,6 +404,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用原始窗口过程
|
// 调用原始窗口过程
|
||||||
|
CallOriginal:
|
||||||
IntPtr originalWndProc;
|
IntPtr originalWndProc;
|
||||||
lock (_staticLock)
|
lock (_staticLock)
|
||||||
{
|
{
|
||||||
@@ -277,6 +427,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
_interactiveRegions[handle] = regions;
|
_interactiveRegions[handle] = regions;
|
||||||
// 同步刷新屏幕原点(DPI 缩放可能影响坐标,每次更新区域时一并刷新)
|
// 同步刷新屏幕原点(DPI 缩放可能影响坐标,每次更新区域时一并刷新)
|
||||||
UpdateWindowScreenOrigin(handle);
|
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)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
private struct RECT { public int Left, Top, Right, Bottom; }
|
private struct RECT { public int Left, Top, Right, Bottom; }
|
||||||
|
|
||||||
@@ -328,6 +504,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
|||||||
|
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam);
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Drawing2D;
|
using System.Drawing.Drawing2D;
|
||||||
using System.Drawing.Imaging;
|
using System.Drawing.Imaging;
|
||||||
@@ -696,18 +696,23 @@ internal static class WindowsIconService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var source = Image.FromHbitmap(bitmapHandle);
|
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))
|
using (var graphics = Graphics.FromImage(bitmap))
|
||||||
{
|
{
|
||||||
graphics.Clear(Color.Transparent);
|
graphics.Clear(Color.Transparent);
|
||||||
graphics.CompositingMode = CompositingMode.SourceOver;
|
graphics.CompositingMode = CompositingMode.SourceCopy;
|
||||||
graphics.CompositingQuality = CompositingQuality.HighQuality;
|
graphics.CompositingQuality = CompositingQuality.HighQuality;
|
||||||
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||||
graphics.SmoothingMode = SmoothingMode.HighQuality;
|
graphics.SmoothingMode = SmoothingMode.HighQuality;
|
||||||
graphics.PixelOffsetMode = PixelOffsetMode.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();
|
using var stream = new MemoryStream();
|
||||||
bitmap.Save(stream, ImageFormat.Png);
|
bitmap.Save(stream, ImageFormat.Png);
|
||||||
return stream.ToArray();
|
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)
|
private static bool TryInitializeCom(out bool shouldUninitialize)
|
||||||
{
|
{
|
||||||
shouldUninitialize = false;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -475,7 +475,15 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
new DesktopComponentRuntimeRegistration(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||||
"component.zhijiao_hub",
|
"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 PressedColor,
|
||||||
Color DividerColor);
|
Color DividerColor);
|
||||||
|
|
||||||
|
private readonly IPowerManagementService _powerService = PowerManagementServiceFactory.GetOrCreate();
|
||||||
|
private bool _isPowerMenuOpen;
|
||||||
|
private bool _isPowerMenuAnimating;
|
||||||
|
|
||||||
private void InitializeTaskbarProfileFlyout()
|
private void InitializeTaskbarProfileFlyout()
|
||||||
{
|
{
|
||||||
if (TaskbarProfileButton is null || TaskbarProfilePopup is null)
|
if (TaskbarProfileButton is null || TaskbarProfilePopup is null)
|
||||||
@@ -98,6 +102,16 @@ public partial class MainWindow
|
|||||||
TaskbarProfileDisplayNameTextBlock.Text = profile.DisplayName;
|
TaskbarProfileDisplayNameTextBlock.Text = profile.DisplayName;
|
||||||
TaskbarProfileSettingsActionTextBlock.Text = L("tooltip.open_settings", "Settings");
|
TaskbarProfileSettingsActionTextBlock.Text = L("tooltip.open_settings", "Settings");
|
||||||
TaskbarProfileDesktopEditActionTextBlock.Text = L("button.component_library", "Edit Desktop");
|
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());
|
ApplyTaskbarProfilePopupTheme(_appearanceThemeService.GetCurrent());
|
||||||
|
|
||||||
ToolTip.SetTip(TaskbarProfileButton, profile.DisplayName);
|
ToolTip.SetTip(TaskbarProfileButton, profile.DisplayName);
|
||||||
@@ -216,6 +230,7 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ResetPowerMenuState();
|
||||||
RefreshTaskbarProfilePresentation();
|
RefreshTaskbarProfilePresentation();
|
||||||
TaskbarProfilePopup.IsOpen = true;
|
TaskbarProfilePopup.IsOpen = true;
|
||||||
}
|
}
|
||||||
@@ -279,6 +294,202 @@ public partial class MainWindow
|
|||||||
app?.OpenIndependentSettingsModule("MainWindowTaskbar");
|
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)
|
private void OnCloseComponentLibraryClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_componentLibraryWindowService.Close(this);
|
_componentLibraryWindowService.Close(this);
|
||||||
|
|||||||
@@ -398,69 +398,215 @@
|
|||||||
<Border x:Name="TaskbarProfilePopupPanel"
|
<Border x:Name="TaskbarProfilePopupPanel"
|
||||||
Classes="taskbar-profile-popup-panel"
|
Classes="taskbar-profile-popup-panel"
|
||||||
Margin="0,0,0,10">
|
Margin="0,0,0,10">
|
||||||
<StackPanel Width="280"
|
<Grid Width="340">
|
||||||
Spacing="12">
|
<Grid x:Name="TaskbarProfileMainPanel"
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
HorizontalAlignment="Stretch"
|
||||||
ColumnSpacing="12">
|
VerticalAlignment="Stretch">
|
||||||
<Border x:Name="TaskbarProfileHeaderAvatarBorder"
|
<Grid.Transitions>
|
||||||
Classes="taskbar-profile-popup-avatar"
|
<Transitions>
|
||||||
Width="44"
|
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||||
Height="44"
|
</Transitions>
|
||||||
ClipToBounds="True">
|
</Grid.Transitions>
|
||||||
<Grid>
|
<StackPanel Spacing="12">
|
||||||
<Image x:Name="TaskbarProfileHeaderAvatarImage"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
Stretch="UniformToFill"
|
ColumnSpacing="12">
|
||||||
IsVisible="False" />
|
<Border x:Name="TaskbarProfileHeaderAvatarBorder"
|
||||||
<TextBlock x:Name="TaskbarProfileHeaderAvatarFallbackText"
|
Classes="taskbar-profile-popup-avatar"
|
||||||
Classes="taskbar-profile-popup-primary"
|
Width="44"
|
||||||
HorizontalAlignment="Center"
|
Height="44"
|
||||||
VerticalAlignment="Center"
|
ClipToBounds="True">
|
||||||
FontWeight="SemiBold"
|
<Grid>
|
||||||
Text="U" />
|
<Image x:Name="TaskbarProfileHeaderAvatarImage"
|
||||||
</Grid>
|
Stretch="UniformToFill"
|
||||||
</Border>
|
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"
|
<StackPanel Grid.Column="1"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Spacing="2">
|
Spacing="2">
|
||||||
<TextBlock x:Name="TaskbarProfileDisplayNameTextBlock"
|
<TextBlock x:Name="TaskbarProfileDisplayNameTextBlock"
|
||||||
Classes="taskbar-profile-popup-title"
|
Classes="taskbar-profile-popup-title"
|
||||||
Text="User" />
|
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>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border x:Name="TaskbarProfilePopupDivider"
|
<Grid x:Name="TaskbarProfilePowerPanel"
|
||||||
Height="1"
|
IsVisible="False"
|
||||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
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"
|
<TextBlock x:Name="TaskbarPowerTitleTextBlock"
|
||||||
Classes="taskbar-profile-popup-action"
|
FontSize="16"
|
||||||
Click="OnOpenSettingsClick">
|
FontWeight="SemiBold"
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
Foreground="{DynamicResource TaskbarProfilePopupTextBrush}"
|
||||||
ColumnSpacing="12">
|
Margin="2,6,0,0"
|
||||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
Text="Power" />
|
||||||
Kind="Settings" />
|
|
||||||
<TextBlock x:Name="TaskbarProfileSettingsActionTextBlock"
|
|
||||||
Grid.Column="1"
|
|
||||||
Classes="taskbar-profile-popup-action-text"
|
|
||||||
Text="Settings" />
|
|
||||||
</Grid>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button x:Name="TaskbarProfileDesktopEditActionButton"
|
<Border Height="1"
|
||||||
Classes="taskbar-profile-popup-action"
|
Background="{DynamicResource TaskbarProfilePopupDividerBrush}"
|
||||||
Click="OnOpenComponentLibraryClick">
|
Margin="0,4" />
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
|
||||||
ColumnSpacing="12">
|
<Button x:Name="PowerShutdownButton"
|
||||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
Classes="taskbar-profile-popup-action"
|
||||||
Kind="Pencil" />
|
Click="OnPowerShutdownClick">
|
||||||
<TextBlock x:Name="TaskbarProfileDesktopEditActionTextBlock"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
Grid.Column="1"
|
ColumnSpacing="14">
|
||||||
Classes="taskbar-profile-popup-action-text"
|
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||||
Text="Edit Desktop" />
|
Kind="Power" />
|
||||||
</Grid>
|
<TextBlock x:Name="PowerShutdownTextBlock"
|
||||||
</Button>
|
Grid.Column="1"
|
||||||
</StackPanel>
|
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>
|
</Border>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -258,18 +258,43 @@ public partial class TransparentOverlayWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var control = descriptor.CreateControl(
|
// 【修复问题3】尝试从现有窗口中获取组件实例,避免重新创建导致状态丢失
|
||||||
_currentDesktopCellSize,
|
var control = TryGetExistingControl(placement.PlacementId);
|
||||||
_timeZoneService,
|
if (control is null)
|
||||||
_weatherDataService,
|
{
|
||||||
_recommendationInfoService,
|
// 如果没有现有实例,才创建新的
|
||||||
_calculatorDataService,
|
control = descriptor.CreateControl(
|
||||||
_settingsFacade,
|
_currentDesktopCellSize,
|
||||||
placement.PlacementId);
|
_timeZoneService,
|
||||||
|
_weatherDataService,
|
||||||
|
_recommendationInfoService,
|
||||||
|
_calculatorDataService,
|
||||||
|
_settingsFacade,
|
||||||
|
placement.PlacementId);
|
||||||
|
}
|
||||||
|
|
||||||
RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height);
|
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>
|
||||||
/// 移除组件
|
/// 移除组件
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user