9 Commits

7 changed files with 155 additions and 32 deletions
+16 -5
View File
@@ -1,7 +1,7 @@
# AGENTS.md # AGENTS.md
Guidance for AI agents working in this repo. Verified against `CMakeLists.txt`, Guidance for AI agents working in this repo. Verified against `CMakeLists.txt`,
`package/metadata.json`, and the installed macro file at `package/metadata.json.in`, and the installed macro file at
`/usr/lib/x86_64-linux-gnu/cmake/DDEShell/DDEShellPackageMacros.cmake`. `/usr/lib/x86_64-linux-gnu/cmake/DDEShell/DDEShellPackageMacros.cmake`.
## What this is ## What this is
@@ -71,7 +71,7 @@ then restart `dde-shell` (it discovers applets by scanning the install dir for
├── networkmonitorapplet.h # C++ backend header ├── networkmonitorapplet.h # C++ backend header
├── networkmonitorapplet.cpp # C++ backend implementation ├── networkmonitorapplet.cpp # C++ backend implementation
├── package/ ├── package/
│ ├── metadata.json # Plugin metadata │ ├── metadata.json.in # Plugin metadata 模板(由 CMake 生成 metadata.json
│ └── networkview.qml # QML UI │ └── networkview.qml # QML UI
├── docs/ # Design docs and implementation plans ├── docs/ # Design docs and implementation plans
└── AGENTS.md # This file └── AGENTS.md # This file
@@ -112,15 +112,26 @@ Inherits from `DApplet`, provides:
|---|---|---| |---|---|---|
| `CMakeLists.txt` | `add_library(...)` target | `space.jokul.JNetApplet` | | `CMakeLists.txt` | `add_library(...)` target | `space.jokul.JNetApplet` |
| `CMakeLists.txt` | `PLUGIN_ID` | `space.jokul.JNetApplet` | | `CMakeLists.txt` | `PLUGIN_ID` | `space.jokul.JNetApplet` |
| `package/metadata.json` | `Plugin.Id` | `space.jokul.JNetApplet` | | `package/metadata.json.in` | `Plugin.Id` | `space.jokul.JNetApplet` |
`Plugin.Url` (`networkview.qml`) is a path **relative to `package/`**, not an identifier. `Plugin.Url` (`networkview.qml`) is a path **relative to `package/`**, not an identifier.
Keep it pointing at the entry QML. Keep it pointing at the entry QML.
## Required metadata fields (QML applet) ## Required metadata fields (QML applet)
`metadata.json` must contain `Plugin.Version`, `Plugin.Id`, `Plugin.Url`, and `package/metadata.json.in` is the template; CMake's `configure_file()` generates the
`Plugin.Parent`. The current file is complete — don't drop any. final `metadata.json` in the build directory at configure time. The template must
contain `Plugin.Version`, `Plugin.Id`, `Plugin.Url`, and `Plugin.Parent`. Don't drop any.
## Version management
**Single source of truth**: `CMakeLists.txt` line 3 `project(JNetApplet VERSION x.y.z)`.
- `package/metadata.json.in` uses `@PROJECT_VERSION@` placeholder, substituted at
configure time -> installed `metadata.json` always matches.
- `build-deb.sh` extracts the version directly from `CMakeLists.txt` via grep.
- **To bump the version**: edit only the `project(... VERSION ...)` line in
`CMakeLists.txt`. All downstream consumers (install, deb) pick it up automatically.
## Conventions ## Conventions
+6 -1
View File
@@ -36,5 +36,10 @@ target_link_libraries(space.jokul.JNetApplet PRIVATE
# 安装到dde-shell插件目录 # 安装到dde-shell插件目录
install(TARGETS space.jokul.JNetApplet DESTINATION /usr/lib/x86_64-linux-gnu/dde-shell) install(TARGETS space.jokul.JNetApplet DESTINATION /usr/lib/x86_64-linux-gnu/dde-shell)
install(FILES package/metadata.json DESTINATION /usr/share/dde-shell/${PLUGIN_ID})
# 由模板生成 metadata.json,版本号从 project() 取,避免多处手动维护
# 设计原因:版本号唯一源为 CMakeLists.txt 顶部的 project(... VERSION)
# configure_file 在构建目录生成最终 metadata.jsoninstall 安装生成文件
configure_file(package/metadata.json.in ${CMAKE_CURRENT_BINARY_DIR}/metadata.json @ONLY)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/metadata.json DESTINATION /usr/share/dde-shell/${PLUGIN_ID})
install(FILES package/networkview.qml DESTINATION /usr/share/dde-shell/${PLUGIN_ID}) install(FILES package/networkview.qml DESTINATION /usr/share/dde-shell/${PLUGIN_ID})
+1 -1
View File
@@ -68,7 +68,7 @@ bash build-deb.sh
├── networkmonitorapplet.h # C++ 后端头文件 ├── networkmonitorapplet.h # C++ 后端头文件
├── networkmonitorapplet.cpp # C++ 后端实现 ├── networkmonitorapplet.cpp # C++ 后端实现
├── package/ ├── package/
│ ├── metadata.json # 插件元数据 │ ├── metadata.json.in # 插件元数据模板(由 CMake 生成 metadata.json
│ └── networkview.qml # QML 界面 │ └── networkview.qml # QML 界面
├── docs/ # 设计文档与实现计划 ├── docs/ # 设计文档与实现计划
├── README.md # 本文件 ├── README.md # 本文件
+26 -7
View File
@@ -110,6 +110,12 @@ QString NetworkMonitorApplet::ipAddress() const
return m_ipAddress; return m_ipAddress;
} }
// 返回活动接口的 IPv6 全球地址,供 QML 显示
QString NetworkMonitorApplet::ipv6Address() const
{
return m_ipv6Address;
}
void NetworkMonitorApplet::refresh() void NetworkMonitorApplet::refresh()
{ {
readNetworkStats(); readNetworkStats();
@@ -279,21 +285,30 @@ qint64 NetworkMonitorApplet::getActiveTxBytes() const
return m_interfaces[m_activeInterface].txBytes; return m_interfaces[m_activeInterface].txBytes;
} }
// 检测活动接口的 IPv4 地址 // 检测活动接口的 IPv4 与 IPv6 地址
// 跳过 IPv6 和 loopback取第一个有效 IPv4 地址 // IPv4跳过 loopback,取第一个
// IP 变化时发射 ipAddressChanged 信号通知 QML 更新 // IPv6:跳过 loopback 和 link-local (fe80::),取第一个全球地址
// 设计原因:DHCP 续约、网络切换等场景下 IP 可能变化,需持续检测 // 任一地址变化时分别 emit 对应信号通知 QML 更新
// 设计原因:DHCP 续约、网络切换、IPv6 SLAAC 等场景下地址可能变化,需持续检测
void NetworkMonitorApplet::detectIpAddress() void NetworkMonitorApplet::detectIpAddress()
{ {
QString newIp; QString newIp;
QString newIpv6;
if (!m_activeInterface.isEmpty()) { if (!m_activeInterface.isEmpty()) {
QNetworkInterface iface = QNetworkInterface::interfaceFromName(m_activeInterface); QNetworkInterface iface = QNetworkInterface::interfaceFromName(m_activeInterface);
for (const QNetworkAddressEntry &entry : iface.addressEntries()) { for (const QNetworkAddressEntry &entry : iface.addressEntries()) {
// 只取 IPv4 地址,跳过 IPv6 和 loopback // IPv4:跳过 loopback,取第一个
if (entry.ip().protocol() == QAbstractSocket::IPv4Protocol if (entry.ip().protocol() == QAbstractSocket::IPv4Protocol
&& !entry.ip().isLoopback()) { && !entry.ip().isLoopback()
&& newIp.isEmpty()) {
newIp = entry.ip().toString(); newIp = entry.ip().toString();
break; }
// IPv6:跳过 loopback 和 link-local (fe80::),取第一个全球地址
if (entry.ip().protocol() == QAbstractSocket::IPv6Protocol
&& !entry.ip().isLoopback()
&& !entry.ip().isLinkLocal()
&& newIpv6.isEmpty()) {
newIpv6 = entry.ip().toString();
} }
} }
} }
@@ -301,6 +316,10 @@ void NetworkMonitorApplet::detectIpAddress()
m_ipAddress = newIp; m_ipAddress = newIp;
emit ipAddressChanged(); emit ipAddressChanged();
} }
if (m_ipv6Address != newIpv6) {
m_ipv6Address = newIpv6;
emit ipv6AddressChanged();
}
} }
// 判断是否为物理网卡 // 判断是否为物理网卡
+6 -1
View File
@@ -40,6 +40,8 @@ class NetworkMonitorApplet : public DApplet
Q_PROPERTY(QString activeInterface READ activeInterface NOTIFY activeInterfaceChanged) Q_PROPERTY(QString activeInterface READ activeInterface NOTIFY activeInterfaceChanged)
// 活动接口的 IPv4 地址,供 QML 在弹出面板和 tooltip 中显示 // 活动接口的 IPv4 地址,供 QML 在弹出面板和 tooltip 中显示
Q_PROPERTY(QString ipAddress READ ipAddress NOTIFY ipAddressChanged) Q_PROPERTY(QString ipAddress READ ipAddress NOTIFY ipAddressChanged)
// 活动接口的 IPv6 全球地址(已过滤 link-local 和 loopback),供 QML 显示
Q_PROPERTY(QString ipv6Address READ ipv6Address NOTIFY ipv6AddressChanged)
public: public:
explicit NetworkMonitorApplet(QObject *parent = nullptr); explicit NetworkMonitorApplet(QObject *parent = nullptr);
@@ -57,6 +59,7 @@ public:
bool ready() const; bool ready() const;
QString activeInterface() const; QString activeInterface() const;
QString ipAddress() const; QString ipAddress() const;
QString ipv6Address() const;
Q_INVOKABLE void refresh(); Q_INVOKABLE void refresh();
Q_INVOKABLE void setActiveInterface(const QString &interface); Q_INVOKABLE void setActiveInterface(const QString &interface);
@@ -69,12 +72,13 @@ signals:
void readyChanged(); void readyChanged();
void activeInterfaceChanged(); void activeInterfaceChanged();
void ipAddressChanged(); void ipAddressChanged();
void ipv6AddressChanged();
private: private:
void readNetworkStats(); void readNetworkStats();
void calculateSpeed(); void calculateSpeed();
void detectInterfaces(); void detectInterfaces();
// 检测活动接口的 IPv4 地址,变化时发射 ipAddressChanged // 检测活动接口的 IPv4 与 IPv6 地址,变化时分别发射对应信号
void detectIpAddress(); void detectIpAddress();
// 判断是否为物理网卡(无线 wlp/wlan,有线 enp/eth), // 判断是否为物理网卡(无线 wlp/wlan,有线 enp/eth),
// 用于自动选择时优先真实网卡而非虚拟代理接口(如 Meta/tun0) // 用于自动选择时优先真实网卡而非虚拟代理接口(如 Meta/tun0)
@@ -87,6 +91,7 @@ private:
QStringList m_interfaceList; QStringList m_interfaceList;
QString m_activeInterface; QString m_activeInterface;
QString m_ipAddress; // 活动接口的 IPv4 地址 QString m_ipAddress; // 活动接口的 IPv4 地址
QString m_ipv6Address; // 活动接口的 IPv6 全球地址(过滤 link-local
// 速度计算 // 速度计算
qint64 m_lastRxBytes; qint64 m_lastRxBytes;
@@ -1,6 +1,6 @@
{ {
"Plugin": { "Plugin": {
"Version": "1.0", "Version": "@PROJECT_VERSION@",
"Id": "space.jokul.JNetApplet", "Id": "space.jokul.JNetApplet",
"Url": "networkview.qml", "Url": "networkview.qml",
"Parent": "org.deepin.ds.dock", "Parent": "org.deepin.ds.dock",
+99 -16
View File
@@ -16,11 +16,16 @@ AppletItem {
objectName: "network monitor applet" objectName: "network monitor applet"
property int dockOrder: 21 property int dockOrder: 21
property int dockSize: Panel.rootObject.dockItemMaxSize || 48 property int dockSize: Panel.rootObject.dockItemMaxSize || 48
// 任务栏方向:0=Top, 1=Right, 2=Bottom, 3=Left% 2 为 0 表示水平,1 表示竖向
// 设计原因:dde-shell 规范用法,org.deepin.ds.dock 已在 line 11 导入
property bool isVerticalDock: Panel.position % 2 === 1
// 任务栏宽度稍宽于标准 dock 尺寸,容纳双行速度数值 // 水平任务栏宽度稍宽容纳双行数值,高度匹配 dock 尺寸
// 设计原因:固定宽度避免数值长度变化导致插件宽度抖动、箭头位置漂移 // 竖向任务栏:宽度匹配 dock 尺寸(~40px),高度加大容纳竖排字符
implicitWidth: Math.round(dockSize * 1.35) // 2.4 倍:dockSize=40 时约 96px,可容纳最多 8 字符的速度字符串(如 "1023.99K"
implicitHeight: dockSize // 增大字号后需更高以容纳竖排字符
implicitWidth: isVerticalDock ? dockSize : Math.round(dockSize * 1.35)
implicitHeight: isVerticalDock ? Math.round(dockSize * 2.4) : dockSize
readonly property var applet: Applet readonly property var applet: Applet
readonly property bool ready: applet ? applet.ready : false readonly property bool ready: applet ? applet.ready : false
@@ -31,6 +36,8 @@ AppletItem {
readonly property string activeInterface: applet ? (applet.activeInterface || "") : "" readonly property string activeInterface: applet ? (applet.activeInterface || "") : ""
// 活动接口的 IPv4 地址,由 C++ 后端 detectIpAddress 持续更新 // 活动接口的 IPv4 地址,由 C++ 后端 detectIpAddress 持续更新
readonly property string ipAddress: applet ? (applet.ipAddress || "") : "" readonly property string ipAddress: applet ? (applet.ipAddress || "") : ""
// 活动接口的 IPv6 全球地址,由 C++ 后端 detectIpAddress 持续更新
readonly property string ipv6Address: applet ? (applet.ipv6Address || "") : ""
readonly property var networkInterfaces: applet ? (applet.networkInterfaces || []) : [] readonly property var networkInterfaces: applet ? (applet.networkInterfaces || []) : []
readonly property var interfaceStats: applet ? (applet.interfaceStats || []) : [] readonly property var interfaceStats: applet ? (applet.interfaceStats || []) : []
@@ -132,6 +139,7 @@ AppletItem {
// 不给每行固定高度,让 RowLayout 按内容自然高度排列, // 不给每行固定高度,让 RowLayout 按内容自然高度排列,
// 整组垂直居中于 dock 区域,避免两行间出现过大间隙 // 整组垂直居中于 dock 区域,避免两行间出现过大间隙
Column { Column {
visible: !root.isVerticalDock
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 4 anchors.leftMargin: 4
@@ -143,16 +151,16 @@ AppletItem {
Text { Text {
text: "↓" text: "↓"
font.pixelSize: root.dockSize * 0.18 font.pixelSize: root.dockSize * 0.20
color: root.secondaryText color: root.secondaryText
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
} }
Text { Text {
text: root.formatSpeedShort(root.downloadSpeed) text: root.formatSpeedShort(root.downloadSpeed)
font.pixelSize: root.dockSize * 0.22 font.pixelSize: root.dockSize * 0.25
font.weight: Font.Medium font.weight: Font.Medium
color: root.downloadValueColor color: root.primaryText
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
} }
@@ -164,28 +172,93 @@ AppletItem {
Text { Text {
text: "↑" text: "↑"
font.pixelSize: root.dockSize * 0.18 font.pixelSize: root.dockSize * 0.20
color: root.secondaryText color: root.secondaryText
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
} }
Text { Text {
text: root.formatSpeedShort(root.uploadSpeed) text: root.formatSpeedShort(root.uploadSpeed)
font.pixelSize: root.dockSize * 0.22 font.pixelSize: root.dockSize * 0.25
font.weight: Font.Medium font.weight: Font.Medium
color: root.uploadValueColor color: root.primaryText
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
} }
} }
} }
// 悬停提示:网卡名 + IP 地址一行展示 // 竖向任务栏布局:双列字符竖排,每列一个方向(下载|上传)
// PanelToolTip 不支持 contentItem 覆盖和文本对齐,用默认左对齐 text 属性 // 设计原因:竖向任务栏宽度仅 ~40px,水平布局的 "↓6.64K" 约 35px 勉强容纳但
// 数值长度变化时易裁切;逐字符竖排每列仅 ~10px,双列 ~25px,舒适容纳且视觉对称
RowLayout {
visible: root.isVerticalDock
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 4
spacing: 4
// 下载列:箭头在上,数值字符从上往下竖排
Column {
spacing: 0
Layout.alignment: Qt.AlignTop | Qt.AlignHCenter
Text {
text: "↓"
font.pixelSize: root.dockSize * 0.20
color: root.secondaryText
anchors.horizontalCenter: parent.horizontalCenter
}
Repeater {
// 将 "6.64K" 拆为 ["6", ".", "6", "4", "K"] 逐字符渲染
// 属性绑定:downloadSpeed 变化时 formatSpeedShort 重算,split 自动刷新 model
model: root.formatSpeedShort(root.downloadSpeed).split('')
Text {
text: modelData
font.pixelSize: root.dockSize * 0.25
height: font.pixelSize
font.weight: Font.Medium
color: root.primaryText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
// 上传列:结构与下载列对称,颜色用 uploadValueColor
Column {
spacing: 0
Layout.alignment: Qt.AlignTop | Qt.AlignHCenter
Text {
text: "↑"
font.pixelSize: root.dockSize * 0.20
color: root.secondaryText
anchors.horizontalCenter: parent.horizontalCenter
}
Repeater {
model: root.formatSpeedShort(root.uploadSpeed).split('')
Text {
text: modelData
font.pixelSize: root.dockSize * 0.25
height: font.pixelSize
font.weight: Font.Medium
color: root.primaryText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
// 悬停提示:网卡名 + IPv4 单行,IPv6 在第二行(仅有 IPv6 时追加)
// 设计原因:IPv6 地址较长,单行容纳不下;无 IPv6 时保持单行与原视觉一致
PanelToolTip { PanelToolTip {
id: toolTip id: toolTip
text: root.ready text: root.ready
? (root.activeInterface + " · " + qsTr("IP地址:") + (root.ipAddress || qsTr("无"))) ? (root.activeInterface
+ " · IPv4" + (root.ipAddress || qsTr("无"))
+ (root.ipv6Address ? "\nIPv6" + root.ipv6Address : ""))
: qsTr("未检测到网络接口") : qsTr("未检测到网络接口")
toolTipX: DockPanelPositioner.x toolTipX: DockPanelPositioner.x
toolTipY: DockPanelPositioner.y toolTipY: DockPanelPositioner.y
@@ -267,8 +340,9 @@ AppletItem {
// 网卡名 + IP 地址卡片背景 // 网卡名 + IP 地址卡片背景
Rectangle { Rectangle {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: 180 Layout.preferredWidth: 300
Layout.preferredHeight: 40 // 有 IPv6 时增高以容纳第三行;无 IPv6 时保持原 40px
Layout.preferredHeight: root.ipv6Address ? 54 : 40
color: root.cardBackground color: root.cardBackground
radius: 8 radius: 8
border.width: 1 border.width: 1
@@ -287,12 +361,21 @@ AppletItem {
} }
Text { Text {
text: root.ipAddress ? qsTr("IP地址") + root.ipAddress : "" text: root.ipAddress ? qsTr("IPv4") + root.ipAddress : ""
font.pixelSize: 13 font.pixelSize: 13
color: root.secondaryText color: root.secondaryText
visible: root.ipAddress !== "" visible: root.ipAddress !== ""
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
} }
// IPv6 全球地址行,无 IPv6 时隐藏不占位
Text {
text: root.ipv6Address ? qsTr("IPv6") + root.ipv6Address : ""
font.pixelSize: 13
color: root.secondaryText
visible: root.ipv6Address !== ""
Layout.alignment: Qt.AlignHCenter
}
} }
} }
} }