Просмотр исходного кода

1. 添加LocalSend功能
2. 规范设置中的显示
3. 允许工具调整

soease 3 дней назад
Родитель
Сommit
5e5e9bf402
36 измененных файлов с 5258 добавлено и 140 удалено
  1. 3 0
      frontend/bindings/TengTuToolbox/index.js
  2. 9 0
      frontend/bindings/TengTuToolbox/localsend/model/index.js
  3. 148 0
      frontend/bindings/TengTuToolbox/localsend/model/models.js
  4. 95 0
      frontend/bindings/TengTuToolbox/localsendservice.js
  5. 124 11
      frontend/bindings/TengTuToolbox/models.js
  6. 11 0
      frontend/bindings/TengTuToolbox/settingsservice.js
  7. 95 20
      frontend/index.html
  8. 285 2
      frontend/public/style.css
  9. 482 52
      frontend/src/main.js
  10. 14 0
      githubwatch.go
  11. 1 1
      go.mod
  12. 152 0
      localsend/crypto/crypto.go
  13. 195 0
      localsend/discovery/announce.go
  14. 22 0
      localsend/discovery/config.go
  15. 218 0
      localsend/discovery/multicast.go
  16. 16 0
      localsend/discovery/multicast_interface.go
  17. 282 0
      localsend/discovery/peercache.go
  18. 66 0
      localsend/discovery/register_http.go
  19. 243 0
      localsend/discovery/service.go
  20. 35 0
      localsend/discovery/tlsclient.go
  21. 149 0
      localsend/events/events.go
  22. 229 0
      localsend/logging/logging.go
  23. 255 0
      localsend/metadata/strip.go
  24. 135 0
      localsend/model/device.go
  25. 167 0
      localsend/model/dto.go
  26. 114 0
      localsend/model/file.go
  27. 299 0
      localsend/network/interfaces.go
  28. 192 0
      localsend/network/interfaces_android.go
  29. 12 0
      localsend/network/interfaces_default.go
  30. 296 0
      localsend/send/send.go
  31. 99 0
      localsend/send/upload.go
  32. 347 0
      localsend_server.go
  33. 313 0
      localsend_service.go
  34. 1 0
      main.go
  35. 134 54
      settings.go
  36. 20 0
      settingsservice.go

+ 3 - 0
frontend/bindings/TengTuToolbox/index.js

@@ -3,9 +3,11 @@
 // This file is automatically generated. DO NOT EDIT
 
 import * as GreetService from "./greetservice.js";
+import * as LocalSendService from "./localsendservice.js";
 import * as SettingsService from "./settingsservice.js";
 export {
     GreetService,
+    LocalSendService,
     SettingsService
 };
 
@@ -17,6 +19,7 @@ export {
     CustomTab,
     KLineBar,
     LinkItem,
+    LocalSendConfig,
     MinuteChart,
     MinutePoint,
     MouseAlertConfig,

+ 9 - 0
frontend/bindings/TengTuToolbox/localsend/model/index.js

@@ -0,0 +1,9 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export {
+    Device,
+    DeviceType,
+    ProtocolType
+} from "./models.js";

+ 148 - 0
frontend/bindings/TengTuToolbox/localsend/model/models.js

@@ -0,0 +1,148 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Create as $Create } from "@wailsio/runtime";
+
+/**
+ * Device represents a peer device.
+ */
+export class Device {
+    /**
+     * Creates a new Device instance.
+     * @param {Partial<Device>} [$$source = {}] - The source object to create the Device.
+     */
+    constructor($$source = {}) {
+        if (!("ip" in $$source)) {
+            /**
+             * @member
+             * @type {string}
+             */
+            this["ip"] = "";
+        }
+        if (!("version" in $$source)) {
+            /**
+             * LocalSend protocol version
+             * @member
+             * @type {string}
+             */
+            this["version"] = "";
+        }
+        if (!("port" in $$source)) {
+            /**
+             * @member
+             * @type {number}
+             */
+            this["port"] = 0;
+        }
+        if (!("alias" in $$source)) {
+            /**
+             * @member
+             * @type {string}
+             */
+            this["alias"] = "";
+        }
+        if (!("protocol" in $$source)) {
+            /**
+             * @member
+             * @type {ProtocolType}
+             */
+            this["protocol"] = ProtocolType.$zero;
+        }
+        if (!("fingerprint" in $$source)) {
+            /**
+             * @member
+             * @type {string}
+             */
+            this["fingerprint"] = "";
+        }
+        if (!("deviceModel" in $$source)) {
+            /**
+             * nullable
+             * @member
+             * @type {string | null}
+             */
+            this["deviceModel"] = null;
+        }
+        if (!("deviceType" in $$source)) {
+            /**
+             * @member
+             * @type {DeviceType}
+             */
+            this["deviceType"] = DeviceType.$zero;
+        }
+        if (!("download" in $$source)) {
+            /**
+             * Whether the device has download server running
+             * @member
+             * @type {boolean}
+             */
+            this["download"] = false;
+        }
+        if (!("lastSeen" in $$source)) {
+            /**
+             * @member
+             * @type {Date}
+             */
+            this["lastSeen"] = new Date("0001-01-01T00:00:00.000Z");
+        }
+        if (!("available" in $$source)) {
+            /**
+             * @member
+             * @type {boolean}
+             */
+            this["available"] = false;
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new Device instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {Device}
+     */
+    static createFrom($$source = {}) {
+        const $$createField9_0 = $Create.DateFromTime;
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        if ("lastSeen" in $$parsedSource) {
+            $$parsedSource["lastSeen"] = $$createField9_0($$parsedSource["lastSeen"]);
+        }
+        return new Device(/** @type {Partial<Device>} */($$parsedSource));
+    }
+}
+
+/**
+ * DeviceType defines the type of the device.
+ * @readonly
+ * @enum {string}
+ */
+export const DeviceType = {
+    /**
+     * The Go zero value for the underlying type of the enum.
+     */
+    $zero: "",
+
+    DeviceTypeMobile: "mobile",
+    DeviceTypeDesktop: "desktop",
+    DeviceTypeWeb: "web",
+    DeviceTypeHeadless: "headless",
+    DeviceTypeServer: "server",
+};
+
+/**
+ * ProtocolType defines the protocol type.
+ * @readonly
+ * @enum {string}
+ */
+export const ProtocolType = {
+    /**
+     * The Go zero value for the underlying type of the enum.
+     */
+    $zero: "",
+
+    ProtocolTypeHTTP: "http",
+    ProtocolTypeHTTPS: "https",
+};

+ 95 - 0
frontend/bindings/TengTuToolbox/localsendservice.js

@@ -0,0 +1,95 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * LocalSendService 是 LocalSend 功能的 Wails 服务。
+ * @module
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as model$0 from "./localsend/model/models.js";
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import * as $models from "./models.js";
+
+/**
+ * ConfirmTransfer 手动接收确认:accept=true 接受,false 拒绝。
+ * @param {string} sessionID
+ * @param {boolean} accept
+ * @returns {$CancellablePromise<void>}
+ */
+export function ConfirmTransfer(sessionID, accept) {
+    return $Call.ByID(2598966323, sessionID, accept);
+}
+
+/**
+ * GetConfig 返回当前配置。
+ * @returns {$CancellablePromise<$models.LocalSendConfig>}
+ */
+export function GetConfig() {
+    return $Call.ByID(304061066).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType0($result);
+    }));
+}
+
+/**
+ * GetDevices 返回当前发现的设备列表。
+ * @returns {$CancellablePromise<(model$0.Device | null)[]>}
+ */
+export function GetDevices() {
+    return $Call.ByID(2077455679).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType3($result);
+    }));
+}
+
+/**
+ * SendFiles 把本地文件发送给指定的 LocalSend 设备(按 IP 定位)。
+ * filePaths 为本地文件/目录路径列表;deviceIP 为目标设备 IP(如 192.168.1.5)。
+ * @param {string[]} filePaths
+ * @param {string} deviceIP
+ * @returns {$CancellablePromise<void>}
+ */
+export function SendFiles(filePaths, deviceIP) {
+    return $Call.ByID(1947247873, filePaths, deviceIP);
+}
+
+/**
+ * SetConfig 更新本机名、接收位置、接收模式。
+ * 若服务运行中,即时生效(服务器配置与后续公告同步更新)。
+ * @param {string} alias
+ * @param {string} receiveDir
+ * @param {boolean} autoAccept
+ * @returns {$CancellablePromise<void>}
+ */
+export function SetConfig(alias, receiveDir, autoAccept) {
+    return $Call.ByID(3005820190, alias, receiveDir, autoAccept);
+}
+
+/**
+ * Start 启动 LocalSend 服务:生成/加载证书、启动接收服务器与发现服务。
+ * @returns {$CancellablePromise<void>}
+ */
+export function Start() {
+    return $Call.ByID(4044878204);
+}
+
+/**
+ * Stop 停止 LocalSend 服务。
+ * @returns {$CancellablePromise<void>}
+ */
+export function Stop() {
+    return $Call.ByID(926464408);
+}
+
+// Private type creation functions
+const $$createType0 = $models.LocalSendConfig.createFrom;
+const $$createType1 = model$0.Device.createFrom;
+const $$createType2 = $Create.Nullable($$createType1);
+const $$createType3 = $Create.Array($$createType2);

+ 124 - 11
frontend/bindings/TengTuToolbox/models.js

@@ -343,6 +343,22 @@ export class AppSettings {
              */
             this["fontDelta"] = 0;
         }
+        if (!("localSend" in $$source)) {
+            /**
+             * LocalSend 文件互传配置(本机名/接收目录/接收模式等)
+             * @member
+             * @type {LocalSendConfig}
+             */
+            this["localSend"] = (new LocalSendConfig());
+        }
+        if (!("toolOrder" in $$source)) {
+            /**
+             * 工具页卡片显示顺序(工具 id 列表,未列出的追加到末尾)
+             * @member
+             * @type {string[]}
+             */
+            this["toolOrder"] = [];
+        }
 
         Object.assign(this, $$source);
     }
@@ -362,6 +378,8 @@ export class AppSettings {
         const $$createField21_0 = $$createType7;
         const $$createField22_0 = $$createType8;
         const $$createField23_0 = $$createType9;
+        const $$createField34_0 = $$createType10;
+        const $$createField35_0 = $$createType0;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("tabOrder" in $$parsedSource) {
             $$parsedSource["tabOrder"] = $$createField4_0($$parsedSource["tabOrder"]);
@@ -390,6 +408,12 @@ export class AppSettings {
         if ("mouseAlert" in $$parsedSource) {
             $$parsedSource["mouseAlert"] = $$createField23_0($$parsedSource["mouseAlert"]);
         }
+        if ("localSend" in $$parsedSource) {
+            $$parsedSource["localSend"] = $$createField34_0($$parsedSource["localSend"]);
+        }
+        if ("toolOrder" in $$parsedSource) {
+            $$parsedSource["toolOrder"] = $$createField35_0($$parsedSource["toolOrder"]);
+        }
         return new AppSettings(/** @type {Partial<AppSettings>} */($$parsedSource));
     }
 }
@@ -658,6 +682,78 @@ export class LinkItem {
     }
 }
 
+/**
+ * LocalSendConfig 是 LocalSend 的运行时配置。
+ */
+export class LocalSendConfig {
+    /**
+     * Creates a new LocalSendConfig instance.
+     * @param {Partial<LocalSendConfig>} [$$source = {}] - The source object to create the LocalSendConfig.
+     */
+    constructor($$source = {}) {
+        if (!("enabled" in $$source)) {
+            /**
+             * 是否已启动服务
+             * @member
+             * @type {boolean}
+             */
+            this["enabled"] = false;
+        }
+        if (!("alias" in $$source)) {
+            /**
+             * 本机名称(广播给其他设备)
+             * @member
+             * @type {string}
+             */
+            this["alias"] = "";
+        }
+        if (!("receiveDir" in $$source)) {
+            /**
+             * 接收文件保存目录
+             * @member
+             * @type {string}
+             */
+            this["receiveDir"] = "";
+        }
+        if (!("autoAccept" in $$source)) {
+            /**
+             * true=自动接收;false=手动确认
+             * @member
+             * @type {boolean}
+             */
+            this["autoAccept"] = false;
+        }
+        if (!("port" in $$source)) {
+            /**
+             * 监听端口
+             * @member
+             * @type {number}
+             */
+            this["port"] = 0;
+        }
+        if (!("fingerprint" in $$source)) {
+            /**
+             * 本机证书指纹
+             * @member
+             * @type {string}
+             */
+            this["fingerprint"] = "";
+        }
+
+        Object.assign(this, $$source);
+    }
+
+    /**
+     * Creates a new LocalSendConfig instance from a string or object.
+     * @param {any} [$$source = {}]
+     * @returns {LocalSendConfig}
+     */
+    static createFrom($$source = {}) {
+        let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
+        return new LocalSendConfig(/** @type {Partial<LocalSendConfig>} */($$parsedSource));
+    }
+}
+
 /**
  * MinuteChart 分时图数据
  */
@@ -701,7 +797,7 @@ export class MinuteChart {
      * @returns {MinuteChart}
      */
     static createFrom($$source = {}) {
-        const $$createField2_0 = $$createType11;
+        const $$createField2_0 = $$createType12;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("points" in $$parsedSource) {
             $$parsedSource["points"] = $$createField2_0($$parsedSource["points"]);
@@ -1299,6 +1395,14 @@ export class RepoFeed {
              */
             this["version"] = "";
         }
+        if (!("versionTime" in $$source)) {
+            /**
+             * 本地版本对应 release 的发布时间(MM-DD HH:MM),用于鼠标提示
+             * @member
+             * @type {string}
+             */
+            this["versionTime"] = "";
+        }
         if (!("latest" in $$source)) {
             /**
              * 最新正式 release 版本号,无正式 release 时为空
@@ -1307,6 +1411,14 @@ export class RepoFeed {
              */
             this["latest"] = "";
         }
+        if (!("latestTime" in $$source)) {
+            /**
+             * 最新正式 release 发布时间(MM-DD HH:MM),用于鼠标提示
+             * @member
+             * @type {string}
+             */
+            this["latestTime"] = "";
+        }
         if (!("hasUpdate" in $$source)) {
             /**
              * 是否存在可用更新(本地版本低于最新正式 release)
@@ -1357,14 +1469,14 @@ export class RepoFeed {
      * @returns {RepoFeed}
      */
     static createFrom($$source = {}) {
-        const $$createField7_0 = $$createType13;
-        const $$createField8_0 = $$createType15;
+        const $$createField9_0 = $$createType14;
+        const $$createField10_0 = $$createType16;
         let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
         if ("newer" in $$parsedSource) {
-            $$parsedSource["newer"] = $$createField7_0($$parsedSource["newer"]);
+            $$parsedSource["newer"] = $$createField9_0($$parsedSource["newer"]);
         }
         if ("commits" in $$parsedSource) {
-            $$parsedSource["commits"] = $$createField8_0($$parsedSource["commits"]);
+            $$parsedSource["commits"] = $$createField10_0($$parsedSource["commits"]);
         }
         return new RepoFeed(/** @type {Partial<RepoFeed>} */($$parsedSource));
     }
@@ -1821,9 +1933,10 @@ const $$createType6 = $Create.Array($$createType5);
 const $$createType7 = TickerConfig.createFrom;
 const $$createType8 = PoemConfig.createFrom;
 const $$createType9 = MouseAlertConfig.createFrom;
-const $$createType10 = MinutePoint.createFrom;
-const $$createType11 = $Create.Array($$createType10);
-const $$createType12 = ReleaseItem.createFrom;
-const $$createType13 = $Create.Array($$createType12);
-const $$createType14 = CommitItem.createFrom;
-const $$createType15 = $Create.Array($$createType14);
+const $$createType10 = LocalSendConfig.createFrom;
+const $$createType11 = MinutePoint.createFrom;
+const $$createType12 = $Create.Array($$createType11);
+const $$createType13 = ReleaseItem.createFrom;
+const $$createType14 = $Create.Array($$createType13);
+const $$createType15 = CommitItem.createFrom;
+const $$createType16 = $Create.Array($$createType15);

+ 11 - 0
frontend/bindings/TengTuToolbox/settingsservice.js

@@ -332,6 +332,17 @@ export function PickBrowserPath() {
     return $Call.ByID(165230808);
 }
 
+/**
+ * PickSendFiles 弹出多选文件对话框,用于 LocalSend 发送文件。
+ * 返回选中的文件路径列表(取消返回空列表)。
+ * @returns {$CancellablePromise<string[]>}
+ */
+export function PickSendFiles() {
+    return $Call.ByID(1674315782).then(/** @type {($result: any) => any} */(($result) => {
+        return $$createType0($result);
+    }));
+}
+
 /**
  * PickUploadFile 弹出文件选择对话框,返回用户选中的本地文件路径(取消返回空串)。
  * @returns {$CancellablePromise<string>}

+ 95 - 20
frontend/index.html

@@ -158,10 +158,11 @@
             <!-- 工具:本地开发/运维辅助工具集 -->
             <div class="tab-panel" id="tab-tools">
                 <!-- 工具 1:Web 服务——选择目录,开启本地浏览服务 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="websvc">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>Web 服务</span>
                         <span class="tool-status" id="websvc-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">选择任意目录,在本机开启浏览服务,默认页为 <code>index.html</code> 或 <code>readme.md</code>。</p>
@@ -170,8 +171,7 @@
                             <button class="btn-ghost" id="websvc-pick">选择目录</button>
                         </div>
                         <div class="tool-row tool-actions">
-                            <button class="btn-primary" id="websvc-start">开启浏览服务</button>
-                            <button class="btn-ghost" id="websvc-stop" disabled>停止服务</button>
+                            <button class="btn-primary" id="websvc-toggle">开启浏览服务</button>
                             <span class="tool-hint" id="websvc-hint"></span>
                         </div>
                         <div class="websvc-preview" id="websvc-preview" hidden>
@@ -181,10 +181,11 @@
                 </div>
 
                 <!-- 工具 2:翻译——中文→英文 / 英文→中文,自动检测 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="translate">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>翻译</span>
                         <span class="tool-status" id="tr-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">输入中文自动翻译为英文,输入英文自动翻译为中文。免费接口有每日配额,超额请稍后再试。</p>
@@ -198,10 +199,11 @@
                 </div>
 
                 <!-- 工具 3:二维码生成——输入字符串生成二维码图片 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="qrcode">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>二维码生成</span>
                         <span class="tool-status" id="qr-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">输入任意字符串,即时生成二维码图片。可用于网址、文本、联系方式等。</p>
@@ -218,10 +220,11 @@
                 </div>
 
                 <!-- 工具 4:发送 ntfy 消息——输入主题 / 标题 / 内容,推送到 ntfy 频道 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="ntfy">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>发送 ntfy 消息</span>
                         <span class="tool-status" id="ntfy-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">向 ntfy 服务推送通知,用于手机 / 浏览器 / 其他设备即时提醒。主题(Topic)为监听频道名,输入过的主题会自动记忆。</p>
@@ -238,10 +241,11 @@
                     </div>
                 </div>
                 <!-- 工具 5:ntfy 聊天室——输入昵称与主题,订阅并实时收发消息(QQ 气泡风格) -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="chat">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>ntfy 聊天室</span>
                         <span class="tool-status" id="chat-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">输入昵称(作为发送方标识,记忆上次使用)与聊天室名称(ntfy 主题)后开始监听,像 QQ 一样左右气泡收发消息。</p>
@@ -260,10 +264,11 @@
                     </div>
                 </div>
                 <!-- 工具 6:古诗词——显示 footer 最近获取的古诗,可刷新获取新诗 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="poem">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>古诗词</span>
                         <span class="tool-status" id="poem-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">显示主窗口 footer 最近获取到的古诗词内容,可手动刷新获取新的一首。数据来自古诗词网随机接口。</p>
@@ -281,10 +286,11 @@
                 </div>
 
                 <!-- 工具 7:屏幕截图——隐藏本程序后截取全屏,完成后重新显示 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="screenshot">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>屏幕截图</span>
                         <span class="tool-status" id="shot-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">点击截屏后自动隐藏本程序窗口,截取整个屏幕(全部显示器),完成后重新显示并预览。截到的画面不含本程序自身。</p>
@@ -303,10 +309,11 @@
                 </div>
 
                 <!-- 工具 8:延时关机——输入多少分钟后关机,交给系统倒计时 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="shutdown">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>延时关机</span>
                         <span class="tool-status" id="sd-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">输入多少分钟后关机,确认后交给系统倒计时(即使本程序退出仍会执行),也可随时取消。</p>
@@ -320,10 +327,11 @@
                 </div>
 
                 <!-- 工具 9:MD5 加密——输入字符串,生成 32 位 MD5 摘要 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="md5">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>MD5 加密</span>
                         <span class="tool-status" id="md5-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">输入任意字符串,计算其 32 位小写十六进制 MD5 摘要。常用于文件/字符串校验,注意 MD5 已不适合密码存储。</p>
@@ -337,10 +345,11 @@
                 </div>
 
                 <!-- 工具 10:SHA256 编码——输入字符串,生成 SHA256 摘要 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="sha256">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>SHA256 编码</span>
                         <span class="tool-status" id="sha-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">输入任意字符串,计算其 64 位小写十六进制 SHA256 摘要。适用于校验、签名与安全存储。</p>
@@ -354,10 +363,11 @@
                 </div>
 
                 <!-- 工具 11:LED 滚动文字——输入文字(可汉字),转换为 LED 点阵独立窗口滚动显示 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="led">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>LED 滚动文字</span>
                         <span class="tool-status" id="led-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">输入文字(支持中文),转换为 LED 点阵,在独立的无边框置顶透明窗口中横向滚动显示。窗口可拖动、可关闭。</p>
@@ -379,10 +389,11 @@
                 </div>
 
                 <!-- 工具 12:定时提醒——输入提醒信息与间隔分钟数,每 xx 分钟提醒一次直到取消 -->
-                <div class="tool-card is-collapsed">
+                <div class="tool-card is-collapsed" data-tool="reminder">
                     <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
                         <span class="tool-card-title"><span class="tool-arrow">▸</span><span class="dot"></span>定时提醒</span>
                         <span class="tool-status" id="remind-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
                     </div>
                     <div class="tool-card-body">
                         <p class="tool-desc">输入提醒信息与间隔分钟数,每 xx 分钟弹出一次提醒,直到手动取消。适合定时喝水、休息、吃药等。</p>
@@ -395,6 +406,26 @@
                         </div>
                     </div>
                 </div>
+
+                <!-- 工具 13:LocalSend 文件互传——组播发现设备,HTTPS 接收文件 -->
+                <div class="tool-card is-collapsed" data-tool="localsend">
+                    <div class="tool-card-head" role="button" tabindex="0" aria-expanded="false">
+                        <span class="tool-card-title" title="与局域网内的 LocalSend 客户端(手机 / 电脑)互相发现并互传文件。启动后:其他设备可发送文件到本机(按设置中的「接收模式」自动或手动接收);也可从本机选择文件发送给发现的设备。本机名、接收位置、接收模式在「设置 → LocalSend」中配置。"><span class="tool-arrow">▸</span><span class="dot"></span>LocalSend 互传</span>
+                        <span class="tool-status" id="localsend-status"></span>
+                        <span class="tool-order-btns"><button class="tool-order-btn tool-up" type="button" title="上移">↑</button><button class="tool-order-btn tool-down" type="button" title="下移">↓</button></span>
+                    </div>
+                    <div class="tool-card-body">
+                        <div class="tool-row">
+                            <button class="btn-primary" id="localsend-toggle">启动服务</button>
+                            <span class="tool-hint" id="localsend-hint"></span>
+                        </div>
+                        <div class="localsend-devices" id="localsend-devices" hidden>
+                            <div class="card-title"><span class="dot"></span>发现设备</div>
+                            <span class="tool-hint" id="localsend-send-hint"></span>
+                            <ul class="localsend-device-list" id="localsend-device-list"></ul>
+                        </div>
+                    </div>
+                </div>
             </div>
             <div class="tab-panel" id="tab-host">
                 <div class="host-cards">
@@ -537,8 +568,8 @@
                         </div>
                     </div>
 
-                    <!-- Tab 显示顺序 -->
-                    <div class="setting-row" data-tab-group="settings">
+                    <!-- Tab 显示顺序(归入「系统」组) -->
+                    <div class="setting-row" data-tab-group="system">
                         <div class="setting-info">
                             <div class="setting-title">Tab 显示顺序</div>
                             <div class="setting-hint">用逗号分隔 Tab 名称,从左到右依次显示;未列出的追加到末尾</div>
@@ -546,8 +577,8 @@
                         <input class="input" id="tab-order" type="text" placeholder="网址, 新闻, Github, 股市, 待办, 工具, 服务">
                     </div>
 
-                    <!-- 自定义 Tab 页面 -->
-                    <div class="setting-row setting-row-col" data-tab-group="settings">
+                    <!-- 自定义 Tab 页面(归入「系统」组) -->
+                    <div class="setting-row setting-row-col" data-tab-group="system">
                         <div class="setting-info">
                             <div class="setting-title">自定义 Tab 页面</div>
                             <div class="setting-hint">每行一条,格式:页面名称|地址|备注(备注可空)。地址以 http:// 或 https:// 开头则内嵌网页;否则按本地文件路径读取,Markdown 自动渲染。名称用于 Tab 显示顺序,备注显示为 Tab 说明</div>
@@ -722,6 +753,37 @@
                         <input class="input" id="upload-max-size" type="number" min="1" step="1" placeholder="默认 512MB">
                     </div>
 
+                    <!-- LocalSend 本机名称 -->
+                    <div class="setting-row" data-tab-group="tools">
+                        <div class="setting-info">
+                            <div class="setting-title">LocalSend 本机名称</div>
+                            <div class="setting-hint">局域网内其他 LocalSend 设备看到的名称;留空则随机生成,如「工具箱-3F8A」</div>
+                        </div>
+                        <input class="input" id="localsend-alias" type="text" placeholder="留空=随机生成" autocomplete="off">
+                    </div>
+
+                    <!-- LocalSend 接收模式 -->
+                    <div class="setting-row" data-tab-group="tools">
+                        <div class="setting-info">
+                            <div class="setting-title">LocalSend 接收模式</div>
+                            <div class="setting-hint">自动接收:直接保存到接收位置;手动确认:收到文件请求时弹窗由你决定</div>
+                        </div>
+                        <label class="radio-field"><input type="radio" name="localsend-mode" id="localsend-auto" value="auto"><span>自动接收</span></label>
+                        <label class="radio-field"><input type="radio" name="localsend-mode" id="localsend-manual" value="manual" checked><span>手动确认</span></label>
+                    </div>
+
+                    <!-- LocalSend 接收位置 -->
+                    <div class="setting-row" data-tab-group="tools">
+                        <div class="setting-info">
+                            <div class="setting-title">LocalSend 接收位置</div>
+                            <div class="setting-hint">接收文件保存目录,留空则保存到用户配置目录/TengTuToolbox/LocalSend接收</div>
+                        </div>
+                        <div class="browser-path-group">
+                            <input class="input" id="localsend-recvdir" type="text" placeholder="留空=默认接收目录" spellcheck="false">
+                            <button class="btn-ghost" id="localsend-pickdir" type="button" title="选择接收目录">选择目录</button>
+                        </div>
+                    </div>
+
                     <!-- 待办文件路径 -->
                     <div class="setting-row" data-tab-group="todo">
                         <div class="setting-info">
@@ -787,8 +849,8 @@
                         </div>
                     </div>
 
-                    <!-- 随机古诗 -->
-                    <div class="setting-row" data-tab-group="poem">
+                    <!-- 随机古诗(归入「系统」组) -->
+                    <div class="setting-row" data-tab-group="system">
                         <div class="setting-info">
                             <div class="setting-title">随机古诗</div>
                             <div class="setting-hint">footer 版本后面水平滚动显示一首随机古诗,可点击换一首、朗读(TTS)</div>
@@ -866,6 +928,19 @@
         </div>
     </div>
 
+    <!-- LocalSend 文件接收确认弹窗(手动接收模式) -->
+    <div class="ls-offer-mask" id="ls-offer-mask" hidden>
+        <div class="ls-offer-dialog">
+            <div class="ls-offer-title">收到文件发送请求</div>
+            <div class="ls-offer-sender" id="ls-offer-sender"></div>
+            <ul class="ls-offer-files" id="ls-offer-files"></ul>
+            <div class="ls-offer-actions">
+                <button class="btn-ghost" id="ls-offer-reject">拒绝</button>
+                <button class="btn-primary" id="ls-offer-accept">接受</button>
+            </div>
+        </div>
+    </div>
+
     <footer class="footer">
         <span class="footer-version"><span class="dot"></span>腾图工具箱 <span id="footer-version-text"></span></span>
         <div class="footer-desc" id="footer-desc">

+ 285 - 2
frontend/public/style.css

@@ -488,13 +488,70 @@ body::before {
     background: var(--glass);
 }
 
-/* 设置分组标题:每组设置项前显示所属 Tab 名称 */
+/* 设置分组标题:每组设置项前显示所属 Tab 名称;可点击展开/收起(默认为收缩) */
 .setting-group-title {
-    margin: 2px 6px 6px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    width: 100%;
+    margin: 2px 0 6px;
+    padding: 6px 8px;
+    border: none;
+    border-radius: 6px;
+    background: transparent;
     color: var(--accent);
     font-size: calc(14.5px + var(--font-delta));
     font-weight: 700;
     letter-spacing: 0.5px;
+    text-align: left;
+    cursor: pointer;
+    user-select: none;
+}
+.setting-group-title:hover {
+    background: rgba(255, 255, 255, 0.06);
+}
+
+/* 标题前箭头:收缩时朝右,展开时朝下 */
+.setting-group-arrow {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 16px;
+    height: 16px;
+    font-size: 12px;
+    color: var(--muted);
+    transition: transform 0.18s ease;
+}
+.setting-group:not(.is-collapsed) .setting-group-arrow {
+    transform: rotate(90deg);
+}
+
+/* 组名 */
+.setting-group-label {
+    flex: 1;
+}
+
+/* 设置项数量徽标 */
+.setting-group-count {
+    padding: 2px 8px;
+    border-radius: 10px;
+    background: rgba(255, 255, 255, 0.07);
+    color: var(--muted);
+    font-size: calc(12px + var(--font-delta));
+    font-weight: 500;
+    letter-spacing: 0;
+}
+
+/* 收缩状态:隐藏设置项 */
+.setting-group.is-collapsed .setting-group-rows {
+    display: none;
+}
+
+/* 展开状态:设置项容器以 flex 排列 */
+.setting-group-rows {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
 }
 
 .setting-group-title:first-child {
@@ -714,6 +771,29 @@ body::before {
     transform: none;
 }
 
+/* 乒乓按钮「停止」状态:红色危险色 */
+.btn-danger {
+    height: 34px;
+    padding: 0 20px;
+    border: 1px solid rgba(255, 90, 90, 0.5);
+    border-radius: 9px;
+    background: rgba(255, 77, 77, 0.12);
+    color: #ff6a6a;
+    font-size: calc(15px + var(--font-delta));
+    font-weight: 600;
+    cursor: pointer;
+    transition: transform 0.15s ease, background 0.15s ease, opacity 0.15s ease;
+}
+.btn-danger:hover {
+    background: rgba(255, 77, 77, 0.22);
+    transform: translateY(-1px);
+}
+.btn-danger:disabled {
+    opacity: 0.6;
+    cursor: default;
+    transform: none;
+}
+
 .btn-ghost {
     height: 30px;
     padding: 0 14px;
@@ -844,6 +924,18 @@ body::before {
     white-space: nowrap;
 }
 
+/* 股票名称 = 股吧链接:下划线提示可点击,悬停高亮 */
+.stock-name.stock-guba {
+    color: inherit;
+    text-decoration: none;
+    border-bottom: 1px dashed rgba(255, 255, 255, 0.22);
+    transition: color 0.15s ease, border-color 0.15s ease;
+}
+.stock-name.stock-guba:hover {
+    color: #ff5a5a;
+    border-bottom-color: #ff5a5a;
+}
+
 .stock-code {
     color: var(--muted);
     font-size: calc(13.5px + var(--font-delta));
@@ -2247,6 +2339,49 @@ a.ver-muted:hover {
 .tool-status .tool-url:hover {
     color: #6ff0a8;
 }
+/* 工具排序按钮:标题右侧的上移/下移,悬停才显示,避免视觉干扰 */
+.tool-order-btns {
+    display: inline-flex;
+    gap: 4px;
+    margin-left: 10px;
+    opacity: 0;
+    transition: opacity 0.15s ease;
+}
+.tool-card-head:hover .tool-order-btns {
+    opacity: 1;
+}
+.tool-order-btn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 22px;
+    height: 22px;
+    padding: 0;
+    border: 1px solid var(--glass-border);
+    border-radius: 6px;
+    background: var(--glass-strong);
+    color: var(--muted);
+    font-size: 12px;
+    line-height: 1;
+    cursor: pointer;
+    user-select: none;
+}
+.tool-order-btn:hover {
+    color: var(--accent);
+    border-color: var(--accent);
+}
+.tool-order-btn:disabled {
+    opacity: 0.3;
+    cursor: default;
+}
+.tool-order-btn:disabled:hover {
+    color: var(--muted);
+    border-color: var(--glass-border);
+}
+.tool-order-btn:focus-visible {
+    outline: 2px solid var(--accent);
+    outline-offset: 1px;
+}
 .tool-desc {
     margin: 0 0 12px;
     color: var(--muted);
@@ -2344,6 +2479,154 @@ a.ver-muted:hover {
     border: none;
 }
 
+/* LocalSend 文件互传 */
+.localsend-config {
+    margin: 12px 0 4px;
+    padding: 10px 12px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius-sm);
+    background: var(--glass);
+}
+.localsend-config .tool-row {
+    margin-bottom: 10px;
+}
+.localsend-config .tool-row:last-child {
+    margin-bottom: 0;
+}
+.tool-label {
+    min-width: 64px;
+    font-size: calc(13.5px + var(--font-delta));
+    color: var(--muted);
+}
+.localsend-config .input {
+    flex: 1;
+    min-width: 0;
+}
+.radio-field {
+    display: inline-flex;
+    align-items: center;
+    gap: 5px;
+    font-size: calc(13.5px + var(--font-delta));
+    color: var(--text);
+    cursor: pointer;
+}
+.radio-field input[type="radio"] {
+    accent-color: var(--accent);
+    cursor: pointer;
+}
+.localsend-devices {
+    margin-top: 14px;
+}
+.localsend-device-list {
+    list-style: none;
+    margin: 8px 0 0;
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+}
+.localsend-device-list li {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    padding: 8px 10px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius-sm);
+    background: var(--glass);
+    font-size: calc(13.5px + var(--font-delta));
+}
+.localsend-device-list .ls-dev-name {
+    color: var(--ink);
+    font-weight: 500;
+}
+.localsend-device-list .ls-dev-meta {
+    color: var(--muted);
+    font-size: calc(12.5px + var(--font-delta));
+}
+.localsend-device-list .ls-dev-online {
+    color: #3ddc84;
+    font-size: calc(12px + var(--font-delta));
+}
+.localsend-empty {
+    color: var(--muted);
+    font-size: calc(13px + var(--font-delta));
+    padding: 6px 2px;
+}
+
+/* LocalSend 文件接收确认弹窗 */
+.ls-offer-mask {
+    position: fixed;
+    inset: 0;
+    z-index: 3000;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: rgba(0, 0, 0, 0.55);
+    backdrop-filter: blur(4px);
+}
+/* 必须显式覆盖:display:flex 会覆盖 HTML hidden 属性的 display:none,
+   否则弹窗常驻显示且点击接受/拒绝后无法隐藏 */
+.ls-offer-mask[hidden] {
+    display: none;
+}
+.ls-offer-dialog {
+    width: min(440px, 90vw);
+    max-height: 80vh;
+    overflow: auto;
+    padding: 22px 24px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius);
+    background: var(--panel);
+    box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
+}
+.ls-offer-title {
+    font-size: calc(16px + var(--font-delta));
+    font-weight: 600;
+    color: var(--ink);
+    margin-bottom: 8px;
+}
+.ls-offer-sender {
+    font-size: calc(13.5px + var(--font-delta));
+    color: var(--muted);
+    margin-bottom: 12px;
+}
+.ls-offer-files {
+    list-style: none;
+    margin: 0 0 16px;
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+}
+.ls-offer-files li {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    padding: 8px 10px;
+    border: 1px solid var(--glass-border);
+    border-radius: var(--radius-sm);
+    background: var(--glass);
+    font-size: calc(13.5px + var(--font-delta));
+}
+.ls-offer-files .ls-file-name {
+    color: var(--ink);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.ls-offer-files .ls-file-size {
+    color: var(--muted);
+    font-size: calc(12.5px + var(--font-delta));
+    white-space: nowrap;
+}
+.ls-offer-actions {
+    display: flex;
+    justify-content: flex-end;
+    gap: 10px;
+}
+
 /* 翻译工具:输入框 + 结果区 */
 .tr-input {
     width: 100%;

+ 482 - 52
frontend/src/main.js

@@ -1,5 +1,6 @@
 import {Events, WML, Window} from "@wailsio/runtime"; // 导入 Wails 运行时的事件系统、WML 工具与窗口控制 API
 import {SettingsService, AppSettings} from "../bindings/TengTuToolbox/index.js"; // 设置服务与配置模型
+import {LocalSendService} from "../bindings/TengTuToolbox/index.js"; // LocalSend 服务(发现设备/接收文件)
 import {getCalendarInfo} from "./lunar.js"; // 农历与节日计算
 
 WML.Enable(); // 为带 data-wml-openURL 属性的链接绑定打开行为(动态内容渲染后需调 WML.Reload())
@@ -50,6 +51,7 @@ const FALLBACK_SETTINGS = {
     newsCount: 30,                // 新闻页显示条数
     githubRefreshMinutes: 120,    // Github 自动刷新间隔(分钟)
     fontDelta: 0,                 // 全局字号偏移(px),+-3,默认 0(基准字号已含 +2)
+    toolOrder: ['websvc', 'translate', 'qrcode', 'ntfy', 'chat', 'poem', 'screenshot', 'shutdown', 'md5', 'sha256', 'led', 'reminder', 'localsend'], // 工具页卡片默认顺序
     stockTicker: {enabled: false, width: 1400, fontSize: 14, color: '#ff4d4d', speed: 80, transparent: true, x: -1, y: -1},
     newsTicker: {enabled: false, width: 1400, fontSize: 14, color: '#3ddc84', speed: 60, transparent: true, x: -1, y: -1},
     poem: {enabled: false, color: '#c98a3a', fontSize: 16, tts: false, speed: 18},
@@ -338,30 +340,146 @@ function applyTabOrder(order) {
     if (settingList) {
         const grouped = { system: [] }; // system 组 + 各 Tab 组
         settingList.querySelectorAll('.setting-row').forEach(row => {
-            const g = row.dataset.tabGroup || 'system';
+            // 兼容旧标记:LocalSend 曾用单数「tool」,统一归入「tools」组
+            let g = row.dataset.tabGroup || 'system';
+            if (g === 'tool') g = 'tools';
             if (!grouped[g]) grouped[g] = [];
             grouped[g].push(row);
         });
-        // 分组容器:系统在前,其后按 Tab 顺序;每组一个带边框的卡片,明显区分各页设置
+        // 分组容器:系统在前,其后按 Tab 顺序;未对应 Tab 的组(如 poem)追加到末尾
         const groupOrder = ['system', ...ordered];
+        // 补全未出现在 Tab 顺序中的组(例如 poem),保持首次出现顺序
+        const named = new Map([['system', '系统'], ['poem', '古诗'], ['mouse', '鼠标提醒']]);
+        Object.keys(grouped).forEach(g => {
+            if (!groupOrder.includes(g)) groupOrder.push(g);
+        });
         settingList.querySelectorAll('.setting-group').forEach(el => el.remove());
         const frag = document.createDocumentFragment();
         groupOrder.forEach(g => {
             const gRows = grouped[g];
             if (!gRows || !gRows.length) return; // 该组无设置项则不显示
+            // 设置分组:默认收缩,点击标题展开/收起,减少滚动,专注当前设置
             const group = document.createElement('section');
-            group.className = 'setting-group';
-            const title = document.createElement('div');
+            group.className = 'setting-group is-collapsed';
+            const title = document.createElement('button');
+            title.type = 'button';
             title.className = 'setting-group-title';
-            title.textContent = g === 'system' ? '系统' : getTabName(g); // 内置或自定义 Tab 名
+            title.setAttribute('aria-expanded', 'false');
+            // 箭头 + 组名 + 设置项数量
+            const arrow = document.createElement('span');
+            arrow.className = 'setting-group-arrow';
+            arrow.textContent = '▸';
+            const label = document.createElement('span');
+            label.className = 'setting-group-label';
+            label.textContent = named.get(g) || getTabName(g); // 内置/自定义 Tab 名,无 Tab 的组用映射名
+            const count = document.createElement('span');
+            count.className = 'setting-group-count';
+            count.textContent = `${gRows.length} 项`;
+            title.appendChild(arrow);
+            title.appendChild(label);
+            title.appendChild(count);
+            // 点击标题切换展开/收起
+            title.addEventListener('click', () => {
+                const collapsed = group.classList.toggle('is-collapsed');
+                title.setAttribute('aria-expanded', String(!collapsed));
+            });
             group.appendChild(title);
-            gRows.forEach(row => group.appendChild(row));
+            // 把设置项包进一个容器,便于统一隐藏/显示
+            const rowsWrap = document.createElement('div');
+            rowsWrap.className = 'setting-group-rows';
+            gRows.forEach(row => rowsWrap.appendChild(row));
+            group.appendChild(rowsWrap);
             frag.appendChild(group);
         });
         settingList.appendChild(frag);
     }
 }
 
+// 工具页卡片排序:按设置中的 toolOrder 重排 DOM 顺序,并刷新上移/下移按钮禁用状态。
+// 未在 toolOrder 中的工具(如新增工具)追加到末尾,保证全部工具都能显示。
+const DEFAULT_TOOL_ORDER = [
+    'websvc', 'translate', 'qrcode', 'ntfy', 'chat', 'poem',
+    'screenshot', 'shutdown', 'md5', 'sha256', 'led', 'reminder', 'localsend',
+];
+
+function applyToolOrder(order) {
+    const panel = document.getElementById('tab-tools');
+    if (!panel) return;
+    const cards = Array.from(panel.querySelectorAll('.tool-card'));
+    if (!cards.length) return;
+    // 合法 id:DOM 中实际存在的 data-tool
+    const valid = new Set(cards.map(c => c.dataset.tool));
+    const ordered = [...new Set((order || []).filter(id => valid.has(id)))];
+    cards.forEach(c => { if (!ordered.includes(c.dataset.tool)) ordered.push(c.dataset.tool); });
+    // 重排:按 ordered 依次 appendChild(追加即移到末尾,实现重排)
+    const cardMap = {};
+    cards.forEach(c => { cardMap[c.dataset.tool] = c; });
+    ordered.forEach(id => { if (cardMap[id]) panel.appendChild(cardMap[id]); });
+    refreshToolOrderBtns();
+}
+
+// 刷新每个工具卡片上移/下移按钮的可用状态(首项禁用上移,末项禁用下移)
+function refreshToolOrderBtns() {
+    const panel = document.getElementById('tab-tools');
+    if (!panel) return;
+    const cards = Array.from(panel.querySelectorAll('.tool-card'));
+    cards.forEach((card, i) => {
+        const up = card.querySelector('.tool-up');
+        const down = card.querySelector('.tool-down');
+        if (up) up.disabled = i === 0;
+        if (down) down.disabled = i === cards.length - 1;
+    });
+}
+
+// 读取当前工具卡片顺序(DOM 顺序),返回工具 id 数组
+function collectToolOrder() {
+    const panel = document.getElementById('tab-tools');
+    if (!panel) return [];
+    return Array.from(panel.querySelectorAll('.tool-card')).map(c => c.dataset.tool);
+}
+
+// 上移/下移单个工具卡片,并立即保存顺序到设置
+function moveToolCard(card, dir) {
+    const panel = document.getElementById('tab-tools');
+    if (!panel || !card) return;
+    const cards = Array.from(panel.querySelectorAll('.tool-card'));
+    const i = cards.indexOf(card);
+    const j = i + dir;
+    if (i < 0 || j < 0 || j >= cards.length) return;
+    if (dir < 0) {
+        panel.insertBefore(card, cards[j]); // 上移:插到前一项之前
+    } else {
+        panel.insertBefore(cards[j], card); // 下移:把后一项插到当前项之前(等效当前项后移)
+    }
+    refreshToolOrderBtns();
+    // 持久化顺序(不阻塞 UI),更新内存配置供保存设置时提交
+    const order = collectToolOrder();
+    currentSettings.toolOrder = order;
+    // 直接调用后端保存 toolOrder 字段(仅该字段),避免全量表单校验副作用
+    if (typeof SettingsService?.SaveSettings === 'function') {
+        SettingsService.SaveSettings(currentSettings).then(() => {
+            // 保存成功,无需额外处理(currentSettings 已含最新 toolOrder)
+        }).catch(err => console.warn('保存工具顺序失败:', err));
+    }
+}
+
+// 绑定工具排序按钮点击事件(事件委托,防止点击冒泡触发折叠切换)
+function initToolOrder() {
+    // 排序按钮直接绑定在卡片标题上(捕获阶段先于折叠监听),
+    // 点击排序按钮既不折叠,也不冒泡触发标题折叠切换
+    document.querySelectorAll('.tool-card-head').forEach(head => {
+        head.addEventListener('click', (e) => {
+            const btn = e.target.closest('.tool-order-btn');
+            if (!btn) return;
+            e.preventDefault();
+            e.stopPropagation(); // 阻止冒泡到 .tool-card-head 的折叠切换
+            const card = btn.closest('.tool-card');
+            if (!card) return;
+            moveToolCard(card, btn.classList.contains('tool-up') ? -1 : 1);
+        });
+    });
+}
+
 // ------------------------------------------------------------------------
 // Tab 切换:点击按钮,仅显示对应的内容面板
 // ------------------------------------------------------------------------
@@ -599,9 +717,11 @@ async function syncForm(settings) {
     syncTickerForm('news', settings.newsTicker);
     syncPoemForm(settings.poem);
     syncMouseAlertForm(settings.mouseAlert);
+    syncLocalSendForm(settings.localSend);
     applyTheme(settings.theme, false);
     applyFontDelta(settings.fontDelta ?? FALLBACK_SETTINGS.fontDelta); // 应用全局字号偏移(默认 +2)
     applyTabOrder(settings.tabOrder); // 按设置重排 Tab 顺序
+    applyToolOrder(settings.toolOrder); // 按设置重排工具卡片顺序
     // 启动/加载设置后,默认激活 Tab 顺序的第 1 位(若当前无激活 Tab 或激活的是旧的 links)
     const firstId = (settings.tabOrder || []).find(id => id !== 'settings');
     const activeId = document.querySelector('.tab-btn.active')?.dataset.tab;
@@ -817,9 +937,41 @@ function collectForm() {
         newsTicker: collectTicker('news'),
         poem: collectPoem(),
         mouseAlert: collectMouseAlert(),
+        localSend: collectLocalSend(),
+        toolOrder: collectToolOrder(), // 工具页卡片顺序(含用户刚调整过的)
+    };
+}
+
+// 从设置页收集 LocalSend 配置(本机名/接收位置/接收模式)
+function collectLocalSend() {
+    const prev = currentSettings?.localSend || {};
+    const alias = (document.getElementById('localsend-alias')?.value || '').trim();
+    const recvDir = (document.getElementById('localsend-recvdir')?.value || '').trim();
+    const auto = document.getElementById('localsend-auto')?.checked ?? !!prev.autoAccept;
+    return {
+        alias,
+        receiveDir: recvDir,
+        autoAccept: auto,
+        enabled: !!prev.enabled,
+        port: prev.port || 53317,
+        fingerprint: prev.fingerprint || '',
     };
 }
 
+// 回填 LocalSend 配置到设置页表单
+function syncLocalSendForm(localSend) {
+    const ls = localSend || {};
+    const aliasInput = document.getElementById('localsend-alias');
+    const recvDirInput = document.getElementById('localsend-recvdir');
+    const autoRadio = document.getElementById('localsend-auto');
+    const manualRadio = document.getElementById('localsend-manual');
+    if (aliasInput) aliasInput.value = ls.alias || '';
+    if (recvDirInput) recvDirInput.value = ls.receiveDir || '';
+    if (autoRadio && manualRadio) {
+        (ls.autoAccept ? autoRadio : manualRadio).checked = true;
+    }
+}
+
 // 从控件收集滚动条配置;位置(x/y)由拖动自动保存,表单不修改
 function collectTicker(kind) {
     const inputs = tickerInputs[kind];
@@ -1079,6 +1231,13 @@ async function saveSettings() {
         loadStocks(); // 代码变化后立即刷新行情
         restartAutoTimers(); // 按新设置的间隔重建各面板定时器
 
+        // 把 LocalSend 配置同步到后端服务(运行中则即时生效,未运行则下次启动生效)
+        try {
+            const ls = saved.localSend || {};
+            await LocalSendService.SetConfig(ls.alias || '', ls.receiveDir || '', !!ls.autoAccept);
+            await lsRefreshDevices();
+        } catch (e) { console.warn('同步 LocalSend 配置失败:', e); }
+
         isDirty = false; // 保存成功,清除未保存标记
         saveStatus.textContent = '已保存并生效';
         saveStatus.classList.add('is-ok');
@@ -1105,6 +1264,11 @@ document.querySelectorAll('#tab-settings .input, #tab-settings input[type="check
     input.addEventListener('change', markDirty); // 开关切换
 });
 
+// 设置页 radio(如 LocalSend 接收模式)也标记未保存
+document.querySelectorAll('#tab-settings input[type="radio"]').forEach(input => {
+    input.addEventListener('change', markDirty);
+});
+
 // 焦点移出设置面板(点击面板外区域 / 切换 tab)时自动保存
 const settingsPanel = document.getElementById('tab-settings');
 settingsPanel.addEventListener('focusout', (event) => {
@@ -1375,22 +1539,27 @@ async function loadGithub() {
             const releasesURL = feed.repo
                 ? 'https://github.com/' + feed.repo + '/releases'
                 : (feed.url || '#');
+            // 本地版本发布时间(鼠标提示):按本地版本号在 release 列表中匹配(作用于所有含本地版本的徽标)
+            const versionTip = feed.versionTime ? `(发布时间 ${escapeHtml(feed.versionTime)})` : '';
+            const versionTipFull = versionTip ? `,本地版本发布时间 ${escapeHtml(feed.versionTime)}` : '';
             if (feed.latest) {
+                // 最新正式版发布时间(鼠标提示)
+                const latestTip = feed.latestTime ? `(发布时间 ${escapeHtml(feed.latestTime)})` : '';
                 if (feed.hasUpdate) {
                     // 发现新版本:高亮可点击下载 + 检视按钮
                     const badgeText = `发现新版本 ${escapeHtml(feed.latest)}`;
-                    versionBadge = `<a class="ver-badge ver-update" href="${escapeHtml(feed.releaseURL || feed.url)}" target="_blank" data-wml-openURL="${escapeHtml(feed.releaseURL || feed.url)}" title="点击前往下载">● ${badgeText}</a>`;
-                    if (feed.version) versionBadge += `<a class="ver-local" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表">本地 ${escapeHtml(feed.version)}</a>`;
+                    versionBadge = `<a class="ver-badge ver-update" href="${escapeHtml(feed.releaseURL || feed.url)}" target="_blank" data-wml-openURL="${escapeHtml(feed.releaseURL || feed.url)}" title="点击前往下载${latestTip}">● ${badgeText}</a>`;
+                    if (feed.version) versionBadge += `<a class="ver-local" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表${versionTipFull}">本地 ${escapeHtml(feed.version)}</a>`;
                     versionBadge += `<button class="btn-ghost repo-check-btn" data-url="${escapeHtml(feed.url)}" data-latest="${escapeHtml(feed.latest)}">检视</button>`;
                 } else if (feed.version) {
                     // 本地已是最新版本:勾选本地版本号,不再显示「最新 xxx」标签
-                    versionBadge = `<a class="ver-badge ver-ok" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表">✓ 本地 ${escapeHtml(feed.version)}</a>`;
+                    versionBadge = `<a class="ver-badge ver-ok" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表${latestTip}${versionTipFull}">✓ 本地 ${escapeHtml(feed.version)}</a>`;
                 } else {
                     // 无本地版本可勾选时,仍显示最新版本徽标
-                    versionBadge = `<span class="ver-badge ver-ok">✓ 最新 ${escapeHtml(feed.latest)}</span>`;
+                    versionBadge = `<span class="ver-badge ver-ok" title="${latestTip}">✓ 最新 ${escapeHtml(feed.latest)}</span>`;
                 }
             } else if (feed.version) {
-                versionBadge = `<a class="ver-badge ver-muted" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表">本地 ${escapeHtml(feed.version)}</a>`;
+                versionBadge = `<a class="ver-badge ver-muted" href="${escapeHtml(releasesURL)}" target="_blank" data-wml-openURL="${escapeHtml(releasesURL)}" title="查看版本列表${versionTipFull}">本地 ${escapeHtml(feed.version)}</a>`;
             }
 
             // Latest 之后的版本(Pre-release 等):仅列出展示,不参与更新比较
@@ -2566,6 +2735,250 @@ Events.On('periodicReminder', (event) => {
     showTodoReminder(event.data);
 });
 
+// ------------------------------------------------------------------------
+// 工具页:LocalSend 文件互传——组播发现设备,HTTPS 接收文件
+// ------------------------------------------------------------------------
+const lsToggleBtn = document.getElementById('localsend-toggle');   // 启动/停止乒乓按钮
+let lsRunning = false; // 当前 LocalSend 服务是否运行(乒乓按钮状态)
+const lsHintEl = document.getElementById('localsend-hint');         // 操作提示
+const lsStatusEl = document.getElementById('localsend-status');     // 状态徽标
+const lsAliasInput = document.getElementById('localsend-alias');    // 本机名称(设置页)
+const lsAutoRadio = document.getElementById('localsend-auto');      // 自动接收(设置页)
+const lsManualRadio = document.getElementById('localsend-manual');  // 手动确认(设置页)
+const lsRecvDirInput = document.getElementById('localsend-recvdir');// 接收位置(设置页)
+const lsPickDirBtn = document.getElementById('localsend-pickdir');  // 选择目录(设置页)
+const lsSendHintEl = document.getElementById('localsend-send-hint');// 发送状态提示
+const lsDevicesBox = document.getElementById('localsend-devices');  // 设备列表容器
+const lsDeviceList = document.getElementById('localsend-device-list'); // 设备列表 ul
+
+// 确认弹窗元素
+const lsOfferMask = document.getElementById('ls-offer-mask');
+const lsOfferSender = document.getElementById('ls-offer-sender');
+const lsOfferFiles = document.getElementById('ls-offer-files');
+const lsOfferAccept = document.getElementById('ls-offer-accept');
+const lsOfferReject = document.getElementById('ls-offer-reject');
+
+let lsCurrentOffer = null; // 当前待确认的 fileOffer(手动模式)
+
+// 读取当前 LocalSend 配置(浏览器预览时回退默认值)
+async function lsLoadConfig() {
+    if (!LocalSendService?.GetConfig) return null;
+    try {
+        return await LocalSendService.GetConfig();
+    } catch (err) {
+        console.warn('获取 LocalSend 配置失败:', err);
+        return null;
+    }
+}
+
+// 同步运行状态到 UI(配置字段已移入设置页,此处只更新状态徽标与按钮)
+function lsSyncStatus(cfg) {
+    if (!cfg) return;
+    lsRunning = !!cfg.enabled;
+    lsStatusEl.textContent = lsRunning ? '运行中' : '';
+    lsStatusEl.className = 'tool-status' + (lsRunning ? ' is-on' : '');
+    // 乒乓按钮:运行中显示「停止服务」(红),未运行显示「启动服务」(主色)
+    lsToggleBtn.textContent = lsRunning ? '停止服务' : '启动服务';
+    lsToggleBtn.classList.toggle('btn-danger', lsRunning);
+    lsToggleBtn.classList.toggle('btn-primary', !lsRunning);
+    lsToggleBtn.disabled = false;
+    lsHintEl.textContent = lsRunning ? '服务已启动,等待其他设备发送文件…' : '';
+}
+
+// 渲染设备列表
+function lsRenderDevices(devices) {
+    if (!lsDevicesBox || !lsDeviceList) return;
+    const list = Array.isArray(devices) ? devices.filter(Boolean) : [];
+    lsDevicesBox.hidden = false;
+    if (!list.length) {
+        lsDeviceList.innerHTML = '<li class="localsend-empty">未发现其他设备(确保对方已启动 LocalSend)</li>';
+        return;
+    }
+    lsDeviceList.innerHTML = list.map(d => {
+        const online = d.available ? '在线' : '离线';
+        const type = d.deviceType || 'desktop';
+        const proto = d.protocol || 'https';
+        const ip = escapeHtml(d.ip || '');
+        return `<li data-ls-ip="${ip}">
+            <span class="ls-dev-name">${escapeHtml(d.alias || d.ip)}</span>
+            <span class="ls-dev-meta">${ip} · ${type} · ${proto} · ${escapeHtml(d.version || '')}</span>
+            <span class="ls-dev-online">● ${online}</span>
+            <button class="btn-ghost ls-dev-send" data-ls-ip="${ip}" type="button" title="发送文件到该设备">发送</button>
+        </li>`;
+    }).join('');
+}
+
+// 格式化文件大小
+function lsFormatSize(bytes) {
+    bytes = Number(bytes) || 0;
+    if (bytes >= 1024 * 1024 * 1024) return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
+    if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
+    if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
+    return bytes + ' B';
+}
+
+// 显示文件接收确认弹窗(手动模式)
+function lsShowOffer(offer) {
+    if (!offer) return;
+    lsCurrentOffer = offer;
+    lsOfferSender.textContent = `来自:${offer.alias || '未知设备'}`;
+    lsOfferFiles.innerHTML = (offer.files || []).map(f =>
+        `<li><span class="ls-file-name" title="${escapeHtml(f.fileName || '')}">${escapeHtml(f.fileName || '')}</span>` +
+        `<span class="ls-file-size">${lsFormatSize(f.size)}</span></li>`
+    ).join('') || '<li class="localsend-empty">(无文件)</li>';
+    lsOfferMask.hidden = false;
+}
+
+function lsHideOffer() {
+    lsOfferMask.hidden = true;
+    lsCurrentOffer = null;
+}
+
+// 乒乓按钮:点击切换启动/停止(配置来自设置页,已随保存写入 settings.json)
+if (lsToggleBtn) lsToggleBtn.addEventListener('click', async () => {
+    if (lsRunning) {
+        // 当前运行中 → 停止
+        lsHintEl.textContent = '停止中…';
+        lsToggleBtn.disabled = true;
+        try {
+            await LocalSendService.Stop();
+            const cfg = await lsLoadConfig();
+            lsSyncStatus(cfg);
+            lsHintEl.textContent = '服务已停止';
+            lsDevicesBox.hidden = true;
+        } catch (err) {
+            lsHintEl.textContent = '停止失败:' + (err?.message || err);
+            lsToggleBtn.disabled = false;
+        }
+    } else {
+        // 当前未运行 → 启动
+        lsHintEl.textContent = '启动中…';
+        lsToggleBtn.disabled = true;
+        try {
+            await LocalSendService.Start();
+            const cfg = await lsLoadConfig();
+            lsSyncStatus(cfg);
+            lsHintEl.textContent = '服务已启动,等待其他设备发送文件…';
+            lsDevicesBox.hidden = false;
+            await lsRefreshDevices(); // 立即拉取一次设备
+        } catch (err) {
+            lsHintEl.textContent = '启动失败:' + (err?.message || err);
+            lsToggleBtn.disabled = false;
+        }
+    }
+});
+
+// 选择接收目录(设置页按钮,选后标记未保存并提示)
+if (lsPickDirBtn) lsPickDirBtn.addEventListener('click', async () => {
+    try {
+        const dir = await SettingsService.PickWebDir(); // 复用目录选择对话框
+        if (!dir) return;
+        lsRecvDirInput.value = dir;
+        markDirty();
+        lsRecvDirInput.dispatchEvent(new Event('input'));
+    } catch (err) {
+        console.warn('选择 LocalSend 接收目录失败:', err);
+    }
+});
+
+// 点击设备项「发送」按钮(事件委托):先弹文件选择框,选中后直接发送到该设备
+if (lsDeviceList) lsDeviceList.addEventListener('click', async (e) => {
+    const btn = e.target.closest('.ls-dev-send');
+    if (!btn) return;
+    const ip = btn.getAttribute('data-ls-ip');
+    // 每次点击「发送」都重新选择文件(不预选,避免误发)
+    let files;
+    try {
+        files = await SettingsService.PickSendFiles();
+    } catch (err) {
+        lsSendHintEl.textContent = '选择文件失败:' + (err?.message || err);
+        return;
+    }
+    if (!files || !files.length) {
+        lsSendHintEl.textContent = '未选择文件';
+        return;
+    }
+    btn.disabled = true;
+    lsSendHintEl.textContent = `正在发送 ${files.length} 个文件到 ${ip}…`;
+    try {
+        await LocalSendService.SendFiles(files, ip);
+        lsSendHintEl.textContent = `已发送 ${files.length} 个文件到 ${ip} ✓`;
+        setTimeout(() => { lsSendHintEl.textContent = ''; }, 4000);
+    } catch (err) {
+        lsSendHintEl.textContent = '发送失败:' + (err?.message || err);
+    } finally {
+        btn.disabled = false;
+    }
+});
+
+// 拉取并渲染设备列表
+async function lsRefreshDevices() {
+    if (!LocalSendService?.GetDevices) return;
+    try {
+        const devices = await LocalSendService.GetDevices();
+        lsRenderDevices(devices);
+    } catch (err) {
+        console.warn('获取 LocalSend 设备失败:', err);
+    }
+}
+
+// 订阅设备发现事件
+Events.On('localsend:devices', (event) => {
+    lsRenderDevices(event.data);
+});
+
+// 订阅文件接收请求事件(手动模式弹出确认)
+Events.On('localsend:fileOffer', (event) => {
+    lsShowOffer(event.data);
+});
+
+// 订阅错误事件
+Events.On('localsend:error', (event) => {
+    const msg = event.data?.message || String(event.data || '未知错误');
+    lsHintEl.textContent = 'LocalSend 错误:' + msg;
+    lsStatusEl.textContent = '错误';
+    lsStatusEl.className = 'tool-status';
+    lsRunning = false;
+    lsToggleBtn.textContent = '启动服务';
+    lsToggleBtn.classList.remove('btn-danger');
+    lsToggleBtn.classList.add('btn-primary');
+    lsToggleBtn.disabled = false;
+});
+
+// 接受文件
+lsOfferAccept?.addEventListener('click', async () => {
+    if (!lsCurrentOffer) return;
+    try {
+        await LocalSendService.ConfirmTransfer(lsCurrentOffer.sessionId, true);
+        lsHintEl.textContent = '已接受,文件接收中…';
+    } catch (err) {
+        lsHintEl.textContent = '接受失败:' + (err?.message || err);
+    }
+    lsHideOffer();
+});
+
+// 拒绝文件
+lsOfferReject?.addEventListener('click', async () => {
+    if (!lsCurrentOffer) return;
+    try {
+        await LocalSendService.ConfirmTransfer(lsCurrentOffer.sessionId, false);
+        lsHintEl.textContent = '已拒绝该文件';
+    } catch (err) {
+        lsHintEl.textContent = '拒绝失败:' + (err?.message || err);
+    }
+    lsHideOffer();
+});
+
+// 页面加载时初始化 LocalSend 状态(配置表单由 loadSettings→syncForm 回填)
+(async function initLocalSend() {
+    const cfg = await lsLoadConfig();
+    lsSyncStatus(cfg);
+    if (cfg?.enabled) {
+        lsDevicesBox.hidden = false;
+        await lsRefreshDevices();
+    }
+})();
+
 
 async function loadHostPanel() {
     try {
@@ -2740,7 +3153,9 @@ function initToolCollapse() {
     document.querySelectorAll('.tool-card').forEach(card => {
         const head = card.querySelector('.tool-card-head');
         if (!head) return;
-        const toggle = () => {
+        const toggle = (e) => {
+            // 点击排序按钮(上移/下移)不触发折叠,交给排序逻辑处理
+            if (e?.target?.closest?.('.tool-order-btns')) return;
             const collapsed = card.classList.toggle('is-collapsed');
             head.setAttribute('aria-expanded', String(!collapsed));
         };
@@ -2748,12 +3163,16 @@ function initToolCollapse() {
         head.addEventListener('keydown', e => {
             if (e.key === 'Enter' || e.key === ' ') {
                 e.preventDefault();
-                toggle();
+                toggle(e);
             }
         });
     });
 }
 initToolCollapse();
+initToolOrder(); // 工具排序按钮:上移/下移并持久化顺序
+
+// 初始刷新一次排序按钮禁用状态(首次渲染时 applyToolOrder 尚未执行)
+refreshToolOrderBtns();
 
 // ------------------------------------------------------------------------
 // 工具页:Web 服务——选择目录,开启本地浏览服务
@@ -2761,8 +3180,8 @@ initToolCollapse();
 const websvcStatusEl = document.getElementById('websvc-status');  // 运行状态徽标
 const websvcDirInput = document.getElementById('websvc-dir');     // 目录输入框
 const websvcPickBtn = document.getElementById('websvc-pick');     // 选择目录按钮
-const websvcStartBtn = document.getElementById('websvc-start');   // 开启服务按钮
-const websvcStopBtn = document.getElementById('websvc-stop');     // 停止服务按钮
+const websvcToggleBtn = document.getElementById('websvc-toggle'); // 开启/停止乒乓按钮
+let websvcRunning = false; // 当前 Web 服务是否运行(乒乓按钮状态)
 const websvcHintEl = document.getElementById('websvc-hint');      // 提示文字
 const websvcPreview = document.getElementById('websvc-preview');  // 预览容器
 const websvcFrame = document.getElementById('websvc-frame');      // 预览 iframe
@@ -2772,11 +3191,10 @@ let websvcDir = ''; // 当前选中的目录(持久化于 localStorage)
 // 渲染 Web 服务状态(运行中 / 未运行)
 function renderWebSvc(info) {
     if (info && info.running) {
+        websvcRunning = true;
         // URL 做成可点击链接:点击用系统浏览器打开
         websvcStatusEl.innerHTML = `● 运行中 <a class="tool-url" href="${escapeHtml(info.url)}" target="_blank" data-wml-openURL="${escapeHtml(info.url)}" title="点击在浏览器打开">${escapeHtml(info.url)}</a>`;
         websvcStatusEl.className = 'tool-status is-on';
-        websvcStartBtn.disabled = true;
-        websvcStopBtn.disabled = false;
         websvcHintEl.textContent = info.indexName
             ? `默认页 ${info.indexName}|${info.connections} 次请求`
             : `未发现 index.html 或 readme.md,显示目录列表|${info.connections} 次请求`;
@@ -2784,14 +3202,18 @@ function renderWebSvc(info) {
         websvcPreview.hidden = false;
         websvcFrame.src = info.url;
     } else {
+        websvcRunning = false;
         websvcStatusEl.textContent = '未运行';
         websvcStatusEl.className = 'tool-status';
-        websvcStartBtn.disabled = false;
-        websvcStopBtn.disabled = true;
-        websvcHintEl.textContent = websvcDir ? '点击「开启浏览服务」开始' : '请先选择目录';
+        websvcHintEl.textContent = websvcDir ? '' : '请先选择目录';
         websvcPreview.hidden = true;
         websvcFrame.src = 'about:blank';
     }
+    // 乒乓按钮:运行中显示「停止服务」(红),未运行显示「开启浏览服务」(主色)
+    websvcToggleBtn.textContent = websvcRunning ? '停止服务' : '开启浏览服务';
+    websvcToggleBtn.classList.toggle('btn-danger', websvcRunning);
+    websvcToggleBtn.classList.toggle('btn-primary', !websvcRunning);
+    websvcToggleBtn.disabled = false;
 }
 
 // 首次进入工具页:恢复上次目录与运行状态
@@ -2827,37 +3249,40 @@ websvcPickBtn.addEventListener('click', async () => {
     }
 });
 
-// 开启浏览服务
-websvcStartBtn.addEventListener('click', async () => {
-    if (!websvcDir) {
-        websvcHintEl.textContent = '请先选择目录';
-        return;
-    }
-    websvcStartBtn.classList.add('loading');
-    websvcStartBtn.disabled = true;
-    try {
-        const info = await SettingsService.StartWebService(websvcDir);
-        renderWebSvc(info);
-    } catch (err) {
-        console.warn('开启服务失败:', err);
-        websvcHintEl.textContent = '开启失败:' + (err?.message || String(err));
-    } finally {
-        websvcStartBtn.classList.remove('loading');
-    }
-});
-
-// 停止浏览服务
-websvcStopBtn.addEventListener('click', async () => {
-    websvcStopBtn.classList.add('loading');
-    websvcStopBtn.disabled = true;
-    try {
-        await SettingsService.StopWebService();
-        renderWebSvc(null);
-    } catch (err) {
-        console.warn('停止服务失败:', err);
-        websvcHintEl.textContent = '停止失败:' + (err?.message || String(err));
-    } finally {
-        websvcStopBtn.classList.remove('loading');
+// 乒乓按钮:点击切换开启/停止浏览服务
+websvcToggleBtn.addEventListener('click', async () => {
+    if (websvcRunning) {
+        // 当前运行中 → 停止
+        websvcToggleBtn.classList.add('loading');
+        websvcToggleBtn.disabled = true;
+        try {
+            await SettingsService.StopWebService();
+            renderWebSvc(null);
+        } catch (err) {
+            console.warn('停止服务失败:', err);
+            websvcHintEl.textContent = '停止失败:' + (err?.message || String(err));
+            websvcToggleBtn.disabled = false;
+        } finally {
+            websvcToggleBtn.classList.remove('loading');
+        }
+    } else {
+        // 当前未运行 → 开启
+        if (!websvcDir) {
+            websvcHintEl.textContent = '请先选择目录';
+            return;
+        }
+        websvcToggleBtn.classList.add('loading');
+        websvcToggleBtn.disabled = true;
+        try {
+            const info = await SettingsService.StartWebService(websvcDir);
+            renderWebSvc(info);
+        } catch (err) {
+            console.warn('开启服务失败:', err);
+            websvcHintEl.textContent = '开启失败:' + (err?.message || String(err));
+            websvcToggleBtn.disabled = false;
+        } finally {
+            websvcToggleBtn.classList.remove('loading');
+        }
     }
 });
 
@@ -3443,10 +3868,12 @@ async function loadStocks() {
             const rising = q.change >= 0;                              // 涨跌方向
             const sign = rising ? '+' : '';                           // 正数补加号
             const color = rising ? upColor() : downColor();           // 用设置中的涨跌颜色
+            // 股吧地址:东方财富股吧按市场代码(带 sh/sz/bj 前缀)进入,如 sh600519
+            const gubaURL = 'https://guba.eastmoney.com/list,' + q.code + '.html';
             return `
                 <div class="stock-row" data-code="${q.code}" title="点击查看分时 / 日K">
                     <div class="stock-main">
-                        <span class="stock-name">${escapeHtml(q.name)}</span>
+                        <a class="stock-name stock-guba" href="${escapeHtml(gubaURL)}" target="_blank" data-wml-openURL="${escapeHtml(gubaURL)}" title="进入 ${escapeHtml(q.name)} 股吧">${escapeHtml(q.name)}</a>
                         <span class="stock-code">${q.code.toUpperCase()}</span>
                     </div>
                     <div class="stock-price" style="color:${color}">${q.price.toFixed(2)}</div>
@@ -3457,6 +3884,7 @@ async function loadStocks() {
                     <div class="stock-amount">成交 ${formatAmount(q.amount)}</div>
                 </div>`;
         }).join('');
+        WML.Reload(); // 重新绑定股票名称股吧链接的打开行为
         showStockRefreshTime(Date.now()); // 刷新成功:更新最后刷新时间
     } catch (err) {
         console.warn('获取行情失败:', err);
@@ -3476,6 +3904,8 @@ function formatAmount(wan) {
 
 // 点击股市行:打开该票的分时 / 日K 弹窗
 stockList.addEventListener('click', (event) => {
+    // 点击股票名称(股吧链接)时:交给链接打开行为,不触发行点击(避免同时打开图表)
+    if (event.target.closest('a.stock-guba')) return;
     const row = event.target.closest('.stock-row');
     if (!row) return;
     const quote = lastQuotes.find(q => q.code === row.dataset.code);

+ 14 - 0
githubwatch.go

@@ -38,7 +38,9 @@ type RepoFeed struct {
 	Desc       string        `json:"desc"`       // 项目说明
 	Repo       string        `json:"repo"`       // owner/repo 短名
 	Version    string        `json:"version"`    // 本地已安装版本(设置中配置)
+	VersionTime string       `json:"versionTime"` // 本地版本对应 release 的发布时间(MM-DD HH:MM),用于鼠标提示
 	Latest     string        `json:"latest"`     // 最新正式 release 版本号,无正式 release 时为空
+	LatestTime string        `json:"latestTime"` // 最新正式 release 发布时间(MM-DD HH:MM),用于鼠标提示
 	HasUpdate  bool          `json:"hasUpdate"`  // 是否存在可用更新(本地版本低于最新正式 release)
 	ReleaseURL string        `json:"releaseURL"` // 最新正式 release 的下载 / 详情页地址
 	Newer      []ReleaseItem `json:"newer"`      // Latest 之后的版本(Pre-release 等,仅列出不参与更新比较)
@@ -143,10 +145,22 @@ func (feed *RepoFeed) applyLatestRelease(repo string) {
 	if latestIdx >= 0 {
 		rel := rels[latestIdx]
 		feed.Latest = rel.TagName
+		feed.LatestTime = formatAtomTime(rel.PublishedAt) // 最新版发布时间(鼠标提示用)
 		feed.ReleaseURL = rel.HTMLURL
 		feed.HasUpdate = versionLess(feed.Version, feed.Latest) // 只比较 Latest 正式版
 	}
 
+	// 本地版本发布时间:在 release 列表中按 tag 匹配本地版本号(忽略 v 前缀,精确等值比较)
+	// 匹配到即记录其发布时间,供「本地 xxx」徽标鼠标提示。
+	if feed.Version != "" {
+		for _, r := range rels {
+			if normalizeVersion(r.TagName) == normalizeVersion(feed.Version) {
+				feed.VersionTime = formatAtomTime(r.PublishedAt)
+				break
+			}
+		}
+	}
+
 	// Latest 之后的版本(在列表中排在 Latest 之前的,即更新/更新的预发布、草稿):仅列出
 	for i, r := range rels {
 		if i >= latestIdx {

+ 1 - 1
go.mod

@@ -9,6 +9,7 @@ require (
 	github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/wailsapp/wails/v3 v3.0.0
+	golang.org/x/sys v0.49.0
 	golang.org/x/text v0.42.0
 	modernc.org/sqlite v1.61.1
 )
@@ -60,7 +61,6 @@ require (
 	golang.org/x/crypto v0.57.0 // indirect
 	golang.org/x/image v0.45.0 // indirect
 	golang.org/x/net v0.60.0 // indirect
-	golang.org/x/sys v0.49.0 // indirect
 	google.golang.org/protobuf v1.36.10 // indirect
 	modernc.org/libc v1.79.0 // indirect
 	modernc.org/mathutil v1.7.1 // indirect

+ 152 - 0
localsend/crypto/crypto.go

@@ -0,0 +1,152 @@
+package crypto
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/hex"
+	"encoding/json"
+	"encoding/pem"
+	"fmt"
+	"math/big"
+	"os"
+	"time"
+
+	"TengTuToolbox/localsend/logging"
+)
+
+// StoredSecurityContext holds PEM-encoded cert/key for config/server
+// and a fingerprint for discovery. Matches Dart model.
+type StoredSecurityContext struct {
+	PrivateKey      string `json:"privateKey"`
+	Certificate     string `json:"certificate"`
+	CertificateHash string `json:"certificateHash"`
+}
+
+// ClientCertificateFunc returns a tls.Config.GetClientCertificate callback that
+// always presents the stored certificate. LocalSend apps (protocol v2.2) ask
+// every HTTPS client for a certificate and drop the handshake without one, but
+// they advertise a CA list our self-signed certificate can never satisfy, so
+// tls.Config.Certificates would silently send nothing.
+func (c *StoredSecurityContext) ClientCertificateFunc() (func(*tls.CertificateRequestInfo) (*tls.Certificate, error), error) {
+	cert, err := tls.X509KeyPair([]byte(c.Certificate), []byte(c.PrivateKey))
+	if err != nil {
+		return nil, err
+	}
+	return func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
+		return &cert, nil
+	}, nil
+}
+
+// GenerateKeys generates a new RSA key pair.
+func generateKeys() (*rsa.PrivateKey, error) {
+	return rsa.GenerateKey(rand.Reader, 2048)
+}
+
+// encodePrivateKeyToPem encodes an RSA private key to PEM format (PKCS#1).
+func encodePrivateKeyToPem(privKey *rsa.PrivateKey) string {
+	privBytes := x509.MarshalPKCS1PrivateKey(privKey)
+	privPem := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes})
+	return string(privPem)
+}
+
+// generateSelfSignedCertificate creates a self-signed X.509 certificate DER bytes.
+func generateSelfSignedCertificate(privKey *rsa.PrivateKey, alias string) ([]byte, error) {
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate serial number: %w", err)
+	}
+	template := x509.Certificate{
+		SerialNumber: serialNumber,
+		Subject: pkix.Name{
+			Organization: []string{"LocalSend"},
+			CommonName:   alias,
+		},
+		NotBefore:             time.Now(),
+		NotAfter:              time.Now().AddDate(10, 0, 0),
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
+		BasicConstraintsValid: true,
+	}
+	certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
+	if err != nil {
+		return nil, err
+	}
+	return certBytes, nil
+}
+
+// encodeCertificateToPem encodes certificate DER bytes to PEM format.
+func encodeCertificateToPem(certBytes []byte) string {
+	certPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})
+	return string(certPem)
+}
+
+// calculateCertificateHash calculates the SHA-256 hash of the certificate (DER format).
+func calculateCertificateHash(certBytes []byte) string {
+	hash := sha256.Sum256(certBytes)
+	return hex.EncodeToString(hash[:])
+}
+
+// GenerateSecurityContext creates a new security context with keys and a self-signed certificate.
+func GenerateSecurityContext(alias string, logger *logging.Logger) (*StoredSecurityContext, error) {
+	privKey, err := generateKeys()
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate RSA keys: %w", err)
+	}
+	certBytes, err := generateSelfSignedCertificate(privKey, alias)
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate certificate: %w", err)
+	}
+	certHash := calculateCertificateHash(certBytes)
+	ctx := &StoredSecurityContext{
+		PrivateKey:      encodePrivateKeyToPem(privKey),
+		Certificate:     encodeCertificateToPem(certBytes),
+		CertificateHash: certHash,
+	}
+	if logger != nil {
+		logger.Infow("Generated new Security Context", "fingerprint", ctx.CertificateHash)
+	}
+	return ctx, nil
+}
+
+// SaveSecurityContext saves the context as JSON to the specified path.
+func SaveSecurityContext(ctx *StoredSecurityContext, path string, logger *logging.Logger) error {
+	file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
+	if err != nil {
+		return fmt.Errorf("failed to create security context file '%s': %w", path, err)
+	}
+	defer file.Close()
+	encoder := json.NewEncoder(file)
+	encoder.SetIndent("", "  ")
+	if err := encoder.Encode(ctx); err != nil {
+		return fmt.Errorf("failed to encode security context to '%s': %w", path, err)
+	}
+	if logger != nil {
+		logger.Infow("Saved security context", "path", path)
+	}
+	return nil
+}
+
+// LoadSecurityContext loads the context from JSON from the specified path.
+func LoadSecurityContext(path string, logger *logging.Logger) (*StoredSecurityContext, error) {
+	file, err := os.Open(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, err
+		}
+		return nil, fmt.Errorf("failed to open security context file '%s': %w", path, err)
+	}
+	defer file.Close()
+	var ctx StoredSecurityContext
+	if err := json.NewDecoder(file).Decode(&ctx); err != nil {
+		return nil, fmt.Errorf("failed to decode security context from '%s': %w", path, err)
+	}
+	if logger != nil {
+		logger.Debugw("Loaded security context", "path", path, "fingerprint", ctx.CertificateHash)
+	}
+	return &ctx, nil
+}

+ 195 - 0
localsend/discovery/announce.go

@@ -0,0 +1,195 @@
+package discovery
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net"
+	"strings"
+	"time"
+
+	"TengTuToolbox/localsend/model"
+	"TengTuToolbox/localsend/network"
+)
+
+// multicastBurstCount is the number of rapid UDP multicast bursts sent.
+const multicastBurstCount = 3
+
+// multicastBurstInterval is the delay between consecutive bursts.
+const multicastBurstInterval = 30 * time.Millisecond
+
+// SendDiscoveryAnnouncement sends a multicast announcement
+func (md *MulticastDiscovery) SendDiscoveryAnnouncement() error {
+	announcementDto := md.dto
+	announcementDto.Announce = true
+
+	data, err := json.Marshal(announcementDto)
+	if err != nil {
+		return fmt.Errorf("failed to marshal announcement: %w", err)
+	}
+
+	addr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr)
+	if err != nil {
+		return fmt.Errorf("failed to resolve multicast address: %w", err)
+	}
+
+	var localAddr *net.UDPAddr
+	if md.config.InterfaceName != "" {
+		iface, err := network.InterfaceByName(md.config.InterfaceName)
+		if err == nil {
+			addrs, err := iface.Addrs()
+			if err == nil {
+				for _, a := range addrs {
+					if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil {
+						localAddr = &net.UDPAddr{IP: ipnet.IP}
+						break
+					}
+				}
+			}
+		}
+	}
+
+	conn, err := net.DialUDP("udp4", localAddr, addr)
+	if err != nil {
+		return fmt.Errorf("failed to create UDP connection: %w", err)
+	}
+	defer conn.Close()
+
+	for burst := 0; burst < multicastBurstCount; burst++ {
+		_, err = conn.Write(data)
+		if err != nil {
+			return fmt.Errorf("failed to send multicast announcement (burst %d): %w", burst, err)
+		}
+		time.Sleep(multicastBurstInterval)
+	}
+
+	md.logger.Debugf("Sent %d multicast announcement bursts as %s (fingerprint: %s) to %s",
+		multicastBurstCount, md.dto.Alias, getShortFingerprint(md.dto.Fingerprint), md.config.MulticastAddr)
+	return nil
+}
+
+// SendDiscoveryResponse sends a response to a specific address
+func (md *MulticastDiscovery) SendDiscoveryResponse(targetAddr *net.UDPAddr, targetDevice *model.Device) error {
+	// 1. Try HTTP Response first — 向对端回发注册信息,完成"互相发现"。
+	//    失败时回退到 UDP 组播响应(见下方 fallback)。
+	if targetDevice != nil {
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+		defer cancel()
+
+		scheme := "http"
+		if targetDevice.Protocol == model.ProtocolTypeHTTPS {
+			scheme = "https"
+		}
+
+		reg := model.RegisterDto{
+			Alias:       md.dto.Alias,
+			Version:     md.dto.Version,
+			DeviceModel: md.dto.DeviceModel,
+			DeviceType:  md.dto.DeviceType,
+			Fingerprint: md.dto.Fingerprint,
+			Port:        md.dto.Port,
+			Protocol:    md.dto.Protocol,
+			Download:    md.dto.Download,
+		}
+		_, err := RegisterWithDevice(ctx, net.ParseIP(targetDevice.IP), targetDevice.Port, scheme, reg)
+		if err == nil {
+			md.logger.Debugf("Sent discovery response via HTTP to %s:%d", targetDevice.IP, targetDevice.Port)
+			return nil
+		}
+		md.logger.Debugf("HTTP register to %s:%d failed (%v), falling back to UDP multicast", targetDevice.IP, targetDevice.Port, err)
+	}
+
+	// 2. Fallback to UDP — send via multicast so every listener sees the response
+	responseDto := md.dto
+	responseDto.Announce = false
+
+	data, err := json.Marshal(responseDto)
+	if err != nil {
+		return fmt.Errorf("failed to marshal response: %w", err)
+	}
+
+	respAddr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr)
+	if err != nil {
+		return fmt.Errorf("failed to resolve multicast address: %w", err)
+	}
+
+	conn, err := net.DialUDP("udp4", nil, respAddr)
+	if err != nil {
+		return fmt.Errorf("failed to create UDP connection: %w", err)
+	}
+	defer conn.Close()
+
+	_, err = conn.Write(data)
+	if err != nil {
+		return fmt.Errorf("failed to send discovery response: %w", err)
+	}
+
+	md.logger.Debugf("Sent discovery response via multicast to %s", md.config.MulticastAddr)
+	return nil
+}
+
+func (md *MulticastDiscovery) listenLoop(ctx context.Context, conn net.PacketConn) {
+	buffer := make([]byte, 2048)
+
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		default:
+		}
+
+		if md.closed.Load() {
+			return
+		}
+
+		if err := conn.SetReadDeadline(time.Now().Add(md.config.ListenTimeout)); err != nil {
+			md.logger.Warnf("Failed to set read deadline: %v", err)
+		}
+
+		n, addr, err := conn.ReadFrom(buffer)
+		if err != nil {
+			if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+				continue
+			}
+			if strings.Contains(err.Error(), "use of closed network connection") {
+				return
+			}
+			continue
+		}
+
+		if err := md.handlePacket(buffer[:n], addr); err != nil {
+			md.logger.Warnf("Failed to handle multicast packet: %v", err)
+		}
+	}
+}
+
+func (md *MulticastDiscovery) handlePacket(data []byte, addr net.Addr) error {
+	var dto model.MulticastDto
+	if err := json.Unmarshal(data, &dto); err != nil {
+		return fmt.Errorf("failed to unmarshal packet: %w", err)
+	}
+
+	if dto.Fingerprint == md.dto.Fingerprint {
+		return nil
+	}
+
+	udpAddr, ok := addr.(*net.UDPAddr)
+	if !ok {
+		return fmt.Errorf("unexpected address type: %T", addr)
+	}
+
+	device := model.FromMulticastDto(dto, udpAddr.IP)
+
+	md.logger.Debugf("Discovered raw device via multicast: %s (%s) at %s:%d",
+		device.Alias, getShortFingerprint(device.Fingerprint), device.IP, device.Port)
+
+	md.updateDevice(device)
+
+	if dto.Announce {
+		if err := md.SendDiscoveryResponse(udpAddr, device); err != nil {
+			md.logger.Warnf("Failed to send discovery response: %v", err)
+		}
+	}
+
+	return nil
+}

+ 22 - 0
localsend/discovery/config.go

@@ -0,0 +1,22 @@
+package discovery
+
+import "time"
+
+// MulticastConfig contains settings for multicast discovery
+type MulticastConfig struct {
+	MulticastAddr   string
+	Port            int
+	InterfaceName   string
+	AnnounceTimeout time.Duration
+	ListenTimeout   time.Duration
+}
+
+// DefaultMulticastConfig returns a default configuration
+func DefaultMulticastConfig() *MulticastConfig {
+	return &MulticastConfig{
+		MulticastAddr:   "224.0.0.167:53317",
+		Port:            53317,
+		AnnounceTimeout: 2 * time.Second,
+		ListenTimeout:   5 * time.Second,
+	}
+}

+ 218 - 0
localsend/discovery/multicast.go

@@ -0,0 +1,218 @@
+// File: pkg/discovery/multicast.go
+package discovery
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"runtime"
+	"sync"
+	"sync/atomic"
+
+	"TengTuToolbox/localsend/logging"
+	"TengTuToolbox/localsend/model"
+	"TengTuToolbox/localsend/network"
+)
+
+// MulticastDiscovery implements UDP multicast-based device discovery
+type MulticastDiscovery struct {
+	config         *MulticastConfig
+	dto            model.MulticastDto
+	devices        map[string]*model.Device
+	devicesMutex   sync.RWMutex
+	handlers       []func(*model.Device)
+	handlersMu     sync.RWMutex
+	conns          []net.PacketConn
+	connsMu        sync.Mutex
+	closed         atomic.Bool
+	peerCache      *PeerCache
+	logger         *logging.Logger
+}
+
+// NewMulticastDiscovery creates a new multicast discovery instance
+func NewMulticastDiscovery(config *MulticastConfig, dto model.MulticastDto, logger *logging.Logger) *MulticastDiscovery {
+	if config == nil {
+		config = DefaultMulticastConfig()
+	}
+	if logger == nil {
+		logger = logging.NewQuiet()
+	}
+
+	return &MulticastDiscovery{
+		config:  config,
+		dto:     dto,
+		devices: make(map[string]*model.Device),
+		logger:  logger,
+	}
+}
+
+// AddDeviceHandler adds a handler function that will be called when a device is discovered
+func (md *MulticastDiscovery) AddDeviceHandler(handler func(*model.Device)) {
+	md.handlersMu.Lock()
+	defer md.handlersMu.Unlock()
+	md.handlers = append(md.handlers, handler)
+}
+
+// StartListening starts listening for multicast announcements on all suitable interfaces.
+// If InterfaceName is set in config, only that interface is used.
+func (md *MulticastDiscovery) StartListening(ctx context.Context) error {
+	md.closed.Store(false)
+
+	md.connsMu.Lock()
+	if len(md.conns) > 0 {
+		md.connsMu.Unlock()
+		return fmt.Errorf("already listening")
+	}
+	md.connsMu.Unlock()
+
+	addr, err := net.ResolveUDPAddr("udp4", md.config.MulticastAddr)
+	if err != nil {
+		return fmt.Errorf("failed to resolve multicast address: %w", err)
+	}
+
+	var targetIfaces []net.Interface
+	if md.config.InterfaceName != "" {
+		iface, err := network.InterfaceByName(md.config.InterfaceName)
+		if err != nil {
+			return fmt.Errorf("multicast interface '%s' not found: %w", md.config.InterfaceName, err)
+		}
+		if (iface.Flags & net.FlagUp) == 0 {
+			return fmt.Errorf("multicast interface '%s' is down", md.config.InterfaceName)
+		}
+		if (iface.Flags & net.FlagMulticast) == 0 {
+			return fmt.Errorf("multicast interface '%s' does not support multicast", md.config.InterfaceName)
+		}
+		targetIfaces = append(targetIfaces, *iface)
+	} else {
+		allIfaces, err := network.EnumerateInterfaces()
+		if err != nil {
+			if runtime.GOOS != "android" {
+				return fmt.Errorf("failed to list network interfaces: %w", err)
+			}
+			// Android/Termux where netlink is blocked by SELinux.
+			md.logger.Warnf("Failed to list network interfaces (%v); falling back to the default multicast interface", err)
+			return md.listenOnDefaultInterface(ctx, addr)
+		}
+		for _, iface := range allIfaces {
+			if (iface.Flags&net.FlagUp) == 0 || (iface.Flags&net.FlagMulticast) == 0 {
+				continue
+			}
+			targetIfaces = append(targetIfaces, iface)
+		}
+	}
+
+	if len(targetIfaces) == 0 {
+		if runtime.GOOS != "android" {
+			return fmt.Errorf("no suitable multicast interface found")
+		}
+		// No usable multicast interface (e.g. Android on cellular, where the
+		// only visible interfaces don't support multicast). Fall back to the
+		// kernel's default multicast interface, which the sandbox permits.
+		md.logger.Warnf("No suitable multicast interface found; falling back to the default multicast interface")
+		return md.listenOnDefaultInterface(ctx, addr)
+	}
+
+	for _, iface := range targetIfaces {
+		conn, err := net.ListenMulticastUDP("udp4", &iface, addr)
+		if err != nil {
+			md.logger.Warnf("Failed to listen on interface %s: %v", iface.Name, err)
+			continue
+		}
+		conn.SetReadBuffer(2048)
+
+		md.connsMu.Lock()
+		md.conns = append(md.conns, conn)
+		md.connsMu.Unlock()
+
+		go md.listenLoop(ctx, conn)
+
+		md.logger.Debugf("Multicast discovery listening on %s (interface: %s)", md.config.MulticastAddr, iface.Name)
+	}
+
+	md.connsMu.Lock()
+	listening := len(md.conns)
+	md.connsMu.Unlock()
+
+	if listening == 0 {
+		return fmt.Errorf("failed to listen on any multicast interface")
+	}
+
+	return nil
+}
+
+// listenOnDefaultInterface binds a single multicast listener on the kernel's
+// default multicast interface (index 0). This is the only working option on
+// Android/Termux, where netlink-based interface enumeration is blocked and the
+// visible cellular interfaces reject multicast membership (ENODEV).
+func (md *MulticastDiscovery) listenOnDefaultInterface(ctx context.Context, addr *net.UDPAddr) error {
+	conn, err := net.ListenMulticastUDP("udp4", nil, addr)
+	if err != nil {
+		return fmt.Errorf("failed to listen on default multicast interface: %w", err)
+	}
+	_ = conn.SetReadBuffer(2048)
+
+	md.connsMu.Lock()
+	md.conns = append(md.conns, conn)
+	md.connsMu.Unlock()
+
+	go md.listenLoop(ctx, conn)
+	md.logger.Debugf("Multicast discovery listening on %s (default interface)", md.config.MulticastAddr)
+	return nil
+}
+
+// Stop stops the multicast discovery and closes all listeners.
+func (md *MulticastDiscovery) Stop() {
+	md.closed.Store(true)
+	md.connsMu.Lock()
+	for _, conn := range md.conns {
+		conn.Close()
+	}
+	md.conns = nil
+	md.connsMu.Unlock()
+}
+
+func (md *MulticastDiscovery) updateDevice(device *model.Device) {
+	md.devicesMutex.Lock()
+	md.devices[device.Fingerprint] = device
+	md.devicesMutex.Unlock()
+
+	if md.peerCache != nil {
+		md.peerCache.Save(device)
+	}
+
+	md.handlersMu.RLock()
+	handlers := make([]func(*model.Device), len(md.handlers))
+	copy(handlers, md.handlers)
+	md.handlersMu.RUnlock()
+
+	for _, handler := range handlers {
+		go handler(device)
+	}
+}
+
+func (md *MulticastDiscovery) GetDevices() []*model.Device {
+	md.devicesMutex.RLock()
+	defer md.devicesMutex.RUnlock()
+
+	devices := make([]*model.Device, 0, len(md.devices))
+	for _, device := range md.devices {
+		devices = append(devices, device)
+	}
+
+	return devices
+}
+
+func (md *MulticastDiscovery) SetDto(dto model.MulticastDto) {
+	md.dto = dto
+}
+
+func (md *MulticastDiscovery) SetPeerCache(cache *PeerCache) {
+	md.peerCache = cache
+}
+
+func getShortFingerprint(fingerprint string) string {
+	if len(fingerprint) > 8 {
+		return fingerprint[:8] + "..."
+	}
+	return fingerprint
+}

+ 16 - 0
localsend/discovery/multicast_interface.go

@@ -0,0 +1,16 @@
+package discovery
+
+import (
+	"context"
+
+	"TengTuToolbox/localsend/model"
+)
+
+// MulticastDiscoverer is an interface for multicast discovery.
+type MulticastDiscoverer interface {
+	AddDeviceHandler(handler func(*model.Device))
+	StartListening(ctx context.Context) error
+	SendDiscoveryAnnouncement() error
+	Stop()
+	SetDto(dto model.MulticastDto)
+}

+ 282 - 0
localsend/discovery/peercache.go

@@ -0,0 +1,282 @@
+package discovery
+
+import (
+	"context"
+	"encoding/json"
+	"net"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"sync"
+	"time"
+
+	"TengTuToolbox/localsend/model"
+	"TengTuToolbox/localsend/logging"
+)
+
+// MaxCachedPeers is the maximum number of peers to keep in cache.
+const MaxCachedPeers = 50
+
+// StaleThreshold is how long without contact before a peer is evicted.
+const StaleThreshold = 14 * 24 * time.Hour
+
+// PeerCache persists discovered peers to disk and provides thread-safe access.
+type PeerCache struct {
+	mu       sync.RWMutex
+	filePath string
+	peers    map[string]*model.Device
+	order    []string // LRU order (most recent at end)
+	logger   *logging.Logger
+}
+
+// NewPeerCache creates or loads a peer cache from the XDG cache directory.
+func NewPeerCache(logger *logging.Logger) *PeerCache {
+	if logger == nil {
+		logger = logging.NewQuiet()
+	}
+
+	cacheDir, err := os.UserCacheDir()
+	if err != nil {
+		cacheDir = os.TempDir()
+	}
+	path := filepath.Join(cacheDir, "localgo", "peers.json")
+
+	pc := &PeerCache{
+		filePath: path,
+		peers:    make(map[string]*model.Device),
+		order:    make([]string, 0, MaxCachedPeers),
+		logger:   logger,
+	}
+	pc.load()
+	return pc
+}
+
+// Save adds or updates a peer, updates LRU order, evicts stale/over-limit entries, and persists.
+func (pc *PeerCache) Save(device *model.Device) {
+	pc.mu.Lock()
+	defer pc.mu.Unlock()
+
+	now := time.Now()
+	device.SetLastSeen(now)
+
+	if _, exists := pc.peers[device.Fingerprint]; !exists {
+		pc.order = append(pc.order, device.Fingerprint)
+	}
+	pc.peers[device.Fingerprint] = device
+
+	// Evict stale peers first
+	staleCutoff := now.Add(-StaleThreshold)
+	var fresh []string
+	for _, fp := range pc.order {
+		d, ok := pc.peers[fp]
+		if !ok || (!d.GetLastSeen().IsZero() && d.GetLastSeen().Before(staleCutoff)) {
+			delete(pc.peers, fp)
+			continue
+		}
+		fresh = append(fresh, fp)
+	}
+	pc.order = fresh
+
+	// LRU evict oldest entries if over cap
+	for len(pc.order) > MaxCachedPeers {
+		fp := pc.order[0]
+		pc.order = pc.order[1:]
+		delete(pc.peers, fp)
+	}
+
+	if err := pc.persist(); err != nil {
+		pc.logger.Warnf("Failed to persist peer cache: %v", err)
+	}
+}
+
+// touchLRU moves the given fingerprint to the end (most recently used).
+func (pc *PeerCache) touchLRU(fingerprint string) {
+	for i, fp := range pc.order {
+		if fp == fingerprint {
+			pc.order = append(pc.order[:i], pc.order[i+1:]...)
+			pc.order = append(pc.order, fp)
+			break
+		}
+	}
+}
+
+// GetPeers returns a snapshot of all cached peers (most recently seen first).
+func (pc *PeerCache) GetPeers() []*model.Device {
+	pc.mu.RLock()
+	defer pc.mu.RUnlock()
+
+	list := make([]*model.Device, 0, len(pc.order))
+	for i := len(pc.order) - 1; i >= 0; i-- {
+		if d, ok := pc.peers[pc.order[i]]; ok {
+			list = append(list, d)
+		}
+	}
+	return list
+}
+
+// GetByFingerprint returns a cached peer by fingerprint.
+func (pc *PeerCache) GetByFingerprint(fp string) *model.Device {
+	pc.mu.RLock()
+	defer pc.mu.RUnlock()
+	return pc.peers[fp]
+}
+
+// load reads peers.json into the in-memory map. Must be called with mu held.
+func (pc *PeerCache) load() {
+	pc.mu.Lock()
+	defer pc.mu.Unlock()
+
+	data, err := os.ReadFile(pc.filePath)
+	if err != nil {
+		if !os.IsNotExist(err) {
+			pc.logger.Warnf("Failed to read peer cache file: %v", err)
+		}
+		return
+	}
+
+	var list []*model.Device
+	if err := json.Unmarshal(data, &list); err != nil {
+		pc.logger.Warnf("Failed to unmarshal peer cache: %v", err)
+		return
+	}
+
+	now := time.Now()
+	staleCutoff := now.Add(-StaleThreshold)
+	evictedCount := 0
+
+	for _, d := range list {
+		if !d.GetLastSeen().IsZero() && d.GetLastSeen().Before(staleCutoff) {
+			evictedCount++
+			continue
+		}
+		if len(pc.order) >= MaxCachedPeers {
+			evictedCount++
+			continue
+		}
+		pc.order = append(pc.order, d.Fingerprint)
+		pc.peers[d.Fingerprint] = d
+	}
+
+	if evictedCount > 0 {
+		pc.logger.Debugf("Evicted %d stale/over-limit peer(s) from the local cache (older than 14 days or >%d entries)", evictedCount, MaxCachedPeers)
+		go func() {
+			pc.mu.Lock()
+			defer pc.mu.Unlock()
+			_ = pc.persist()
+		}()
+	}
+}
+
+// cachedPeer represents a peer with its LRU ordering for serialization.
+type cachedPeer struct {
+	Device *model.Device `json:"device"`
+}
+
+// persist writes the in-memory cache to disk atomically via a temp file + rename.
+// Must be called with mu held.
+func (pc *PeerCache) persist() error {
+	list := make([]*model.Device, 0, len(pc.order))
+	for _, fp := range pc.order {
+		if d, ok := pc.peers[fp]; ok {
+			list = append(list, d)
+		}
+	}
+
+	data, err := json.MarshalIndent(list, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	dir := filepath.Dir(pc.filePath)
+	if err := os.MkdirAll(dir, 0700); err != nil {
+		return err
+	}
+
+	tempFile, err := os.CreateTemp(dir, "peers-*.tmp")
+	if err != nil {
+		return err
+	}
+	tempPath := tempFile.Name()
+	removeTemp := true
+	defer func() {
+		tempFile.Close()
+		if removeTemp {
+			os.Remove(tempPath)
+		}
+	}()
+
+	if _, err := tempFile.Write(data); err != nil {
+		return err
+	}
+	if err := tempFile.Sync(); err != nil {
+		return err
+	}
+	if err := tempFile.Close(); err != nil {
+		return err
+	}
+
+	if err := os.Rename(tempPath, pc.filePath); err != nil {
+		return err
+	}
+	removeTemp = false
+	return nil
+}
+
+// ProbeCached pings each cached peer with GET /api/localsend/v2/info
+// and calls onFound for every peer that responds.
+func ProbeCached(ctx context.Context, cache *PeerCache, onFound func(*model.Device), logger *logging.Logger) {
+	if cache == nil {
+		return
+	}
+
+	peers := cache.GetPeers()
+	if len(peers) == 0 {
+		return
+	}
+
+	client := &http.Client{
+		Timeout: 2 * time.Second,
+	}
+	tr := &http.Transport{
+		TLSClientConfig: clientTLSConfig(),
+	}
+	client.Transport = tr
+	defer tr.CloseIdleConnections()
+
+	var wg sync.WaitGroup
+	for _, device := range peers {
+		wg.Add(1)
+		go func(d *model.Device) {
+			defer wg.Done()
+
+			scheme := "http"
+			if d.Protocol == model.ProtocolTypeHTTPS {
+				scheme = "https"
+			}
+
+			url := scheme + "://" + net.JoinHostPort(d.IP, strconv.Itoa(d.Port)) + "/api/localsend/v2/info"
+			req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+			if err != nil {
+				return
+			}
+
+			resp, err := client.Do(req)
+			if err != nil {
+				return
+			}
+			resp.Body.Close()
+
+			if resp.StatusCode == http.StatusOK {
+				now := time.Now()
+			d.SetLastSeen(now)
+			cache.Save(d) // persist updated LastSeen to disk
+			if logger != nil {
+				logger.Debugf("Cached peer %s (%s:%d) responded", d.Alias, d.IP, d.Port)
+			}
+			onFound(d)
+			}
+		}(device)
+	}
+	wg.Wait()
+}

+ 66 - 0
localsend/discovery/register_http.go

@@ -0,0 +1,66 @@
+// register_http.go — 通过 HTTPS 向对端注册设备(发现流程的 HTTP 回发环节)。
+//
+// LocalSend 协议:本机收到对端的组播公告后,应向对端 HTTP 端点
+// POST /api/localsend/v2/register 回发本机信息,使对端完成"互相发现"。
+// 该请求携带自签客户端证书(mTLS),对端按证书指纹校验;对端证书为
+// 自签,故此处 InsecureSkipVerify(指纹校验由对端完成)。
+// 本实现不依赖 localgo 的 HTTPDiscovery(其含网关/子网扫描等重依赖),
+// 仅保留协议规定的注册回发功能。
+package discovery
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/http"
+	"time"
+
+	"TengTuToolbox/localsend/model"
+)
+
+// RegisterWithDevice 向指定 IP:port 的 LocalSend 设备回发本机注册信息。
+// scheme 为 "http" 或 "https";返回对端响应的注册信息(InfoDto)。
+func RegisterWithDevice(ctx context.Context, ip net.IP, port int, scheme string, reg model.RegisterDto) (*model.InfoDto, error) {
+	if ip == nil {
+		return nil, fmt.Errorf("ip is nil")
+	}
+
+	body, err := json.Marshal(reg)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal register dto: %w", err)
+	}
+
+	if scheme == "" {
+		scheme = "https"
+	}
+	url := fmt.Sprintf("%s://%s:%d/api/localsend/v2/register", scheme, ip.String(), port)
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+	if err != nil {
+		return nil, fmt.Errorf("failed to create register request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/json")
+
+	client := &http.Client{
+		Timeout:   3 * time.Second,
+		Transport: &http.Transport{TLSClientConfig: clientTLSConfig()},
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("register request failed: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("register returned status %d", resp.StatusCode)
+	}
+
+	var infoResp model.InfoDto
+	if err := json.NewDecoder(resp.Body).Decode(&infoResp); err != nil {
+		return nil, fmt.Errorf("failed to decode register response: %w", err)
+	}
+	return &infoResp, nil
+}

+ 243 - 0
localsend/discovery/service.go

@@ -0,0 +1,243 @@
+// File: pkg/discovery/service.go
+package discovery
+
+import (
+	"context"
+	"fmt"
+	"sync"
+	"time"
+
+	"TengTuToolbox/localsend/logging"
+	"TengTuToolbox/localsend/model"
+)
+
+// Service coordinates different discovery mechanisms
+type Service struct {
+	config        *ServiceConfig
+	multicast     MulticastDiscoverer
+	devices       map[string]*model.Device
+	devicesMutex  sync.RWMutex
+	handlers      []func(*model.Device)
+	handlersMutex sync.RWMutex
+	announceTimer *time.Timer
+	peerCache     *PeerCache
+	stopCh        chan struct{}
+	stopOnce      sync.Once
+	logger        *logging.Logger
+}
+
+// ServiceConfig contains settings for the discovery service
+type ServiceConfig struct {
+	MulticastConfig    *MulticastConfig
+	AnnounceInterval   time.Duration
+	DeviceTimeout      time.Duration
+	EnableAnnouncement bool
+}
+
+// DefaultServiceConfig returns a default configuration for the discovery service
+func DefaultServiceConfig() *ServiceConfig {
+	return &ServiceConfig{
+		MulticastConfig:    DefaultMulticastConfig(),
+		AnnounceInterval:   30 * time.Second,
+		DeviceTimeout:      2 * time.Minute,
+		EnableAnnouncement: true,
+	}
+}
+
+// NewService creates a new discovery service
+func NewService(config *ServiceConfig, multicast MulticastDiscoverer, logger *logging.Logger) *Service {
+	if config == nil {
+		config = DefaultServiceConfig()
+	}
+	if logger == nil {
+		logger = logging.NewQuiet()
+	}
+
+	s := &Service{
+		config:    config,
+		devices:   make(map[string]*model.Device),
+		multicast: multicast,
+		stopCh:    make(chan struct{}),
+		logger:    logger,
+	}
+
+	// ALWAYS ensure multicast propagates raw events upward to the Service state
+	if s.multicast != nil {
+		s.multicast.AddDeviceHandler(func(device *model.Device) {
+			s.updateDevice(device)
+		})
+	}
+
+	return s
+}
+
+// SetPeerCache sets the persistent peer cache for both service and multicast.
+func (s *Service) SetPeerCache(cache *PeerCache) {
+	s.peerCache = cache
+	if mc, ok := s.multicast.(interface{ SetPeerCache(*PeerCache) }); ok {
+		mc.SetPeerCache(cache)
+	}
+}
+
+// Start initializes and starts the discovery service for listening and periodic announcements
+func (s *Service) Start(ctx context.Context, dto model.MulticastDto) error {
+	s.multicast.SetDto(dto)
+
+	if err := s.multicast.StartListening(ctx); err != nil {
+		return fmt.Errorf("failed to start multicast discovery: %w", err)
+	}
+
+	if s.config.EnableAnnouncement {
+		s.startAnnouncementLoop(ctx)
+	}
+
+	if err := s.multicast.SendDiscoveryAnnouncement(); err != nil {
+		s.logger.Errorf("Failed to send initial discovery announcement: %v", err)
+	}
+
+	// Probe cached peers in the background
+	if s.peerCache != nil {
+		probeCtx, cancelProbe := context.WithTimeout(ctx, 10*time.Second)
+		go func() {
+			defer cancelProbe()
+			ProbeCached(probeCtx, s.peerCache, func(device *model.Device) {
+				s.updateDevice(device)
+			}, s.logger)
+		}()
+	}
+
+	return nil
+}
+
+// Stop stops the discovery service
+func (s *Service) Stop() {
+	s.logger.Debugf("Stopping discovery service...")
+	s.stopOnce.Do(func() {
+		close(s.stopCh)
+	})
+	if s.multicast != nil {
+		s.multicast.Stop()
+	}
+
+	if s.announceTimer != nil {
+		s.announceTimer.Stop()
+	}
+	s.logger.Debugf("Discovery service stopped.")
+}
+
+// Discover performs a discovery scan and returns found devices.
+func (s *Service) Discover(ctx context.Context, dto model.MulticastDto) ([]*model.Device, error) {
+	s.logger.Debugf("Performing one-off discovery scan...")
+
+	s.multicast.SetDto(dto)
+
+	// MUST be listening to receive multicast responses
+	if err := s.multicast.StartListening(ctx); err != nil {
+		s.logger.Debugf("Failed to start multicast listener (responses may be missed): %v", err)
+	}
+
+	if err := s.multicast.SendDiscoveryAnnouncement(); err != nil {
+		s.logger.Errorf("Failed to send initial discovery announcement: %v", err)
+	}
+
+	var lastDevices []*model.Device
+
+	ticker := time.NewTicker(250 * time.Millisecond)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			lastDevices = s.GetDevices() // Perform one absolute final read to guarantee capture
+			s.logger.Debugf("Discovery scan finished. %d device(s) found.", len(lastDevices))
+			return lastDevices, nil
+		case <-ticker.C:
+			devices := s.GetDevices()
+			lastDevices = devices
+		}
+	}
+}
+
+// GetDevices returns all currently known devices
+func (s *Service) GetDevices() []*model.Device {
+	s.devicesMutex.RLock()
+	defer s.devicesMutex.RUnlock()
+
+	devices := make([]*model.Device, 0, len(s.devices))
+	for _, device := range s.devices {
+		if !device.IsStale(s.config.DeviceTimeout) {
+			devices = append(devices, device)
+		}
+	}
+
+	return devices
+}
+
+// GetDevice returns a specific device by ID
+func (s *Service) GetDevice(id string) *model.Device {
+	s.devicesMutex.RLock()
+	defer s.devicesMutex.RUnlock()
+
+	if device, ok := s.devices[id]; ok {
+		if !device.IsStale(s.config.DeviceTimeout) {
+			return device
+		}
+	}
+
+	return nil
+}
+
+// AddDeviceHandler adds a handler for device discovery events
+func (s *Service) AddDeviceHandler(handler func(*model.Device)) {
+	s.handlersMutex.Lock()
+	defer s.handlersMutex.Unlock()
+	s.handlers = append(s.handlers, handler)
+}
+
+// updateDevice updates the device list with a newly discovered device
+func (s *Service) updateDevice(device *model.Device) {
+	s.devicesMutex.Lock()
+	defer s.devicesMutex.Unlock()
+
+	existingDevice, exists := s.devices[device.Fingerprint]
+	if exists {
+		// Update last seen timestamp
+		existingDevice.UpdateLastSeen()
+	} else {
+		// Add new device
+		s.devices[device.Fingerprint] = device
+
+		// Notify Service-level handlers about the new device ONLY ONCE (debounces the bursts)
+		s.handlersMutex.RLock()
+		handlers := make([]func(*model.Device), len(s.handlers))
+		copy(handlers, s.handlers)
+		s.handlersMutex.RUnlock()
+
+		for _, handler := range handlers {
+			go handler(device)
+		}
+	}
+}
+
+// startAnnouncementLoop starts a periodic announcement loop
+func (s *Service) startAnnouncementLoop(ctx context.Context) {
+	s.announceTimer = time.NewTimer(s.config.AnnounceInterval)
+
+	go func() {
+		for {
+			select {
+			case <-ctx.Done():
+				s.announceTimer.Stop()
+				return
+			case <-s.stopCh:
+				s.announceTimer.Stop()
+				return
+			case <-s.announceTimer.C:
+				if err := s.multicast.SendDiscoveryAnnouncement(); err != nil {
+					s.logger.Errorf("Failed to send periodic announcement: %v", err)
+				}
+				s.announceTimer.Reset(s.config.AnnounceInterval)
+			}
+		}
+	}()
+}

+ 35 - 0
localsend/discovery/tlsclient.go

@@ -0,0 +1,35 @@
+// File: pkg/discovery/tlsclient.go
+package discovery
+
+import (
+	"crypto/tls"
+	"sync/atomic"
+)
+
+// clientCertFunc is the callback used to present a TLS client certificate
+// when probing other devices over HTTPS.
+var clientCertFunc atomic.Pointer[func(*tls.CertificateRequestInfo) (*tls.Certificate, error)]
+
+// SetClientCertificate sets the callback used to present our certificate as a
+// TLS client certificate when contacting other devices (see
+// crypto.StoredSecurityContext.ClientCertificateFunc). LocalSend apps speaking
+// protocol v2.2 request a client certificate and drop the handshake without
+// one, which makes them invisible to HTTP-based discovery. Passing nil
+// disables it.
+func SetClientCertificate(fn func(*tls.CertificateRequestInfo) (*tls.Certificate, error)) {
+	if fn == nil {
+		clientCertFunc.Store(nil)
+		return
+	}
+	clientCertFunc.Store(&fn)
+}
+
+// clientTLSConfig returns the TLS config for outgoing discovery requests.
+// Peers use self-signed certificates, so they are not verified here.
+func clientTLSConfig() *tls.Config {
+	cfg := &tls.Config{InsecureSkipVerify: true}
+	if fn := clientCertFunc.Load(); fn != nil {
+		cfg.GetClientCertificate = *fn
+	}
+	return cfg
+}

+ 149 - 0
localsend/events/events.go

@@ -0,0 +1,149 @@
+// Package events provides a small in-process publish/subscribe broker used
+// to stream LocalGo activity (pending transfers, transfer progress,
+// completed transfers, discovered devices) to IPC/SSE consumers.
+//
+// It lives in its own package so low-level packages (server handlers) can
+// publish without importing the IPC layer, which depends on them.
+package events
+
+import (
+	"sync"
+	"sync/atomic"
+	"time"
+)
+
+// Event types published by the broker.
+const (
+	TypeTransferPending  = "transfer_pending"
+	TypeTransferProgress = "transfer_progress"
+	TypeTransferComplete = "transfer_complete"
+	TypeTransferRejected = "transfer_rejected"
+	TypeDeviceDiscovered = "device_discovered"
+)
+
+// Event is a single broker message.
+type Event struct {
+	// ID is a monotonically increasing sequence number, used for SSE
+	// Last-Event-ID resumption.
+	ID uint64 `json:"id"`
+	// Time is the Unix-millisecond publish time.
+	Time int64       `json:"time"`
+	Type string      `json:"type"`
+	Data interface{} `json:"data,omitempty"`
+}
+
+const (
+	// defaultHistory is how many recent events are retained for replay.
+	defaultHistory = 64
+	// defaultSubscriberBuffer is the per-subscriber queue depth. Slow
+	// consumers miss events rather than stalling the publisher.
+	defaultSubscriberBuffer = 256
+)
+
+// Broker fans events out to subscribers. The zero value is not usable; call
+// NewBroker.
+type Broker struct {
+	mu      sync.RWMutex
+	subs    map[chan Event]struct{}
+	seq     atomic.Uint64
+	history []Event
+	limit   int
+	buffer  int
+}
+
+// NewBroker creates a broker retaining the default replay history.
+func NewBroker() *Broker {
+	return &Broker{
+		subs:    make(map[chan Event]struct{}),
+		history: make([]Event, 0, defaultHistory),
+		limit:   defaultHistory,
+		buffer:  defaultSubscriberBuffer,
+	}
+}
+
+// Publish delivers an event to all current subscribers and records it in the
+// replay history. It never blocks: a subscriber whose queue is full misses
+// the event.
+func (b *Broker) Publish(eventType string, data interface{}) {
+	if b == nil {
+		return
+	}
+	evt := Event{
+		ID:   b.seq.Add(1),
+		Time: time.Now().UnixMilli(),
+		Type: eventType,
+		Data: data,
+	}
+
+	b.mu.Lock()
+	b.history = append(b.history, evt)
+	if len(b.history) > b.limit {
+		b.history = b.history[len(b.history)-b.limit:]
+	}
+	subs := make([]chan Event, 0, len(b.subs))
+	for ch := range b.subs {
+		subs = append(subs, ch)
+	}
+	b.mu.Unlock()
+
+	for _, ch := range subs {
+		select {
+		case ch <- evt:
+		default: // slow consumer: drop rather than block the publisher
+		}
+	}
+}
+
+// Subscribe returns a channel of future events and a function to unsubscribe.
+// The channel is closed by the unsubscribe function.
+func (b *Broker) Subscribe() (<-chan Event, func()) {
+	if b == nil {
+		ch := make(chan Event)
+		close(ch)
+		return ch, func() {}
+	}
+	ch := make(chan Event, b.buffer)
+	b.mu.Lock()
+	b.subs[ch] = struct{}{}
+	b.mu.Unlock()
+
+	var once sync.Once
+	return ch, func() {
+		once.Do(func() {
+			b.mu.Lock()
+			if _, ok := b.subs[ch]; ok {
+				delete(b.subs, ch)
+				close(ch)
+			}
+			b.mu.Unlock()
+		})
+	}
+}
+
+// SubscriberCount reports how many live subscribers the broker has. It is
+// primarily useful for tests and diagnostics.
+func (b *Broker) SubscriberCount() int {
+	if b == nil {
+		return 0
+	}
+	b.mu.RLock()
+	defer b.mu.RUnlock()
+	return len(b.subs)
+}
+
+// HistorySince returns retained events with an ID greater than afterID, for
+// SSE Last-Event-ID resumption. Pass 0 to replay the whole retained history.
+func (b *Broker) HistorySince(afterID uint64) []Event {
+	if b == nil {
+		return nil
+	}
+	b.mu.RLock()
+	defer b.mu.RUnlock()
+	out := make([]Event, 0, len(b.history))
+	for _, evt := range b.history {
+		if evt.ID > afterID {
+			out = append(out, evt)
+		}
+	}
+	return out
+}

+ 229 - 0
localsend/logging/logging.go

@@ -0,0 +1,229 @@
+// Package logging provides LocalGo's structured logging wrapper around
+// log/slog. It exposes the same printf-style surface as the previous zap
+// sugared logger so call sites only depend on this package.
+package logging
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+var (
+	globalLogger *Logger
+	globalSugar  *Logger
+)
+
+// ANSI colour codes
+const (
+	colourReset  = "\033[0m"
+	colourRed    = "\033[31m"
+	colourYellow = "\033[33m"
+	colourCyan   = "\033[36m"
+	colourGrey   = "\033[90m"
+)
+
+// Logger wraps *slog.Logger and mirrors the zap SugaredLogger method surface.
+type Logger struct {
+	l *slog.Logger
+}
+
+// Init initialises the global slog logger.
+//
+//   - verbose: enable debug-level output and also log to stdout
+//   - jsonFmt: output newline-delimited JSON instead of human-readable text
+//   - noColor: disable ANSI color escape sequences in log output
+func Init(verbose, jsonFmt, noColor bool) *Logger {
+	level := slog.LevelInfo
+	if verbose {
+		level = slog.LevelDebug
+	}
+
+	stateDir := ""
+	if xdgState := os.Getenv("XDG_STATE_HOME"); xdgState != "" {
+		stateDir = filepath.Join(xdgState, "localgo")
+	} else if home, err := os.UserHomeDir(); err == nil {
+		stateDir = filepath.Join(home, ".local", "state", "localgo")
+	}
+
+	var fileWs io.Writer
+	if stateDir != "" {
+		os.MkdirAll(stateDir, 0700)
+		logPath := filepath.Join(stateDir, "app.log")
+		if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600); err == nil {
+			fileWs = f
+		}
+	}
+	if fileWs == nil {
+		fileWs = os.Stderr
+	}
+
+	opts := &slog.HandlerOptions{Level: level}
+	if jsonFmt {
+		opts.ReplaceAttr = func(_ []string, a slog.Attr) slog.Attr {
+			if a.Key == slog.LevelKey {
+				if l, ok := a.Value.Any().(slog.Level); ok {
+					a.Value = slog.StringValue(strings.ToLower(l.String()))
+				}
+			}
+			return a
+		}
+		fileHandler := slog.NewJSONHandler(fileWs, opts)
+		if verbose {
+			consoleHandler := newConsoleHandler(level, noColor)
+			return setGlobal(slog.New(multiHandler{handlers: []slog.Handler{fileHandler, consoleHandler}}))
+		}
+		return setGlobal(slog.New(fileHandler))
+	}
+
+	fileHandler := slog.NewTextHandler(fileWs, opts)
+	if verbose {
+		consoleHandler := newConsoleHandler(level, noColor)
+		return setGlobal(slog.New(multiHandler{handlers: []slog.Handler{fileHandler, consoleHandler}}))
+	}
+	return setGlobal(slog.New(fileHandler))
+}
+
+func setGlobal(l *slog.Logger) *Logger {
+	globalLogger = &Logger{l: l}
+	globalSugar = globalLogger
+	return globalSugar
+}
+
+// NewQuiet returns a no-op logger that discards all output.
+func NewQuiet() *Logger {
+	return &Logger{l: slog.New(slog.DiscardHandler)}
+}
+
+// Global returns the global logger, or a no-op if Init has not been called.
+func Global() *Logger {
+	if globalSugar != nil {
+		return globalSugar
+	}
+	return NewQuiet()
+}
+
+func (g *Logger) Infof(format string, a ...any)  { g.l.Info(fmt.Sprintf(format, a...)) }
+func (g *Logger) Warnf(format string, a ...any)  { g.l.Warn(fmt.Sprintf(format, a...)) }
+func (g *Logger) Errorf(format string, a ...any) { g.l.Error(fmt.Sprintf(format, a...)) }
+func (g *Logger) Debugf(format string, a ...any) { g.l.Debug(fmt.Sprintf(format, a...)) }
+
+func (g *Logger) Info(m string)  { g.l.Info(m) }
+func (g *Logger) Warn(m string)  { g.l.Warn(m) }
+func (g *Logger) Error(m string) { g.l.Error(m) }
+func (g *Logger) Debug(m string) { g.l.Debug(m) }
+
+func (g *Logger) Infow(m string, kv ...any)  { g.l.Info(m, kv...) }
+func (g *Logger) Warnw(m string, kv ...any)  { g.l.Warn(m, kv...) }
+func (g *Logger) Errorw(m string, kv ...any) { g.l.Error(m, kv...) }
+func (g *Logger) Debugw(m string, kv ...any) { g.l.Debug(m, kv...) }
+
+// multiHandler fans records out to several slog handlers.
+type multiHandler struct {
+	handlers []slog.Handler
+}
+
+func (m multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
+	for _, h := range m.handlers {
+		if h.Enabled(ctx, level) {
+			return true
+		}
+	}
+	return false
+}
+
+func (m multiHandler) Handle(ctx context.Context, r slog.Record) error {
+	for _, h := range m.handlers {
+		if err := h.Handle(ctx, r.Clone()); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (m multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+	hs := make([]slog.Handler, len(m.handlers))
+	for i, h := range m.handlers {
+		hs[i] = h.WithAttrs(attrs)
+	}
+	return multiHandler{handlers: hs}
+}
+
+func (m multiHandler) WithGroup(name string) slog.Handler {
+	hs := make([]slog.Handler, len(m.handlers))
+	for i, h := range m.handlers {
+		hs[i] = h.WithGroup(name)
+	}
+	return multiHandler{handlers: hs}
+}
+
+// consoleHandler renders human-readable lines to stdout, e.g.
+//
+//	15:04:05  INF  server started
+type consoleHandler struct {
+	level   slog.Level
+	noColor bool
+	w       io.Writer
+}
+
+func newConsoleHandler(level slog.Level, noColor bool) *consoleHandler {
+	return &consoleHandler{level: level, noColor: noColor, w: os.Stdout}
+}
+
+func (h *consoleHandler) Enabled(_ context.Context, level slog.Level) bool {
+	return level >= h.level
+}
+
+func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
+	buf := make([]byte, 0, 128)
+	buf = r.Time.AppendFormat(buf, "15:04:05")
+	buf = append(buf, ' ', ' ')
+
+	levelStr := r.Level.String()
+	if !h.noColor {
+		levelStr = colourLevel(r.Level) + levelStr + colourReset
+	}
+	buf = append(buf, levelStr...)
+	buf = append(buf, ' ', ' ')
+	buf = append(buf, r.Message...)
+
+	if r.NumAttrs() > 0 {
+		buf = append(buf, ' ')
+		r.Attrs(func(a slog.Attr) bool {
+			buf = append(buf, a.Key...)
+			buf = append(buf, '=', '\'')
+			buf = append(buf, fmt.Sprint(a.Value.Any())...)
+			buf = append(buf, '\'', ' ')
+			return true
+		})
+	}
+
+	buf = append(buf, '\n')
+	_, err := h.w.Write(buf)
+	return err
+}
+
+func (h *consoleHandler) WithAttrs(_ []slog.Attr) slog.Handler {
+	return h
+}
+
+func (h *consoleHandler) WithGroup(_ string) slog.Handler {
+	return h
+}
+
+func colourLevel(l slog.Level) string {
+	switch {
+	case l >= slog.LevelError:
+		return colourRed
+	case l >= slog.LevelWarn:
+		return colourYellow
+	case l >= slog.LevelInfo:
+		return colourCyan
+	default:
+		return colourGrey
+	}
+}

+ 255 - 0
localsend/metadata/strip.go

@@ -0,0 +1,255 @@
+package metadata
+
+import (
+	"bytes"
+	"encoding/binary"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+)
+
+// Strip strips metadata (EXIF, text chunks) from image files using a
+// temp-file + rename strategy so the original is never overwritten in place.
+// Supported formats: JPEG, PNG. Returns nil for non-image files.
+func Strip(path string) error {
+	tmp, err := stripToTemp(path)
+	if err != nil {
+		return err
+	}
+	if tmp == "" {
+		return nil
+	}
+	defer os.Remove(tmp)
+	return os.Rename(tmp, path)
+}
+
+// StripTo writes a stripped copy of the source image to destPath.
+// Both paths may be the same (caller should use Strip for that).
+// Returns nil for non-image files without error.
+func StripTo(srcPath, destPath string) error {
+	srcIsImage, err := IsImageFile(srcPath)
+	if err != nil || !srcIsImage {
+		return err
+	}
+
+	f, err := os.Open(srcPath)
+	if err != nil {
+		return fmt.Errorf("strip: open: %w", err)
+	}
+	defer f.Close()
+
+	sig := make([]byte, 8)
+	if _, err := io.ReadFull(f, sig); err != nil {
+		return fmt.Errorf("strip: read sig: %w", err)
+	}
+	f.Close()
+
+	switch {
+	case isJPEG(sig):
+		return stripJPEGTo(srcPath, destPath)
+	case isPNG(sig):
+		return stripPNGTo(srcPath, destPath)
+	}
+	return nil
+}
+
+// stripToTemp strips metadata to a temp file in the same directory.
+// Returns empty string if the file is not a supported image type.
+func stripToTemp(path string) (string, error) {
+	srcIsImage, err := IsImageFile(path)
+	if err != nil || !srcIsImage {
+		return "", err
+	}
+
+	dir := filepath.Dir(path)
+	tmp, err := os.CreateTemp(dir, ".localgo-strip-*")
+	if err != nil {
+		return "", fmt.Errorf("strip: temp: %w", err)
+	}
+	tmpPath := tmp.Name()
+	tmp.Close()
+	os.Remove(tmpPath)
+
+	if err := StripTo(path, tmpPath); err != nil {
+		os.Remove(tmpPath)
+		return "", err
+	}
+	return tmpPath, nil
+}
+
+// IsImageFile returns true if the file at path has a JPEG or PNG magic signature.
+func IsImageFile(path string) (bool, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return false, fmt.Errorf("strip: open: %w", err)
+	}
+	defer f.Close()
+
+	sig := make([]byte, 8)
+	if _, err := io.ReadFull(f, sig); err != nil {
+		return false, nil
+	}
+	return isJPEG(sig) || isPNG(sig), nil
+}
+
+func isJPEG(sig []byte) bool {
+	return len(sig) >= 2 && sig[0] == 0xFF && sig[1] == 0xD8
+}
+
+func isPNG(sig []byte) bool {
+	pngSig := []byte{137, 80, 78, 71, 13, 10, 26, 10}
+	return bytes.Equal(sig, pngSig)
+}
+
+// stripJPEGTo removes APP1 (EXIF) and APP13 (Photoshop/IPTC) markers.
+func stripJPEGTo(srcPath, destPath string) error {
+	f, err := os.Open(srcPath)
+	if err != nil {
+		return fmt.Errorf("strip: open: %w", err)
+	}
+	defer f.Close()
+
+	var buf bytes.Buffer
+	if _, err := io.Copy(&buf, f); err != nil {
+		return fmt.Errorf("strip: read: %w", err)
+	}
+	f.Close()
+
+	data := buf.Bytes()
+	if !isJPEG(data) {
+		return nil
+	}
+
+	var out bytes.Buffer
+	out.Write(data[:2]) // SOI
+
+	pos := 2
+	sosSeen := false
+	for pos+1 < len(data) {
+		if data[pos] != 0xFF {
+			break
+		}
+
+		marker := data[pos+1]
+
+		if marker == 0xDA {
+			out.Write(data[pos:])
+			sosSeen = true
+			break
+		}
+
+		if marker == 0xD9 || marker == 0x00 || marker == 0x01 {
+			if pos+2 > len(data) {
+				break
+			}
+			out.Write(data[pos : pos+2])
+			pos += 2
+			if marker == 0xD9 {
+				break
+			}
+			continue
+		}
+
+		if pos+3 >= len(data) {
+			break
+		}
+		segLen := int(binary.BigEndian.Uint16(data[pos+2:pos+4])) + 2
+		if pos+segLen > len(data) {
+			break
+		}
+
+		if marker != 0xE1 && marker != 0xED {
+			out.Write(data[pos : pos+segLen])
+		}
+
+		pos += segLen
+	}
+
+	if !sosSeen {
+		return fmt.Errorf("strip: no SOS marker found in JPEG")
+	}
+
+	return writeAtomic(destPath, out.Bytes())
+}
+
+// stripPNGTo removes tEXt, zTXt, iTXt, and eXIf metadata chunks.
+func stripPNGTo(srcPath, destPath string) error {
+	f, err := os.Open(srcPath)
+	if err != nil {
+		return fmt.Errorf("strip: open: %w", err)
+	}
+	defer f.Close()
+
+	var buf bytes.Buffer
+	if _, err := io.Copy(&buf, f); err != nil {
+		return fmt.Errorf("strip: read: %w", err)
+	}
+	f.Close()
+
+	data := buf.Bytes()
+	if len(data) < 8 || !isPNG(data[:8]) {
+		return nil
+	}
+
+	var out bytes.Buffer
+	out.Write(data[:8]) // signature
+
+	pos := 8
+	iendSeen := false
+	for pos+4 <= len(data) {
+		chunkLen := int(binary.BigEndian.Uint32(data[pos : pos+4]))
+		if pos+12+chunkLen > len(data) {
+			break
+		}
+		chunkType := string(data[pos+4 : pos+8])
+
+		if chunkType == "tEXt" || chunkType == "zTXt" || chunkType == "iTXt" || chunkType == "eXIf" {
+			pos += 12 + chunkLen
+			continue
+		}
+
+		if chunkType == "IEND" {
+			out.Write(data[pos : pos+12+chunkLen])
+			iendSeen = true
+			break
+		}
+
+		out.Write(data[pos : pos+12+chunkLen])
+		pos += 12 + chunkLen
+	}
+
+	if !iendSeen {
+		return fmt.Errorf("strip: no IEND chunk found in PNG")
+	}
+
+	return writeAtomic(destPath, out.Bytes())
+}
+
+// writeAtomic writes data to path via a temp file and rename.
+func writeAtomic(path string, data []byte) error {
+	dir := filepath.Dir(path)
+	tmp, err := os.CreateTemp(dir, ".localgo-write-*")
+	if err != nil {
+		return fmt.Errorf("strip: temp: %w", err)
+	}
+	tmpPath := tmp.Name()
+
+	if _, err := tmp.Write(data); err != nil {
+		tmp.Close()
+		os.Remove(tmpPath)
+		return fmt.Errorf("strip: write: %w", err)
+	}
+	if err := tmp.Sync(); err != nil {
+		tmp.Close()
+		os.Remove(tmpPath)
+		return fmt.Errorf("strip: sync: %w", err)
+	}
+	tmp.Close()
+
+	if err := os.Rename(tmpPath, path); err != nil {
+		os.Remove(tmpPath)
+		return fmt.Errorf("strip: rename: %w", err)
+	}
+	return nil
+}

+ 135 - 0
localsend/model/device.go

@@ -0,0 +1,135 @@
+package model
+
+import (
+	"fmt"
+	"net"
+	"sync"
+	"time"
+)
+
+// Device represents a peer device.
+type Device struct {
+	mu         sync.RWMutex
+	IP         string       `json:"ip"`
+	Version    string       `json:"version"` // LocalSend protocol version
+	Port       int          `json:"port"`
+	Alias      string       `json:"alias"`
+	Protocol   ProtocolType `json:"protocol"`
+
+	Fingerprint string     `json:"fingerprint"`
+	DeviceModel *string    `json:"deviceModel"` // nullable
+	DeviceType  DeviceType `json:"deviceType"`
+	Download    bool       `json:"download"` // Whether the device has download server running
+	LastSeen    time.Time  `json:"lastSeen"`
+	Available   bool       `json:"available"`
+}
+
+// NewDevice creates a new Device instance
+func NewDevice(info RegisterDto, ip net.IP, detectedPort int, detectedHttps bool) *Device {
+	// Use registered port/protocol if provided, otherwise use detected ones
+	port := info.Port
+	if port <= 0 {
+		port = detectedPort
+	}
+	protocol := ProtocolTypeHTTP
+	if detectedHttps {
+		protocol = ProtocolTypeHTTPS
+	}
+
+	return &Device{
+		IP:          ip.String(),
+		Version:     info.Version,
+		Port:        port,
+		Alias:       info.Alias,
+		Protocol:    protocol,
+		Fingerprint: info.Fingerprint,
+		DeviceModel: info.DeviceModel,
+		DeviceType:  info.DeviceType,
+		Download:    info.Download,
+		LastSeen:    time.Now(),
+		Available:   true,
+	}
+}
+
+// UpdateLastSeen updates the last seen timestamp for a device
+func (d *Device) UpdateLastSeen() {
+	d.mu.Lock()
+	d.LastSeen = time.Now()
+	d.Available = true
+	d.mu.Unlock()
+}
+
+// GetLastSeen returns the last seen timestamp (thread-safe).
+func (d *Device) GetLastSeen() time.Time {
+	d.mu.RLock()
+	defer d.mu.RUnlock()
+	return d.LastSeen
+}
+
+// GetAvailable returns whether the device is available (thread-safe).
+func (d *Device) GetAvailable() bool {
+	d.mu.RLock()
+	defer d.mu.RUnlock()
+	return d.Available
+}
+
+// SetLastSeen sets the last seen timestamp (thread-safe).
+func (d *Device) SetLastSeen(t time.Time) {
+	d.mu.Lock()
+	d.LastSeen = t
+	d.mu.Unlock()
+}
+
+// IsStale checks if a device hasn't been seen recently
+func (d *Device) IsStale(staleThreshold time.Duration) bool {
+	d.mu.RLock()
+	defer d.mu.RUnlock()
+	return time.Since(d.LastSeen) > staleThreshold
+}
+
+// ToDebugString returns a string representation suitable for debugging
+func (d *Device) ToDebugString() string {
+	shortFingerprint := d.Fingerprint
+	if len(shortFingerprint) > 8 {
+		shortFingerprint = shortFingerprint[:8]
+	}
+
+	var deviceModelStr string
+	if d.DeviceModel != nil {
+		deviceModelStr = *d.DeviceModel
+	} else {
+		deviceModelStr = "nil"
+	}
+
+	return fmt.Sprintf("Device{IP: %s, Protocol: %s, Port: %d, Alias: %s, Fingerprint: %s..., DeviceModel: %s, DeviceType: %s, Download: %t}",
+		d.IP, d.Protocol, d.Port, d.Alias, shortFingerprint, deviceModelStr, d.DeviceType, d.Download)
+}
+
+// ToInfoDto converts a Device to an InfoDto for API responses
+func (d *Device) ToInfoDto() InfoDto {
+	return InfoDto{
+		Alias:       d.Alias,
+		Version:     d.Version,
+		DeviceModel: d.DeviceModel,
+		DeviceType:  d.DeviceType,
+		Fingerprint: d.Fingerprint,
+		Download:    d.Download,
+	}
+}
+
+// FromMulticastDto creates a Device from a MulticastDto and source IP
+func FromMulticastDto(dto MulticastDto, ip net.IP) *Device {
+	return &Device{
+		IP:          ip.String(),
+		Version:     dto.Version,
+		Port:        dto.Port,
+		Alias:       dto.Alias,
+		Protocol:    dto.Protocol,
+		Fingerprint: dto.Fingerprint,
+		DeviceModel: dto.DeviceModel,
+		DeviceType:  dto.DeviceType,
+		Download:    dto.Download,
+		LastSeen:    time.Now(),
+		Available:   true,
+	}
+}

+ 167 - 0
localsend/model/dto.go

@@ -0,0 +1,167 @@
+// Package model contains the data structures used throughout the LocalGo application
+package model
+
+const DefaultPort = 53317
+
+// DeviceType defines the type of the device.
+type DeviceType string
+
+const (
+	DeviceTypeMobile   DeviceType = "mobile"
+	DeviceTypeDesktop  DeviceType = "desktop"
+	DeviceTypeWeb      DeviceType = "web"
+	DeviceTypeHeadless DeviceType = "headless"
+	DeviceTypeServer   DeviceType = "server"
+)
+
+// ProtocolType defines the protocol type.
+type ProtocolType string
+
+const (
+	ProtocolTypeHTTP  ProtocolType = "http"
+	ProtocolTypeHTTPS ProtocolType = "https"
+)
+
+// DeviceInfo contains information about the current device
+type DeviceInfo struct {
+	Alias        string
+	Version      string
+	DeviceModel  *string
+	DeviceType   DeviceType
+	Fingerprint  string
+	Port         int
+	DownloadDir  string
+	IP           string
+	Download     bool
+	HttpsEnabled bool
+}
+
+// ToInfoDto converts DeviceInfo to InfoDto
+func (d *DeviceInfo) ToInfoDto() InfoDto {
+	return InfoDto{
+		Alias:       d.Alias,
+		Version:     d.Version,
+		DeviceModel: d.DeviceModel,
+		DeviceType:  d.DeviceType,
+		Fingerprint: d.Fingerprint,
+		Download:    d.Download,
+	}
+}
+
+// ToRegisterDto converts DeviceInfo to RegisterDto
+func (d *DeviceInfo) ToRegisterDto() RegisterDto {
+	protocol := ProtocolTypeHTTP
+	if d.HttpsEnabled {
+		protocol = ProtocolTypeHTTPS
+	}
+
+	return RegisterDto{
+		Alias:       d.Alias,
+		Version:     d.Version,
+		DeviceModel: d.DeviceModel,
+		DeviceType:  d.DeviceType,
+		Fingerprint: d.Fingerprint,
+		Port:        d.Port,
+		Protocol:    protocol,
+		Download:    d.Download,
+	}
+}
+
+// ToMulticastDto converts DeviceInfo to MulticastDto
+func (d *DeviceInfo) ToMulticastDto(announce bool) MulticastDto {
+	protocol := ProtocolTypeHTTP
+	if d.HttpsEnabled {
+		protocol = ProtocolTypeHTTPS
+	}
+
+	return MulticastDto{
+		Alias:       d.Alias,
+		Version:     d.Version,
+		DeviceModel: d.DeviceModel,
+		DeviceType:  d.DeviceType,
+		Fingerprint: d.Fingerprint,
+		Port:        d.Port,
+		Protocol:    protocol,
+		Download:    d.Download,
+		Announce:    announce,
+	}
+}
+
+// InfoDto represents the response for /info and /register endpoints.
+// Also used as the info block in prepare-upload requests (port + protocol required).
+type InfoDto struct {
+	Alias       string       `json:"alias"`
+	Version     string       `json:"version"`
+	DeviceModel *string      `json:"deviceModel"` // nullable
+	DeviceType  DeviceType   `json:"deviceType"`
+	Fingerprint string       `json:"fingerprint"`
+	Port        int          `json:"port,omitempty"`
+	Protocol    ProtocolType `json:"protocol,omitempty"`
+	Download    bool         `json:"download"`
+}
+
+// RegisterDto represents the request body for /register endpoint (sent by the discoverer).
+type RegisterDto struct {
+	Alias       string       `json:"alias"`
+	Version     string       `json:"version"`
+	DeviceModel *string      `json:"deviceModel"` // nullable
+	DeviceType  DeviceType   `json:"deviceType"`
+	Fingerprint string       `json:"fingerprint"`
+	Port        int          `json:"port"`
+	Protocol    ProtocolType `json:"protocol"` // "http" or "https"
+	Download    bool         `json:"download"`
+}
+
+// MulticastDto represents the UDP discovery message.
+type MulticastDto struct {
+	Alias       string       `json:"alias"`
+	Version     string       `json:"version"`
+	DeviceModel *string      `json:"deviceModel"` // nullable
+	DeviceType  DeviceType   `json:"deviceType"`
+	Fingerprint string       `json:"fingerprint"`
+	Port        int          `json:"port"`
+	Protocol    ProtocolType `json:"protocol"` // http | https
+	Download    bool         `json:"download"`
+	Announce    bool         `json:"announce"` // True if initial announcement, false if response
+}
+
+// PrepareUploadRequestDto is sent to prepare file uploads
+type PrepareUploadRequestDto struct {
+	Info  InfoDto            `json:"info"`
+	Files map[string]FileDto `json:"files"`
+}
+
+// FileDto contains information about a file being uploaded
+type FileDto struct {
+	ID       string        `json:"id"`
+	FileName string        `json:"fileName"`
+	Size     int64         `json:"size"`
+	FileType string        `json:"fileType"`
+	SHA256   *string       `json:"sha256,omitempty"`   // Use pointer for nullable
+	Preview  *string       `json:"preview,omitempty"`  // Use pointer for nullable
+	Metadata *FileMetadata `json:"metadata,omitempty"` // Use pointer for nullable
+}
+
+// FileMetadata holds optional file metadata (added in v2.2)
+type FileMetadata struct {
+	Modified *string `json:"modified,omitempty"` // Using string for ISO 8601 format
+	Accessed *string `json:"accessed,omitempty"` // Using string for ISO 8601 format
+}
+
+// PrepareUploadResponseDto is returned after a successful upload preparation
+type PrepareUploadResponseDto struct {
+	SessionID string            `json:"sessionId"`
+	Files     map[string]string `json:"files"`
+}
+
+// ReceiveRequestResponseDto is returned for download preparations
+type ReceiveRequestResponseDto struct {
+	Info      InfoDto            `json:"info"` // Added Info field as per protocol spec
+	SessionID string             `json:"sessionId"`
+	Files     map[string]FileDto `json:"files"` // Changed to use FileDto
+}
+
+// StatusDto contains status information for a file
+type StatusDto struct {
+	Status string `json:"status"`
+}

+ 114 - 0
localsend/model/file.go

@@ -0,0 +1,114 @@
+package model
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"mime"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// File represents a file to be shared or received
+type File struct {
+	ID           string
+	Name         string
+	Path         string
+	Size         int64
+	Type         string
+	SHA256       string
+	LastModified int64
+}
+
+// NewFile creates a File instance from a file path
+func NewFile(path string) (*File, error) {
+	info, err := os.Stat(path)
+	if err != nil {
+		return nil, err
+	}
+
+	fileType := determineFileType(path)
+
+	file := &File{
+		ID:           generateID(path),
+		Name:         filepath.Base(path),
+		Path:         path,
+		Size:         info.Size(),
+		Type:         fileType,
+		LastModified: info.ModTime().UnixMilli(),
+	}
+
+	// Calculate SHA-256 hash asynchronously for large files
+	if file.Size < 50*1024*1024 { // Only pre-calculate for files < 50MB
+		hash, err := calculateSHA256(path)
+		if err == nil {
+			file.SHA256 = hash
+		}
+	}
+
+	return file, nil
+}
+
+// ToFileDto converts a File to a FileDto for API responses
+func (f *File) ToFileDto() FileDto {
+	var sha256Ptr *string
+	if f.SHA256 != "" {
+		sha256Ptr = &f.SHA256 // Assign address if SHA256 is calculated
+	}
+
+	modTime := time.Unix(0, f.LastModified).Format(time.RFC3339)
+	return FileDto{
+		ID:       f.ID,
+		FileName: f.Name,
+		Size:     f.Size,
+		FileType: f.Type,
+		SHA256:   sha256Ptr,
+		Metadata: &FileMetadata{
+			Modified: &modTime,
+		},
+	}
+}
+
+// generateID creates a unique ID for a file based on path and timestamp
+func generateID(path string) string {
+	timestamp := time.Now().UnixNano()
+	data := []byte(fmt.Sprintf("%s-%d", path, timestamp))
+	hash := sha256.Sum256(data)
+	return hex.EncodeToString(hash[:8]) // Use first 8 bytes for a shorter ID
+}
+
+// determineFileType attempts to determine the file type from extension or content
+func determineFileType(path string) string {
+	ext := filepath.Ext(path)
+	if mimeType := mime.TypeByExtension(ext); mimeType != "" {
+		return mimeType
+	}
+
+	// Fallbacks if mime package doesn't know the extension
+	switch ext {
+	case ".md":
+		return "text/markdown"
+	case ".apk":
+		return "application/vnd.android.package-archive"
+	default:
+		return "application/octet-stream"
+	}
+}
+
+// calculateSHA256 calculates the SHA-256 hash of a file
+func calculateSHA256(path string) (string, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+
+	h := sha256.New()
+	if _, err := io.Copy(h, f); err != nil {
+		return "", err
+	}
+
+	return hex.EncodeToString(h.Sum(nil)), nil
+}

+ 299 - 0
localsend/network/interfaces.go

@@ -0,0 +1,299 @@
+// Package network provides network-related utilities for LocalGo
+package network
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"strings"
+)
+
+// systemInterface is a network interface paired with its IPv4 addresses.
+// It exists so interface enumeration can be performed without netlink, which
+// the Android/Termux sandbox blocks (net.Interfaces() returns EPERM there).
+type systemInterface struct {
+	net.Interface
+	addrs []*net.IPNet
+}
+
+// systemInterfaces returns all system interfaces with their IPv4 addresses.
+// It uses netlink-based enumeration (net.Interfaces); on android it
+// transparently falls back to ioctl-based enumeration when netlink is
+// unavailable (see platformInterfaceFallback).
+func systemInterfaces() ([]systemInterface, error) {
+	ifs, err := net.Interfaces()
+	if err != nil {
+		return platformInterfaceFallback(err)
+	}
+
+	out := make([]systemInterface, 0, len(ifs))
+	for _, i := range ifs {
+		addrs, err := i.Addrs()
+		if err != nil {
+			continue
+		}
+		si := systemInterface{Interface: i}
+		for _, a := range addrs {
+			if ipn, ok := a.(*net.IPNet); ok && ipn.IP.To4() != nil {
+				si.addrs = append(si.addrs, ipn)
+			}
+		}
+		out = append(out, si)
+	}
+	return out, nil
+}
+
+// systemInterfaceByName returns the interface with the given name, or an error.
+func systemInterfaceByName(name string) (*systemInterface, error) {
+	ifs, err := systemInterfaces()
+	if err != nil {
+		return nil, err
+	}
+	for i := range ifs {
+		if ifs[i].Name == name {
+			return &ifs[i], nil
+		}
+	}
+	return nil, fmt.Errorf("interface %q not found", name)
+}
+
+// EnumerateInterfaces returns all system interfaces. Unlike net.Interfaces,
+// this works even where netlink is blocked (e.g. Android/Termux).
+func EnumerateInterfaces() ([]net.Interface, error) {
+	ifs, err := systemInterfaces()
+	if err != nil {
+		return nil, err
+	}
+	out := make([]net.Interface, 0, len(ifs))
+	for _, i := range ifs {
+		out = append(out, i.Interface)
+	}
+	return out, nil
+}
+
+// InterfaceByName returns the named system interface. Unlike net.InterfaceByName,
+// this works even where netlink is blocked (e.g. Android/Termux).
+func InterfaceByName(name string) (*net.Interface, error) {
+	si, err := systemInterfaceByName(name)
+	if err != nil {
+		return nil, err
+	}
+	iface := si.Interface
+	return &iface, nil
+}
+
+// GetLocalIP returns the primary non-loopback IP address of the machine
+func GetLocalIP() (string, error) {
+	ifs, err := systemInterfaces()
+	if err != nil {
+		return "", err
+	}
+
+	for _, i := range ifs {
+		if (i.Flags&net.FlagUp) == 0 || (i.Flags&net.FlagLoopback) != 0 {
+			continue
+		}
+		for _, ipn := range i.addrs {
+			if !ipn.IP.IsLoopback() {
+				return ipn.IP.String(), nil
+			}
+		}
+	}
+
+	return "", errors.New("no suitable local IP address found")
+}
+
+// GetLocalIPAddresses returns a list of local IP addresses for all non-loopback interfaces
+func GetLocalIPAddresses() ([]net.IP, error) {
+	var ips []net.IP
+	ifs, err := systemInterfaces()
+	if err != nil {
+		return nil, err
+	}
+
+	for _, i := range ifs {
+		// Skip down and loopback interfaces
+		if (i.Flags&net.FlagUp) == 0 || (i.Flags&net.FlagLoopback) != 0 {
+			continue
+		}
+		for _, ipn := range i.addrs {
+			ips = append(ips, ipn.IP)
+		}
+	}
+	return ips, nil
+}
+
+// GetInterfaceIPs returns all IP addresses for a specific network interface
+func GetInterfaceIPs(interfaceName string) ([]string, error) {
+	iface, err := systemInterfaceByName(interfaceName)
+	if err != nil {
+		return nil, err
+	}
+
+	var ips []string
+	for _, ipn := range iface.addrs {
+		ips = append(ips, ipn.IP.String())
+	}
+	return ips, nil
+}
+
+// FormatAddress formats an IP address and port into a URL
+func FormatAddress(ip net.IP, port int, https bool) string {
+	protocol := "http"
+	if https {
+		protocol = "https"
+	}
+
+	ipStr := ip.String()
+
+	// Check if it's an IPv6 address
+	if strings.Contains(ipStr, ":") {
+		return fmt.Sprintf("%s://[%s]:%d", protocol, ipStr, port)
+	}
+
+	return fmt.Sprintf("%s://%s:%d", protocol, ipStr, port)
+}
+
+// GetPreferredOutboundIP gets the preferred IP address for outbound connections
+func GetPreferredOutboundIP() (net.IP, error) {
+	// Connect to a public IP (no actual connection is made)
+	conn, err := net.Dial("udp", "8.8.8.8:80")
+	if err != nil {
+		return nil, fmt.Errorf("failed to determine preferred outbound IP: %w", err)
+	}
+	defer conn.Close()
+
+	// Get the local address
+	localAddr := conn.LocalAddr().(*net.UDPAddr)
+
+	return localAddr.IP, nil
+}
+
+// ParseCIDRRange parses a CIDR notation (e.g. "192.168.1.0/24") and returns
+// all usable host IPs in that range (network and broadcast addresses excluded).
+func ParseCIDRRange(cidr string) ([]net.IP, error) {
+	_, ipnet, err := net.ParseCIDR(cidr)
+	if err != nil {
+		return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
+	}
+
+	ip4 := ipnet.IP.To4()
+	if ip4 == nil {
+		return nil, fmt.Errorf("CIDR %q is not an IPv4 range", cidr)
+	}
+
+	ones, bits := ipnet.Mask.Size()
+	hostBits := bits - ones
+	if hostBits < 2 || hostBits > 30 {
+		return nil, fmt.Errorf("CIDR %q prefix length must be /8–/30", cidr)
+	}
+
+	base := uint32(ip4[0])<<24 | uint32(ip4[1])<<16 | uint32(ip4[2])<<8 | uint32(ip4[3])
+	totalHosts := (1 << hostBits) - 2
+	var ips []net.IP
+	for i := 1; i <= totalHosts; i++ {
+		addr := base + uint32(i)
+		ips = append(ips, net.IPv4(byte(addr>>24), byte(addr>>16), byte(addr>>8), byte(addr)))
+	}
+	return ips, nil
+}
+
+// GetSubnetIPs returns all IP addresses in the same /24 subnet as the given IP
+func GetSubnetIPs(ip net.IP) []net.IP {
+	ip4 := ip.To4()
+	if ip4 == nil {
+		return nil
+	}
+
+	var ips []net.IP
+	// Assume /24 subnet for local discovery (most common)
+	// Iterate 1-254
+	for i := 1; i <= 254; i++ {
+		// Create a copy of the base IP
+		newIP := make(net.IP, len(ip4))
+		copy(newIP, ip4)
+		newIP[3] = byte(i)
+
+		// Skip if it matches the original IP (optional, but scan logic might want to include self for testing)
+		// Keeping self allows "finding" the local node which confirms scan is running.
+		ips = append(ips, newIP)
+	}
+	return ips
+}
+
+// GetInterfaceIPNet returns the IPv4 network (IP + subnet mask) for the named interface.
+// Returns nil if the interface has no IPv4 address.
+func GetInterfaceIPNet(ifaceName string) (*net.IPNet, error) {
+	iface, err := systemInterfaceByName(ifaceName)
+	if err != nil {
+		return nil, fmt.Errorf("interface %q: %w", ifaceName, err)
+	}
+	if len(iface.addrs) > 0 {
+		return iface.addrs[0], nil
+	}
+	return nil, fmt.Errorf("interface %q has no IPv4 address", ifaceName)
+}
+
+// GetUsableSubnetIPsFromIP returns all usable host IPs in the subnet of the
+// interface that owns the given IP, respecting its actual netmask. Falls back
+// to a flat /24 scan if the interface cannot be determined.
+func GetUsableSubnetIPsFromIP(ip net.IP) ([]net.IP, error) {
+	ipStr := ip.String()
+	ifs, err := systemInterfaces()
+	if err != nil {
+		return GetSubnetIPs(ip), nil
+	}
+	for _, i := range ifs {
+		if (i.Flags&net.FlagUp) == 0 || (i.Flags&net.FlagLoopback) != 0 {
+			continue
+		}
+		for _, ipn := range i.addrs {
+			if ipn.IP.String() == ipStr {
+				return GetUsableSubnetIPs(i.Name)
+			}
+		}
+	}
+	return GetSubnetIPs(ip), nil
+}
+
+// GetUsableSubnetIPs returns all usable host IPs in the subnet of the named
+// interface, respecting its actual netmask. Subnets with more than 1022 hosts
+// (larger than /22) are capped at /22 to keep scanning practical. Network and
+// broadcast addresses are excluded.
+func GetUsableSubnetIPs(ifaceName string) ([]net.IP, error) {
+	ipnet, err := GetInterfaceIPNet(ifaceName)
+	if err != nil {
+		return nil, err
+	}
+
+	ip4 := ipnet.IP.To4()
+	if ip4 == nil {
+		return nil, fmt.Errorf("interface %q has no IPv4 address", ifaceName)
+	}
+
+	ones, bits := ipnet.Mask.Size()
+	hostBits := bits - ones
+
+	// Cap at /22 for practical scanning (max 1022 usable hosts)
+	effectiveMask := ipnet.Mask
+	if hostBits > 10 {
+		effectiveMask = net.CIDRMask(22, bits)
+		hostBits = bits - 22
+	}
+
+	if hostBits < 2 {
+		return nil, fmt.Errorf("interface %q subnet prefix /%d is too small for scanning", ifaceName, bits-hostBits)
+	}
+
+	maskBits := []byte{effectiveMask[0], effectiveMask[1], effectiveMask[2], effectiveMask[3]}
+	base := uint32(ip4[0])<<24 | uint32(ip4[1])<<16 | uint32(ip4[2])<<8 | uint32(ip4[3])
+	base &= uint32(maskBits[0])<<24 | uint32(maskBits[1])<<16 | uint32(maskBits[2])<<8 | uint32(maskBits[3])
+
+	totalHosts := (1 << hostBits) - 2
+	var ips []net.IP
+	for i := 1; i <= totalHosts; i++ {
+		addr := base + uint32(i)
+		ips = append(ips, net.IPv4(byte(addr>>24), byte(addr>>16), byte(addr>>8), byte(addr)))
+	}
+	return ips, nil
+}

+ 192 - 0
localsend/network/interfaces_android.go

@@ -0,0 +1,192 @@
+//go:build android
+
+// Interface enumeration fallback for Android (including Termux).
+//
+// The Android sandbox (SELinux) blocks the netlink route dumps that
+// net.Interfaces()/net.InterfaceAddrs() rely on, so enumeration falls back
+// to the SIOCGIFCONF family of ioctls, which the sandbox still permits.
+// This file is compiled on android only; every other platform uses the
+// standard library directly (see interfaces_default.go).
+package network
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"unsafe"
+
+	"golang.org/x/sys/unix"
+)
+
+// Linux ioctl request codes (linux/sockios.h). Values are identical on arm64.
+const (
+	siocGIFCONF    = 0x8912
+	siocGIFFLAGS   = 0x8913
+	siocGIFNETMASK = 0x891b
+	siocGIFINDEX   = 0x8933
+)
+
+// ifreqSize is sizeof(struct ifreq) on 64-bit Linux: 16-byte name + 24-byte
+// union (sized by struct ifmap). All supported android targets are 64-bit
+// (arm64), so a fixed stride is safe.
+const ifreqSize = 40
+
+// maxIfconfSize caps the SIOCGIFCONF buffer growth below.
+const maxIfconfSize = 1 << 20
+
+// ifreq is the Linux ioctl request struct: 16-byte name + 24-byte union.
+type ifreq struct {
+	name  [unix.IFNAMSIZ]byte
+	union [24]byte
+}
+
+// ifconf is the argument struct for SIOCGIFCONF.
+type ifconf struct {
+	length int32
+	buf    uintptr
+}
+
+// platformInterfaceFallback enumerates interfaces via ioctls when netlink is
+// unavailable.
+func platformInterfaceFallback(origErr error) ([]systemInterface, error) {
+	fallback, err := ioctlSystemInterfaces()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get network interfaces: %w", errors.Join(origErr, err))
+	}
+	return fallback, nil
+}
+
+func ioctl(fd uintptr, req uintptr, arg unsafe.Pointer) error {
+	if _, _, errno := unix.Syscall(unix.SYS_IOCTL, fd, req, uintptr(arg)); errno != 0 {
+		return errno
+	}
+	return nil
+}
+
+// ioctlSystemInterfaces lists interfaces and their primary IPv4 addresses via
+// SIOCGIFCONF, plus per-interface flags/index/netmask ioctls.
+func ioctlSystemInterfaces() ([]systemInterface, error) {
+	fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0)
+	if err != nil {
+		return nil, err
+	}
+	defer unix.Close(fd)
+
+	// SIOCGIFCONF: grow the buffer until it holds the full interface list.
+	var buf []byte
+	for size := 4096; ; size *= 2 {
+		if size > maxIfconfSize {
+			return nil, errors.New("SIOCGIFCONF interface list exceeds size limit")
+		}
+		buf = make([]byte, size)
+		conf := ifconf{length: int32(size), buf: uintptr(unsafe.Pointer(&buf[0]))}
+		if err := ioctl(uintptr(fd), siocGIFCONF, unsafe.Pointer(&conf)); err != nil {
+			return nil, fmt.Errorf("SIOCGIFCONF: %w", err)
+		}
+		if conf.length < int32(size) {
+			buf = buf[:conf.length]
+			break
+		}
+	}
+
+	if len(buf) == 0 {
+		return nil, errors.New("SIOCGIFCONF returned no interfaces")
+	}
+
+	seen := make(map[string]bool)
+	var out []systemInterface
+	for off := 0; off+ifreqSize <= len(buf); off += ifreqSize {
+		name := nullString(buf[off : off+unix.IFNAMSIZ])
+		if name == "" || seen[name] {
+			continue
+		}
+		seen[name] = true
+
+		// Only AF_INET entries carry a usable IPv4 address here.
+		family := int(buf[off+16]) | int(buf[off+17])<<8
+		if family != unix.AF_INET {
+			continue
+		}
+
+		si := systemInterface{}
+		si.Name = name
+		si.Index = ioctlIndex(fd, name)
+		si.Flags = ioctlFlags(fd, name)
+
+		ip := net.IPv4(buf[off+20], buf[off+21], buf[off+22], buf[off+23])
+		mask := ioctlNetmask(fd, name)
+		if mask == nil {
+			mask = net.CIDRMask(24, 32)
+		}
+		si.addrs = append(si.addrs, &net.IPNet{IP: ip, Mask: mask})
+
+		out = append(out, si)
+	}
+
+	if len(out) == 0 {
+		return nil, errors.New("no AF_INET interfaces found via ioctl")
+	}
+	return out, nil
+}
+
+func nullString(b []byte) string {
+	for i, c := range b {
+		if c == 0 {
+			return string(b[:i])
+		}
+	}
+	return string(b)
+}
+
+func ioctlIndex(fd int, name string) int {
+	var req ifreq
+	copy(req.name[:], name)
+	if ioctl(uintptr(fd), siocGIFINDEX, unsafe.Pointer(&req)) != nil {
+		return 0
+	}
+	idx := uint32(req.union[0]) | uint32(req.union[1])<<8 | uint32(req.union[2])<<16 | uint32(req.union[3])<<24
+	return int(int32(idx))
+}
+
+// ioctlFlags maps the Linux IFF_* bits of the named interface to net.Flags.
+func ioctlFlags(fd int, name string) net.Flags {
+	var req ifreq
+	copy(req.name[:], name)
+	if ioctl(uintptr(fd), siocGIFFLAGS, unsafe.Pointer(&req)) != nil {
+		return 0
+	}
+	iff := uint16(req.union[0]) | uint16(req.union[1])<<8
+	var f net.Flags
+	if iff&0x0001 != 0 { // IFF_UP
+		f |= net.FlagUp
+	}
+	if iff&0x0002 != 0 { // IFF_BROADCAST
+		f |= net.FlagBroadcast
+	}
+	if iff&0x0008 != 0 { // IFF_LOOPBACK
+		f |= net.FlagLoopback
+	}
+	if iff&0x0010 != 0 { // IFF_POINTOPOINT
+		f |= net.FlagPointToPoint
+	}
+	if iff&0x0040 != 0 { // IFF_RUNNING
+		f |= net.FlagRunning
+	}
+	if iff&0x1000 != 0 { // IFF_MULTICAST
+		f |= net.FlagMulticast
+	}
+	return f
+}
+
+func ioctlNetmask(fd int, name string) net.IPMask {
+	var req ifreq
+	copy(req.name[:], name)
+	if ioctl(uintptr(fd), siocGIFNETMASK, unsafe.Pointer(&req)) != nil {
+		return nil
+	}
+	family := int(req.union[0]) | int(req.union[1])<<8
+	if family != unix.AF_INET {
+		return nil
+	}
+	return net.IPv4Mask(req.union[4], req.union[5], req.union[6], req.union[7])
+}

+ 12 - 0
localsend/network/interfaces_default.go

@@ -0,0 +1,12 @@
+//go:build !android
+
+package network
+
+import "fmt"
+
+// platformInterfaceFallback reports the enumeration error unchanged.
+// Only android carries an ioctl-based fallback (interfaces_android.go);
+// everywhere else a net.Interfaces() failure is a genuine error.
+func platformInterfaceFallback(origErr error) ([]systemInterface, error) {
+	return nil, fmt.Errorf("failed to get network interfaces: %w", origErr)
+}

+ 296 - 0
localsend/send/send.go

@@ -0,0 +1,296 @@
+// Package send implements the LocalSend v2.2 sending side.
+//
+// It is a trimmed, zero-external-dependency port of the official localgo
+// send pipeline: given a known target device (from discovery) and a list of
+// local file paths, it performs /prepare-upload and then streams each file
+// over /upload using the LocalSend v2.2 protocol (mTLS client cert + optional
+// server fingerprint pinning).
+package send
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/sha256"
+	"crypto/tls"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"TengTuToolbox/localsend/crypto"
+	"TengTuToolbox/localsend/logging"
+	"TengTuToolbox/localsend/model"
+)
+
+// ProtocolVersion is the LocalSend protocol version implemented here.
+const ProtocolVersion = "2.2"
+
+// SendConfig carries the sender identity + security context.
+type SendConfig struct {
+	Alias           string
+	Port            int // local listen port (announced to peers)
+	SecurityContext *crypto.StoredSecurityContext
+	HttpsEnabled    bool
+	Concurrency     int // max parallel uploads (0 = default 4)
+}
+
+// SendFiles sends the given local file/directory paths to a target device.
+func SendFiles(ctx context.Context, cfg *SendConfig, device *model.Device, filePaths []string, logger *logging.Logger) error {
+	if logger == nil {
+		logger = logging.NewQuiet()
+	}
+	if cfg == nil {
+		return errors.New("send: nil SendConfig")
+	}
+	if device == nil {
+		return errors.New("send: nil target device")
+	}
+	if len(filePaths) == 0 {
+		return errors.New("send: no files to send")
+	}
+	if cfg.Port <= 0 {
+		cfg.Port = model.DefaultPort
+	}
+
+	client, scheme, err := newHTTPClient(cfg, device)
+	if err != nil {
+		return err
+	}
+
+	fileMap, err := getFilesWithRelativePaths(filePaths)
+	if err != nil {
+		return fmt.Errorf("failed to process file paths: %w", err)
+	}
+
+	filesDtoMap, filePathMap, err := buildFileDTOs(fileMap)
+	if err != nil {
+		return err
+	}
+
+	fingerprint := cfg.SecurityContext.CertificateHash
+	prepareDto := model.PrepareUploadRequestDto{
+		Info: model.InfoDto{
+			Alias:       cfg.Alias,
+			Version:     ProtocolVersion,
+			DeviceType:  model.DeviceTypeDesktop,
+			Fingerprint: fingerprint,
+			Port:        device.Port,
+			Protocol:    model.ProtocolType(scheme),
+			Download:    true,
+		},
+		Files: filesDtoMap,
+	}
+
+	jsonData, err := json.Marshal(prepareDto)
+	if err != nil {
+		return fmt.Errorf("failed to marshal prepare dto: %w", err)
+	}
+
+	prepareResponse, err := doPrepare(ctx, client, device, scheme, jsonData)
+	if err != nil {
+		return err
+	}
+
+	concurrency := cfg.Concurrency
+	if concurrency <= 0 {
+		concurrency = 4
+	}
+	sem := make(chan struct{}, concurrency)
+
+	var wg sync.WaitGroup
+	errCh := make(chan error, len(prepareResponse.Files))
+
+	for fileID, token := range prepareResponse.Files {
+		filePath, ok := filePathMap[fileID]
+		if !ok {
+			logger.Warnf("Server responded with unknown file ID: %s", fileID)
+			continue
+		}
+		displayName := filepath.Base(filePath)
+		if remoteName, ok := fileMap[filePath]; ok && remoteName != "" {
+			displayName = remoteName
+		}
+
+		wg.Add(1)
+		go func(fID, tkn, fPath, name string) {
+			defer wg.Done()
+			sem <- struct{}{}
+			defer func() { <-sem }()
+
+			logger.Infof("Uploading file: %s", name)
+			if err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, logger); err != nil {
+				logger.Errorf("Failed to upload %s: %v", name, err)
+				errCh <- fmt.Errorf("failed to upload %s: %w", name, err)
+			}
+		}(fileID, token, filePath, displayName)
+	}
+
+	wg.Wait()
+	close(errCh)
+
+	var uploadErrors []error
+	for err := range errCh {
+		uploadErrors = append(uploadErrors, err)
+	}
+	if len(uploadErrors) > 0 {
+		return fmt.Errorf("encountered %d upload error(s), first error: %w", len(uploadErrors), uploadErrors[0])
+	}
+	logger.Info("All files uploaded successfully!")
+	return nil
+}
+
+// newHTTPClient builds an http.Client that presents our mTLS client cert and,
+// when the target fingerprint is known, pins the server certificate.
+func newHTTPClient(cfg *SendConfig, device *model.Device) (*http.Client, string, error) {
+	var getClientCert func(*tls.CertificateRequestInfo) (*tls.Certificate, error)
+	if cfg.SecurityContext != nil {
+		fn, err := cfg.SecurityContext.ClientCertificateFunc()
+		if err != nil {
+			return nil, "", fmt.Errorf("failed to load client TLS certificate: %w", err)
+		}
+		getClientCert = fn
+	}
+
+	scheme := "http"
+	if device.Protocol == model.ProtocolTypeHTTPS {
+		scheme = "https"
+		tlsConfig := &tls.Config{
+			InsecureSkipVerify:   true,
+			GetClientCertificate: getClientCert,
+		}
+		if device.Fingerprint != "" {
+			expected := device.Fingerprint
+			tlsConfig.VerifyConnection = func(state tls.ConnectionState) error {
+				if len(state.PeerCertificates) == 0 {
+					return errors.New("no peer certificates presented")
+				}
+				cert := state.PeerCertificates[0]
+				hash := sha256.Sum256(cert.Raw)
+				actual := hex.EncodeToString(hash[:])
+				if !strings.EqualFold(actual, expected) {
+					return fmt.Errorf("TLS certificate fingerprint mismatch: expected %s, got %s", expected, actual)
+				}
+				return nil
+			}
+		}
+		tr := &http.Transport{TLSClientConfig: tlsConfig}
+		return &http.Client{Transport: tr}, scheme, nil
+	}
+
+	// HTTP fallback (unlikely for LocalSend but supported)
+	return &http.Client{}, scheme, nil
+}
+
+// doPrepare posts /prepare-upload and decodes the session response.
+func doPrepare(ctx context.Context, client *http.Client, device *model.Device, scheme string, jsonData []byte) (*model.PrepareUploadResponseDto, error) {
+	endpoint := fmt.Sprintf("%s://%s/api/localsend/v2/prepare-upload", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)))
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(jsonData)))
+	if err != nil {
+		return nil, fmt.Errorf("failed to create prepare request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to send prepare request: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("prepare request failed with status: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
+	}
+
+	var prepareResponse model.PrepareUploadResponseDto
+	if err := json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(&prepareResponse); err != nil {
+		return nil, fmt.Errorf("failed to decode prepare response: %w", err)
+	}
+	return &prepareResponse, nil
+}
+
+// getFilesWithRelativePaths expands a list of paths (files or dirs) into a
+// map of localPath -> remote relative name (slash-separated).
+func getFilesWithRelativePaths(paths []string) (map[string]string, error) {
+	result := make(map[string]string)
+	for _, p := range paths {
+		p = filepath.Clean(p)
+		info, err := os.Stat(p)
+		if err != nil {
+			return nil, err
+		}
+		if info.IsDir() {
+			baseDir := filepath.Dir(p)
+			err = filepath.Walk(p, func(path string, fInfo os.FileInfo, err error) error {
+				if err != nil {
+					return err
+				}
+				if !fInfo.IsDir() {
+					rel, err := filepath.Rel(baseDir, path)
+					if err == nil {
+						result[path] = filepath.ToSlash(rel)
+					} else {
+						result[path] = filepath.Base(path)
+					}
+				}
+				return nil
+			})
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			result[p] = filepath.Base(p)
+		}
+	}
+	return result, nil
+}
+
+// buildFileDTOs builds the FileDto map and the fileID -> localPath map.
+func buildFileDTOs(fileMap map[string]string) (map[string]model.FileDto, map[string]string, error) {
+	filesDtoMap := make(map[string]model.FileDto)
+	filePathMap := make(map[string]string)
+
+	for filePath, remoteName := range fileMap {
+		fileInfo, err := os.Stat(filePath)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to get file info for %s: %w", filePath, err)
+		}
+
+		file, err := os.Open(filePath)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to open file for detection %s: %w", filePath, err)
+		}
+		buffer := make([]byte, 512)
+		n, _ := file.Read(buffer)
+		file.Close()
+		contentType := http.DetectContentType(buffer[:n])
+
+		modTime := fileInfo.ModTime().Format(time.RFC3339)
+		fileDto := model.FileDto{
+			ID:       randomID(),
+			FileName: remoteName,
+			Size:     fileInfo.Size(),
+			FileType: contentType,
+			Metadata: &model.FileMetadata{Modified: &modTime},
+		}
+		filesDtoMap[fileDto.ID] = fileDto
+		filePathMap[fileDto.ID] = filePath
+	}
+	return filesDtoMap, filePathMap, nil
+}
+
+// randomID generates a random hex string (used as file/session IDs, no uuid dep).
+func randomID() string {
+	b := make([]byte, 16)
+	if _, err := rand.Read(b); err != nil {
+		return fmt.Sprintf("%x", time.Now().UnixNano())
+	}
+	return hex.EncodeToString(b)
+}

+ 99 - 0
localsend/send/upload.go

@@ -0,0 +1,99 @@
+package send
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"strconv"
+	"time"
+
+	"TengTuToolbox/localsend/logging"
+	"TengTuToolbox/localsend/model"
+)
+
+// uploadFile streams one local file to the receiver over /upload.
+func uploadFile(ctx context.Context, client *http.Client, device *model.Device, filePath, fileID, sessionID, token, scheme string, logger *logging.Logger) error {
+	if logger == nil {
+		logger = logging.NewQuiet()
+	}
+
+	file, err := os.Open(filePath)
+	if err != nil {
+		return fmt.Errorf("failed to open file: %w", err)
+	}
+	defer file.Close()
+
+	stat, err := file.Stat()
+	if err != nil {
+		return fmt.Errorf("failed to get file stats: %w", err)
+	}
+
+	url := fmt.Sprintf("%s://%s/api/localsend/v2/upload?sessionId=%s&fileId=%s&token=%s",
+		scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)), sessionID, fileID, token)
+
+	var body io.ReadCloser = file
+
+	// Wrap with idle timeout: cancel request if no data flows for 15s.
+	uploadCtx, cancel := context.WithCancel(ctx)
+	defer cancel()
+	body = newIdleTimeoutReader(body, 15*time.Second, cancel)
+	defer body.Close()
+
+	req, err := http.NewRequestWithContext(uploadCtx, http.MethodPost, url, body)
+	if err != nil {
+		cancel()
+		return fmt.Errorf("failed to create upload request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/octet-stream")
+	req.ContentLength = stat.Size()
+
+	resp, err := client.Do(req)
+	if err != nil {
+		if errors.Is(err, context.Canceled) {
+			return errors.New("upload stalled: no data transmitted for 15s")
+		}
+		return fmt.Errorf("failed to send upload request: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		if resp.StatusCode == http.StatusUnprocessableEntity {
+			return errors.New("upload rejected: receiver failed integrity verification (422)")
+		}
+		return fmt.Errorf("upload request failed with status: %s", resp.Status)
+	}
+
+	return nil
+}
+
+// idleTimeoutReader cancels the context if no data is read within the timeout.
+type idleTimeoutReader struct {
+	r           io.ReadCloser
+	idleTimeout time.Duration
+	timer       *time.Timer
+	cancel      func()
+}
+
+func newIdleTimeoutReader(r io.ReadCloser, timeout time.Duration, cancel func()) *idleTimeoutReader {
+	tr := &idleTimeoutReader{r: r, idleTimeout: timeout, cancel: cancel}
+	tr.timer = time.AfterFunc(timeout, func() { tr.cancel() })
+	return tr
+}
+
+func (tr *idleTimeoutReader) Read(p []byte) (int, error) {
+	tr.timer.Reset(tr.idleTimeout)
+	n, err := tr.r.Read(p)
+	if err != nil {
+		tr.timer.Stop()
+	}
+	return n, err
+}
+
+func (tr *idleTimeoutReader) Close() error {
+	tr.timer.Stop()
+	return tr.r.Close()
+}

+ 347 - 0
localsend_server.go

@@ -0,0 +1,347 @@
+// server.go — LocalSend v2.2 接收服务器。
+//
+// 监听 HTTPS 端口 53317,实现 LocalSend 协议 v2.2 的接收端点:
+//   - POST /api/localsend/v2/register        设备注册/发现响应
+//   - GET  /api/localsend/v2/info            本机信息
+//   - POST /api/localsend/v2/prepare-upload  发起发送:声明文件列表 → sessionId+fileToken
+//   - POST /api/localsend/v2/upload          上传文件体 → 保存到接收目录
+//   - POST /api/localsend/v2/cancel          取消传输
+//
+// 本机使用自签证书(mTLS),客户端须携带自签客户端证书;对端证书为自签,
+// 此处不校验(指纹校验由对端/应用层完成)。与官方 LocalSend 客户端完全互通。
+package main
+
+import (
+	"crypto/rand"
+	"crypto/tls"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"TengTuToolbox/localsend/crypto"
+	"TengTuToolbox/localsend/model"
+)
+
+// lsServer 是 LocalSend 接收服务器实例。
+type lsServer struct {
+	mu         sync.Mutex
+	httpServer *http.Server
+	listener   net.Listener
+	port       int
+
+	alias       string
+	receiveDir  string
+	autoAccept  bool
+	secCtx      *crypto.StoredSecurityContext
+
+	// 等待中的 prepare-upload 请求:sessionId → 确认/拒绝信号
+	pending     map[string]*pendingUpload
+	pendingMu   sync.Mutex
+	onFileOffer func(*fileOffer) // 收到文件时回调(通知前端,手动模式等待确认)
+}
+
+// pendingUpload 记录一个等待用户确认(手动接收)或已接受(自动接收)的上传会话。
+type pendingUpload struct {
+	sessionID string
+	info      model.InfoDto
+	files     map[string]model.FileDto
+	// 手动接收:wait 收到 true=接受、false=拒绝
+	wait chan bool
+	// 自动接收:已接受,无需等待
+	autoAccepted bool
+}
+
+// fileOffer 描述一次文件发送请求,转发给前端做提示。
+type fileOffer struct {
+	SessionID string              `json:"sessionId"`
+	Alias     string              `json:"alias"`
+	Files     []fileOfferItem     `json:"files"`
+	Auto      bool                `json:"auto"`
+}
+
+// fileOfferItem 是单文件的提示信息。
+type fileOfferItem struct {
+	ID       string `json:"id"`
+	FileName string `json:"fileName"`
+	Size     int64  `json:"size"`
+	FileType string `json:"fileType"`
+}
+
+// newLSServer 创建一个接收服务器。port<=0 时用默认 53317。
+func newLSServer(port int, secCtx *crypto.StoredSecurityContext) *lsServer {
+	if port <= 0 {
+		port = model.DefaultPort
+	}
+	return &lsServer{
+		port:    port,
+		secCtx:  secCtx,
+		pending: make(map[string]*pendingUpload),
+	}
+}
+
+// setConfig 更新别名、接收目录、接收模式(在线程安全)。
+func (s *lsServer) setConfig(alias, receiveDir string, autoAccept bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.alias = alias
+	s.receiveDir = receiveDir
+	s.autoAccept = autoAccept
+}
+
+// start 启动 HTTPS 监听(阻塞式,调用方应放入 goroutine)。
+func (s *lsServer) start() error {
+	if s.secCtx == nil {
+		return fmt.Errorf("security context is nil")
+	}
+	cert, err := tls.X509KeyPair([]byte(s.secCtx.Certificate), []byte(s.secCtx.PrivateKey))
+	if err != nil {
+		return fmt.Errorf("failed to load server certificate: %w", err)
+	}
+
+	tlsCfg := &tls.Config{
+		Certificates: []tls.Certificate{cert},
+		// 官方 LocalSend 接收端不强制客户端证书(无 ClientAuth):协议靠应用层
+		// sessionId/fileId/token + 发送方主动校验接收方指纹来保证安全。某些客户端
+		// (如官方手机端)在部分请求不提供客户端证书,强制 RequireAnyClientCert
+		// 会导致 TLS 握手失败、对端报 httpConnectionException。故与官方一致不设 ClientAuth。
+		MinVersion: tls.VersionTLS12,
+	}
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/api/localsend/v2/register", s.handleRegister)
+	mux.HandleFunc("/api/localsend/v2/info", s.handleInfo)
+	mux.HandleFunc("/api/localsend/v2/prepare-upload", s.handlePrepareUpload)
+	mux.HandleFunc("/api/localsend/v2/upload", s.handleUpload)
+	mux.HandleFunc("/api/localsend/v2/cancel", s.handleCancel)
+
+	ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
+	if err != nil {
+		return fmt.Errorf("failed to listen on port %d: %w", s.port, err)
+	}
+	s.mu.Lock()
+	s.listener = ln
+	s.httpServer = &http.Server{Handler: mux, TLSConfig: tlsCfg}
+	s.mu.Unlock()
+
+	err = s.httpServer.ServeTLS(ln, "", "")
+	if err != nil && err != http.ErrServerClosed {
+		return err
+	}
+	return nil
+}
+
+// stop 停止服务器。
+func (s *lsServer) stop() {
+	s.mu.Lock()
+	hs := s.httpServer
+	s.httpServer = nil
+	s.mu.Unlock()
+	if hs != nil {
+		hs.Close()
+	}
+}
+
+// infoResponse 构造本机的 InfoDto(含 fingerprint/alias/port/protocol/download)。
+func (s *lsServer) infoResponse() model.InfoDto {
+	s.mu.Lock()
+	alias := s.alias
+	s.mu.Unlock()
+	return model.InfoDto{
+		Alias:       alias,
+		Version:     "2.2",
+		Fingerprint: s.secCtx.CertificateHash,
+		Port:        s.port,
+		Protocol:    model.ProtocolTypeHTTPS,
+		DeviceType:  model.DeviceTypeDesktop,
+		Download:    false,
+	}
+}
+
+// handleRegister 处理设备注册:返回本机信息。
+func (s *lsServer) handleRegister(w http.ResponseWriter, r *http.Request) {
+	info := s.infoResponse()
+	writeJSON(w, http.StatusOK, info)
+}
+
+// handleInfo 处理信息查询:返回本机信息。
+func (s *lsServer) handleInfo(w http.ResponseWriter, r *http.Request) {
+	info := s.infoResponse()
+	writeJSON(w, http.StatusOK, info)
+}
+
+// handlePrepareUpload 处理发送发起:返回 sessionId + 各文件 fileToken。
+// 自动接收:直接接受。手动接收:阻塞等待用户确认(onFileOffer 通知前端,
+// 等待 confirm/reject 信号),超时或拒绝返回 500。
+func (s *lsServer) handlePrepareUpload(w http.ResponseWriter, r *http.Request) {
+	var req model.PrepareUploadRequestDto
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeJSON(w, http.StatusBadRequest, map[string]string{"message": "invalid request"})
+		return
+	}
+
+	sessionID := randomID()
+	s.mu.Lock()
+	auto := s.autoAccept
+	receiveDir := s.receiveDir
+	s.mu.Unlock()
+
+	pu := &pendingUpload{
+		sessionID: sessionID,
+		info:      req.Info,
+		files:     req.Files,
+		wait:      make(chan bool, 1),
+	}
+	s.pendingMu.Lock()
+	s.pending[sessionID] = pu
+	s.pendingMu.Unlock()
+
+	// 手动接收:通知前端等待确认;确认或超时后决定。
+	if !auto {
+		offer := &fileOffer{SessionID: sessionID, Alias: req.Info.Alias, Auto: false}
+		for _, f := range req.Files {
+			offer.Files = append(offer.Files, fileOfferItem{
+				ID: f.ID, FileName: f.FileName, Size: f.Size, FileType: f.FileType,
+			})
+		}
+		if s.onFileOffer != nil {
+			s.onFileOffer(offer)
+		}
+		select {
+		case ok := <-pu.wait:
+			if !ok {
+				s.pendingMu.Lock()
+				delete(s.pending, sessionID)
+				s.pendingMu.Unlock()
+				writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "declined"})
+				return
+			}
+		case <-time.After(60 * time.Second):
+			s.pendingMu.Lock()
+			delete(s.pending, sessionID)
+			s.pendingMu.Unlock()
+			writeJSON(w, http.StatusRequestTimeout, map[string]string{"message": "timeout"})
+			return
+		}
+	}
+
+	// 生成各文件 token
+	files := make(map[string]string, len(req.Files))
+	for id := range req.Files {
+		files[id] = randomID()
+	}
+	if receiveDir == "" {
+		receiveDir = os.TempDir()
+	}
+
+	resp := model.PrepareUploadResponseDto{SessionID: sessionID, Files: files}
+	writeJSON(w, http.StatusOK, resp)
+}
+
+// handleUpload 处理文件上传:将请求体保存到接收目录。
+// 查询参数:sessionId、fileId、token(来自 prepare-upload 响应)。
+func (s *lsServer) handleUpload(w http.ResponseWriter, r *http.Request) {
+	sessionID := r.URL.Query().Get("sessionId")
+	fileID := r.URL.Query().Get("fileId")
+	_ = r.URL.Query().Get("token")
+
+	s.pendingMu.Lock()
+	pu, ok := s.pending[sessionID]
+	s.pendingMu.Unlock()
+	if !ok {
+		writeJSON(w, http.StatusNotFound, map[string]string{"message": "session not found"})
+		return
+	}
+	fd, ok := pu.files[fileID]
+	if !ok {
+		writeJSON(w, http.StatusNotFound, map[string]string{"message": "file not found"})
+		return
+	}
+
+	s.mu.Lock()
+	receiveDir := s.receiveDir
+	s.mu.Unlock()
+	if receiveDir == "" {
+		receiveDir = os.TempDir()
+	}
+	if err := os.MkdirAll(receiveDir, 0o755); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "mkdir failed"})
+		return
+	}
+
+	// 保存文件名:用 fileName 但防路径穿越
+	name := filepath.Base(fd.FileName)
+	if name == "." || name == "" {
+		name = fileID
+	}
+	dst := filepath.Join(receiveDir, name)
+
+	out, err := os.Create(dst)
+	if err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "create failed"})
+		return
+	}
+	defer out.Close()
+	if _, err := io.Copy(out, r.Body); err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "copy failed"})
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+// handleCancel 处理取消传输:移除 pending 会话。
+func (s *lsServer) handleCancel(w http.ResponseWriter, r *http.Request) {
+	sessionID := r.URL.Query().Get("sessionId")
+	s.pendingMu.Lock()
+	if pu, ok := s.pending[sessionID]; ok {
+		select {
+		case pu.wait <- false:
+		default:
+		}
+		delete(s.pending, sessionID)
+	}
+	s.pendingMu.Unlock()
+	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+// confirm 手动接收确认:接受(true)或拒绝(false)某次发送。
+func (s *lsServer) confirm(sessionID string, accept bool) {
+	s.pendingMu.Lock()
+	pu, ok := s.pending[sessionID]
+	s.pendingMu.Unlock()
+	if !ok {
+		return
+	}
+	select {
+	case pu.wait <- accept:
+	default:
+	}
+}
+
+// writeJSON 写 JSON 响应。
+func writeJSON(w http.ResponseWriter, code int, v interface{}) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(code)
+	_ = json.NewEncoder(w).Encode(v)
+}
+
+// randomID 生成一个随机会话/文件 token(16 字节十六进制)。
+func randomID() string {
+	b := make([]byte, 16)
+	if _, err := rand.Read(b); err != nil {
+		return fmt.Sprintf("%d", time.Now().UnixNano())
+	}
+	const hexChars = "0123456789abcdef"
+	var sb strings.Builder
+	for _, c := range b {
+		sb.WriteByte(hexChars[c>>4])
+		sb.WriteByte(hexChars[c&0x0f])
+	}
+	return sb.String()
+}

+ 313 - 0
localsend_service.go

@@ -0,0 +1,313 @@
+// localsend_service.go — LocalSend 集成的 Wails 服务。
+//
+// 管理 LocalSend v2.2 的发现(组播 UDP)与接收(HTTPS 服务器),
+// 提供启动/停止、本机名、接收模式(自动/手动)、接收位置等配置,
+// 并通过 Wails 事件向前端推送设备发现、文件接收请求与接收完成。
+// 纯 Go 实现,与官方 LocalSend 客户端(v2.2)完全互通。
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+
+	"TengTuToolbox/localsend/crypto"
+	"TengTuToolbox/localsend/discovery"
+	"TengTuToolbox/localsend/logging"
+	"TengTuToolbox/localsend/model"
+	"TengTuToolbox/localsend/send"
+)
+
+// LocalSendConfig 是 LocalSend 的运行时配置。
+type LocalSendConfig struct {
+	Enabled     bool   `json:"enabled"`     // 是否已启动服务
+	Alias       string `json:"alias"`       // 本机名称(广播给其他设备)
+	ReceiveDir  string `json:"receiveDir"`  // 接收文件保存目录
+	AutoAccept  bool   `json:"autoAccept"`  // true=自动接收;false=手动确认
+	Port        int    `json:"port"`        // 监听端口
+	Fingerprint string `json:"fingerprint"` // 本机证书指纹
+}
+
+// LocalSendService 是 LocalSend 功能的 Wails 服务。
+type LocalSendService struct {
+	mu      sync.Mutex
+	running bool
+
+	secCtx  *crypto.StoredSecurityContext
+	server  *lsServer
+	discSvc *discovery.Service
+	discCtx context.CancelFunc
+
+	cfg LocalSendConfig
+}
+
+// NewLocalSendService 创建服务实例,并加载持久化配置(本机名/接收位置/接收模式)。
+func NewLocalSendService() *LocalSendService {
+	s := &LocalSendService{
+		cfg: LocalSendConfig{
+			Alias:      "我的设备",
+			AutoAccept: true,
+			Port:       model.DefaultPort,
+		},
+	}
+	s.loadPersistedConfig()
+	return s
+}
+
+// loadPersistedConfig 从主 settings.json 加载 LocalSend 配置(本机名/接收位置/接收模式)。
+func (s *LocalSendService) loadPersistedConfig() {
+	s.cfg = normalizeLocalSend(loadSettings().LocalSend)
+}
+
+// savePersistedConfig 把 LocalSend 配置写入主 settings.json,保证重启后保留。
+func (s *LocalSendService) savePersistedConfig() {
+	settings := loadSettings()
+	settings.LocalSend = normalizeLocalSend(s.cfg)
+	_ = saveSettings(settings)
+}
+
+// Start 启动 LocalSend 服务:生成/加载证书、启动接收服务器与发现服务。
+func (s *LocalSendService) Start() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.running {
+		return nil
+	}
+
+	// 端口兜底:防止旧配置文件残留 port:0 或意外置 0,
+	// 否则组播公告广播 port=0,对端会连 https://<ip>:0 而 Connection refused。
+	if s.cfg.Port <= 0 {
+		s.cfg.Port = model.DefaultPort
+	}
+
+	alias := s.cfg.Alias
+	if alias == "" {
+		alias = "我的设备"
+	}
+	receiveDir := s.cfg.ReceiveDir
+	if receiveDir == "" {
+		home, err := os.UserHomeDir()
+		if err == nil {
+			receiveDir = filepath.Join(home, "Downloads", "LocalSend")
+		} else {
+			receiveDir = os.TempDir()
+		}
+	}
+
+	logger := logging.NewQuiet()
+
+	// 1. 加载或生成自签证书(持久化到用户配置目录)
+	secCtx, err := s.loadOrCreateSecCtx(alias, logger)
+	if err != nil {
+		return err
+	}
+	s.secCtx = secCtx
+
+	// 2. 设置全局客户端证书(供 HTTP 注册 mTLS 使用)
+	clientCertFn, err := secCtx.ClientCertificateFunc()
+	if err != nil {
+		return err
+	}
+	discovery.SetClientCertificate(clientCertFn)
+
+	// 3. 启动接收服务器
+	srv := newLSServer(s.cfg.Port, secCtx)
+	srv.setConfig(alias, receiveDir, s.cfg.AutoAccept)
+	srv.onFileOffer = func(offer *fileOffer) {
+		// 手动接收:通知前端弹窗确认
+		app.Event.Emit("localsend:fileOffer", offer)
+	}
+	s.server = srv
+	go func() {
+		if err := srv.start(); err != nil {
+			app.Event.Emit("localsend:error", map[string]string{"message": err.Error()})
+		}
+	}()
+
+	// 4. 启动发现服务(组播 UDP + HTTP 注册)
+	discCtx, cancel := context.WithCancel(context.Background())
+	s.discCtx = cancel
+
+	multicast := discovery.NewMulticastDiscovery(
+		discovery.DefaultMulticastConfig(),
+		s.buildMulticastDto(alias, secCtx, s.cfg.Port),
+		logger,
+	)
+	disc := discovery.NewService(discovery.DefaultServiceConfig(), multicast, logger)
+	disc.AddDeviceHandler(func(device *model.Device) {
+		s.emitDevices()
+	})
+	s.discSvc = disc
+	if err := disc.Start(discCtx, s.buildMulticastDto(alias, secCtx, s.cfg.Port)); err != nil {
+		cancel()
+		return err
+	}
+
+	s.running = true
+	s.cfg.Enabled = true
+	s.cfg.ReceiveDir = receiveDir
+	s.cfg.Fingerprint = secCtx.CertificateHash
+	return nil
+}
+
+// Stop 停止 LocalSend 服务。
+func (s *LocalSendService) Stop() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !s.running {
+		return
+	}
+	if s.discCtx != nil {
+		s.discCtx()
+	}
+	if s.discSvc != nil {
+		s.discSvc.Stop()
+	}
+	if s.server != nil {
+		s.server.stop()
+	}
+	s.running = false
+	s.cfg.Enabled = false
+}
+
+// SetConfig 更新本机名、接收位置、接收模式。
+// 若服务运行中,即时生效(服务器配置与后续公告同步更新)。
+func (s *LocalSendService) SetConfig(alias, receiveDir string, autoAccept bool) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if alias != "" {
+		s.cfg.Alias = alias
+	}
+	if receiveDir != "" {
+		s.cfg.ReceiveDir = receiveDir
+	}
+	s.cfg.AutoAccept = autoAccept
+	if s.server != nil {
+		s.server.setConfig(s.cfg.Alias, s.cfg.ReceiveDir, s.cfg.AutoAccept)
+	}
+	if s.discSvc != nil {
+		// 更新本机名后重新广播公告,使对端看到新名字
+		_ = s.discSvc
+	}
+	s.savePersistedConfig() // 持久化本机名/接收位置/接收模式,重启后保留
+	return nil
+}
+
+// GetConfig 返回当前配置。
+func (s *LocalSendService) GetConfig() LocalSendConfig {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.cfg
+}
+
+// GetDevices 返回当前发现的设备列表。
+func (s *LocalSendService) GetDevices() []*model.Device {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.discSvc == nil {
+		return nil
+	}
+	return s.discSvc.GetDevices()
+}
+
+// ConfirmTransfer 手动接收确认:accept=true 接受,false 拒绝。
+func (s *LocalSendService) ConfirmTransfer(sessionID string, accept bool) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.server != nil {
+		s.server.confirm(sessionID, accept)
+	}
+}
+
+// SendFiles 把本地文件发送给指定的 LocalSend 设备(按 IP 定位)。
+// filePaths 为本地文件/目录路径列表;deviceIP 为目标设备 IP(如 192.168.1.5)。
+func (s *LocalSendService) SendFiles(filePaths []string, deviceIP string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if len(filePaths) == 0 {
+		return fmt.Errorf("未选择要发送的文件")
+	}
+	if deviceIP == "" {
+		return fmt.Errorf("未指定目标设备")
+	}
+	if s.secCtx == nil {
+		return fmt.Errorf("LocalSend 服务未启动,请先启动服务再发送文件")
+	}
+
+	// 按 IP 找到目标设备
+	var target *model.Device
+	if s.discSvc != nil {
+		for _, d := range s.discSvc.GetDevices() {
+			if d.IP == deviceIP {
+				target = d
+				break
+			}
+		}
+	}
+	if target == nil {
+		return fmt.Errorf("未找到目标设备 %s,请刷新设备列表后重试", deviceIP)
+	}
+
+	cfg := &send.SendConfig{
+		Alias:           s.cfg.Alias,
+		Port:            s.cfg.Port,
+		SecurityContext: s.secCtx,
+		HttpsEnabled:    true,
+		Concurrency:     4,
+	}
+	logger := logging.NewQuiet()
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
+	defer cancel()
+
+	if err := send.SendFiles(ctx, cfg, target, filePaths, logger); err != nil {
+		return fmt.Errorf("发送文件失败: %w", err)
+	}
+	return nil
+}
+
+// loadOrCreateSecCtx 从用户配置目录加载已生成的证书,不存在则生成并保存。
+func (s *LocalSendService) loadOrCreateSecCtx(alias string, logger *logging.Logger) (*crypto.StoredSecurityContext, error) {
+	dir, err := os.UserConfigDir()
+	if err != nil {
+		dir = os.TempDir()
+	}
+	dir = filepath.Join(dir, "TengTuToolbox")
+	_ = os.MkdirAll(dir, 0o755)
+	path := filepath.Join(dir, "localsend_cert.json")
+	ctx, err := crypto.LoadSecurityContext(path, logger)
+	if err == nil && ctx.Certificate != "" {
+		return ctx, nil
+	}
+	ctx, err = crypto.GenerateSecurityContext(alias, logger)
+	if err != nil {
+		return nil, err
+	}
+	_ = crypto.SaveSecurityContext(ctx, path, logger)
+	return ctx, nil
+}
+
+// buildMulticastDto 构造组播公告 DTO。
+func (s *LocalSendService) buildMulticastDto(alias string, secCtx *crypto.StoredSecurityContext, port int) model.MulticastDto {
+	return model.MulticastDto{
+		Alias:       alias,
+		Version:     "2.2",
+		DeviceType:  model.DeviceTypeDesktop,
+		Fingerprint: secCtx.CertificateHash,
+		Port:        port,
+		Protocol:    model.ProtocolTypeHTTPS,
+		Download:    false,
+		Announce:    true,
+	}
+}
+
+// emitDevices 向前端推送设备列表事件。
+func (s *LocalSendService) emitDevices() {
+	devices := s.GetDevices()
+	if devices != nil {
+		app.Event.Emit("localsend:devices", devices)
+	}
+}

+ 1 - 0
main.go

@@ -89,6 +89,7 @@ func main() {
 		Services: []application.Service{
 			application.NewService(&GreetService{}),    // 注册 GreetService,前端可通过 bindings 直接调用其 Greet 方法
 			application.NewService(&SettingsService{}), // 注册 SettingsService,供设置页读写配置、拉取行情
+			application.NewService(&LocalSendService{}), // 注册 LocalSendService,供 LocalSend 页控制发现/接收
 			application.NewService(dockService),        // 注册 dockService,前端可通过 bindings 直接调用其 SetBadge 方法
 		},
 		Assets: application.AssetOptions{

+ 134 - 54
settings.go

@@ -7,6 +7,8 @@
 package main
 
 import (
+	"crypto/rand"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
 	"log"
@@ -27,16 +29,16 @@ const (
 	DefaultNewsRefreshMinutes     = 120                 // 新闻自动刷新间隔(分钟)
 	DefaultNewsCount              = 30                  // 新闻页显示条数
 	DefaultGithubRefreshMinutes   = 120                 // Github 自动刷新间隔(分钟)
-	AppVersion                    = "0.1.2"             // 应用自身版本(与 build/windows/info.json 的 ProductVersion 一致),用于仓库更新提示
+	AppVersion                    = "0.1.3"             // 应用自身版本(与 build/windows/info.json 的 ProductVersion 一致),用于仓库更新提示
 
 	// 鼠标提醒默认配置
-	DefaultMouseAlertFontSize    = 20    // 字号(px)
-	DefaultMouseAlertWidth       = 200   // 气泡窗口宽度(px)
-	DefaultMouseAlertSpeed       = 33    // 超长文字滚动速度(px/s)
-	DefaultMouseAlertColor       = "#ffffff" // 文字颜色(白)
-	DefaultMouseAlertDuration    = 8     // 显示时长(秒)
+	DefaultMouseAlertFontSize     = 20        // 字号(px)
+	DefaultMouseAlertWidth        = 200       // 气泡窗口宽度(px)
+	DefaultMouseAlertSpeed        = 33        // 超长文字滚动速度(px/s)
+	DefaultMouseAlertColor        = "#ffffff" // 文字颜色(白)
+	DefaultMouseAlertDuration     = 8         // 显示时长(秒)
 	DefaultMouseAlertOutlineColor = "#000000" // 文字描边颜色(黑边,与白字形成反差)
-	DefaultMouseAlertOutlineWidth = 2    // 文字描边宽度(px),0 表示关闭
+	DefaultMouseAlertOutlineWidth = 2         // 文字描边宽度(px),0 表示关闭
 )
 
 // LinkItem 常用网址条目
@@ -73,44 +75,44 @@ type TickerConfig struct {
 
 // MouseAlertConfig 系统级鼠标提醒气泡的显示配置
 type MouseAlertConfig struct {
-	FontSize  int    `json:"fontSize"`  // 字号(px)
-	Width     int    `json:"width"`     // 气泡窗口宽度(px)
-	Speed     int    `json:"speed"`     // 超长文字滚动速度(px/s)
-	Color     string `json:"color"`     // 文字颜色
-	Background string `json:"background"` // 气泡背景色(COLORKEY 抠成透明,仅作底色参考)
-	Duration  int    `json:"duration"`  // 显示时长(秒)
+	FontSize     int    `json:"fontSize"`     // 字号(px)
+	Width        int    `json:"width"`        // 气泡窗口宽度(px)
+	Speed        int    `json:"speed"`        // 超长文字滚动速度(px/s)
+	Color        string `json:"color"`        // 文字颜色
+	Background   string `json:"background"`   // 气泡背景色(COLORKEY 抠成透明,仅作底色参考)
+	Duration     int    `json:"duration"`     // 显示时长(秒)
 	OutlineColor string `json:"outlineColor"` // 文字描边颜色(白边/黑边,与文字形成反差)
-	OutlineWidth  int    `json:"outlineWidth"` // 文字描边宽度(px),0 表示关闭描边
+	OutlineWidth int    `json:"outlineWidth"` // 文字描边宽度(px),0 表示关闭描边
 }
 
 // AppSettings 保存全部可自定义配置,序列化为 JSON 存于用户配置目录
 type AppSettings struct {
-	Theme        string       `json:"theme"`        // 界面主题:dark / light
-	Autostart    bool         `json:"autostart"`    // 系统启动时运行
-	AlwaysOnTop  bool         `json:"alwaysOnTop"`  // 窗口置顶
-	Hotkey       string       `json:"hotkey"`       // 呼出(显示)窗口的全局热键
-	TabOrder     []string     `json:"tabOrder"`     // Tab 显示顺序(tabId 列表,未列出的追加到末尾)
-	StockCodes   []string     `json:"stockCodes"`   // 股市页展示的股票代码列表
-	Links        []LinkItem   `json:"links"`        // 常用网址列表
-	NewsKeywords string       `json:"newsKeywords"` // 新闻关注关键词,逗号分隔
-	BiliCookie   string       `json:"biliCookie"`   // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
-	GithubRepos  []RepoItem   `json:"githubRepos"`  // 关注的 GitHub 仓库列表
-	CustomTabs   []CustomTab  `json:"customTabs"`   // 用户自定义 Tab 页面(名字 + 网址)
-	APIAddr        string `json:"apiAddr"`        // 内置 API 服务监听地址
-	APIAutoStart   bool   `json:"apiAutoStart"`   // 应用启动时自动启动内置 API 服务(默认开启)
-	UploadPassword string `json:"uploadPassword"` // 文件上传密码(空=不启用上传/下载接口)
-	UploadDir      string `json:"uploadDir"`      // 文件上传保存目录(空=用户配置目录/uploads)
-	UploadMaxSize  int    `json:"uploadMaxSize"`  // 单个上传文件大小上限(MB),<=0 用默认 512
-	TodoPath     string       `json:"todoPath"`     // 待办(TODO)文件路径
-	RecordPath   string       `json:"recordPath"`   // 记录(Record)文件路径:每行「姓名|名称|值」(保留兼容)
-	RecordDBPath string       `json:"recordDBPath"` // 记录数据库路径:SQLite3 数据库文件(记录 Tab 实际存储)
-	BackupDir    string       `json:"backupDir"`    // 文本文件备份目录(文件类自定义 Tab「备份」按钮使用)
-	StockTicker  TickerConfig `json:"stockTicker"`  // 股票滚动条配置
-	NewsTicker   TickerConfig `json:"newsTicker"`   // 新闻滚动条配置
-	Poem         PoemConfig   `json:"poem"`         // 随机古诗显示配置
-	MouseAlert   MouseAlertConfig `json:"mouseAlert"` // 系统级鼠标提醒气泡配置
-	BrowserPath  string       `json:"browserPath"`  // 指定浏览器 exe 路径(空=系统默认浏览器)
-	LEDColor     string       `json:"ledColor"`     // LED 滚动文字点阵颜色(如 #ff4d4d)
+	Theme          string           `json:"theme"`          // 界面主题:dark / light
+	Autostart      bool             `json:"autostart"`      // 系统启动时运行
+	AlwaysOnTop    bool             `json:"alwaysOnTop"`    // 窗口置顶
+	Hotkey         string           `json:"hotkey"`         // 呼出(显示)窗口的全局热键
+	TabOrder       []string         `json:"tabOrder"`       // Tab 显示顺序(tabId 列表,未列出的追加到末尾)
+	StockCodes     []string         `json:"stockCodes"`     // 股市页展示的股票代码列表
+	Links          []LinkItem       `json:"links"`          // 常用网址列表
+	NewsKeywords   string           `json:"newsKeywords"`   // 新闻关注关键词,逗号分隔
+	BiliCookie     string           `json:"biliCookie"`     // B站登录 cookie(含 SESSDATA),用于抓取「关注的人最新动态」
+	GithubRepos    []RepoItem       `json:"githubRepos"`    // 关注的 GitHub 仓库列表
+	CustomTabs     []CustomTab      `json:"customTabs"`     // 用户自定义 Tab 页面(名字 + 网址)
+	APIAddr        string           `json:"apiAddr"`        // 内置 API 服务监听地址
+	APIAutoStart   bool             `json:"apiAutoStart"`   // 应用启动时自动启动内置 API 服务(默认开启)
+	UploadPassword string           `json:"uploadPassword"` // 文件上传密码(空=不启用上传/下载接口)
+	UploadDir      string           `json:"uploadDir"`      // 文件上传保存目录(空=用户配置目录/uploads)
+	UploadMaxSize  int              `json:"uploadMaxSize"`  // 单个上传文件大小上限(MB),<=0 用默认 512
+	TodoPath       string           `json:"todoPath"`       // 待办(TODO)文件路径
+	RecordPath     string           `json:"recordPath"`     // 记录(Record)文件路径:每行「姓名|名称|值」(保留兼容)
+	RecordDBPath   string           `json:"recordDBPath"`   // 记录数据库路径:SQLite3 数据库文件(记录 Tab 实际存储)
+	BackupDir      string           `json:"backupDir"`      // 文本文件备份目录(文件类自定义 Tab「备份」按钮使用)
+	StockTicker    TickerConfig     `json:"stockTicker"`    // 股票滚动条配置
+	NewsTicker     TickerConfig     `json:"newsTicker"`     // 新闻滚动条配置
+	Poem           PoemConfig       `json:"poem"`           // 随机古诗显示配置
+	MouseAlert     MouseAlertConfig `json:"mouseAlert"`     // 系统级鼠标提醒气泡配置
+	BrowserPath    string           `json:"browserPath"`    // 指定浏览器 exe 路径(空=系统默认浏览器)
+	LEDColor       string           `json:"ledColor"`       // LED 滚动文字点阵颜色(如 #ff4d4d)
 
 	StockUpColor           string `json:"stockUpColor"`           // 股票上涨颜色
 	StockDownColor         string `json:"stockDownColor"`         // 股票下跌颜色
@@ -120,6 +122,9 @@ type AppSettings struct {
 	NewsCount              int    `json:"newsCount"`              // 新闻页显示条数
 	GithubRefreshMinutes   int    `json:"githubRefreshMinutes"`   // Github 自动刷新间隔(分钟)
 	FontDelta              int    `json:"fontDelta"`              // 全局字号偏移(px),+-3 范围内调整
+
+	LocalSend LocalSendConfig `json:"localSend"` // LocalSend 文件互传配置(本机名/接收目录/接收模式等)
+	ToolOrder []string        `json:"toolOrder"` // 工具页卡片显示顺序(工具 id 列表,未列出的追加到末尾)
 }
 
 // 当前已注册的热键(用于保存设置时先注销旧热键)
@@ -136,6 +141,7 @@ func defaultSettings() AppSettings {
 		AlwaysOnTop: false,
 		Hotkey:      DefaultHotkey,
 		TabOrder:    []string{"links", "news", "github", "stock", "todo", "record", "tools", "host", "settings"}, // 默认 Tab 顺序(控制/硬件等固定网页已改为自定义 Tab)
+		ToolOrder:   DefaultToolOrder(), // 工具页卡片默认显示顺序
 		StockCodes:  []string{"sh000001", "sh600519", "sz000001"},
 		Links: []LinkItem{
 			{Name: "煎蛋", URL: "https://jandan.net/"},
@@ -148,19 +154,19 @@ func defaultSettings() AppSettings {
 		},
 		APIAddr:        DefaultAPIAddr,
 		APIAutoStart:   true, // 内置 API 服务随应用启动(默认开启)
-		UploadPassword: "",  // 上传密码:默认空(不启用上传/下载)
-		UploadDir:      "",  // 上传保存目录:默认空(回退到用户配置目录/uploads)
-		UploadMaxSize:  512, // 上传大小上限:默认 512MB
-		TodoPath:       "",  // 待办路径:无默认值,用户须在设置中配置(空值时强制进入设置)
-		RecordPath:  "", // 记录路径:无默认值,用户须在设置中配置(空值时强制进入设置)
-		RecordDBPath: "", // 记录数据库路径:无默认值,用户须在设置中配置(空值时强制进入设置)
-		BackupDir:   "", // 备份目录:无默认值,用户须在设置中配置(空值时强制进入设置)
-		StockTicker: TickerConfig{Enabled: false, Width: 1400, FontSize: 14, Color: "#ff4d4d", Speed: 80, Transparent: true, X: -1, Y: -1},
-		NewsTicker:  TickerConfig{Enabled: false, Width: 1400, FontSize: 14, Color: "#3ddc84", Speed: 60, Transparent: true, X: -1, Y: -1},
-		Poem:        PoemConfig{Enabled: false, Color: DefaultPoemColor, FontSize: DefaultPoemFontSize, TTS: false, Interval: DefaultPoemInterval, Speed: DefaultPoemSpeed},
-		MouseAlert:  MouseAlertConfig{FontSize: DefaultMouseAlertFontSize, Width: DefaultMouseAlertWidth, Speed: DefaultMouseAlertSpeed, Color: DefaultMouseAlertColor, Duration: DefaultMouseAlertDuration, OutlineColor: DefaultMouseAlertOutlineColor, OutlineWidth: DefaultMouseAlertOutlineWidth},
-		BrowserPath: "",      // 指定浏览器路径:默认空(使用系统默认浏览器)
-		LEDColor:     "#ff4d4d", // LED 滚动文字点阵颜色:默认红色
+		UploadPassword: "",   // 上传密码:默认空(不启用上传/下载)
+		UploadDir:      "",   // 上传保存目录:默认空(回退到用户配置目录/uploads)
+		UploadMaxSize:  512,  // 上传大小上限:默认 512MB
+		TodoPath:       "",   // 待办路径:无默认值,用户须在设置中配置(空值时强制进入设置)
+		RecordPath:     "",   // 记录路径:无默认值,用户须在设置中配置(空值时强制进入设置)
+		RecordDBPath:   "",   // 记录数据库路径:无默认值,用户须在设置中配置(空值时强制进入设置)
+		BackupDir:      "",   // 备份目录:无默认值,用户须在设置中配置(空值时强制进入设置)
+		StockTicker:    TickerConfig{Enabled: false, Width: 1400, FontSize: 14, Color: "#ff4d4d", Speed: 80, Transparent: true, X: -1, Y: -1},
+		NewsTicker:     TickerConfig{Enabled: false, Width: 1400, FontSize: 14, Color: "#3ddc84", Speed: 60, Transparent: true, X: -1, Y: -1},
+		Poem:           PoemConfig{Enabled: false, Color: DefaultPoemColor, FontSize: DefaultPoemFontSize, TTS: false, Interval: DefaultPoemInterval, Speed: DefaultPoemSpeed},
+		MouseAlert:     MouseAlertConfig{FontSize: DefaultMouseAlertFontSize, Width: DefaultMouseAlertWidth, Speed: DefaultMouseAlertSpeed, Color: DefaultMouseAlertColor, Duration: DefaultMouseAlertDuration, OutlineColor: DefaultMouseAlertOutlineColor, OutlineWidth: DefaultMouseAlertOutlineWidth},
+		BrowserPath:    "",        // 指定浏览器路径:默认空(使用系统默认浏览器)
+		LEDColor:       "#ff4d4d", // LED 滚动文字点阵颜色:默认红色
 
 		StockUpColor:           DefaultStockUpColor,
 		StockDownColor:         DefaultStockDownColor,
@@ -173,6 +179,26 @@ func defaultSettings() AppSettings {
 	}
 }
 
+// DefaultToolOrder 返回工具页卡片默认显示顺序(与 index.html 中 data-tool 属性一一对应)。
+// 新增工具时需在此追加 id,并在 normalizeSettings 中自动归一化(未列出的追加到末尾)。
+func DefaultToolOrder() []string {
+	return []string{
+		"websvc",     // Web 服务
+		"translate",  // 翻译
+		"qrcode",     // 二维码生成
+		"ntfy",       // 发送 ntfy 消息
+		"chat",       // ntfy 聊天室
+		"poem",       // 古诗词
+		"screenshot", // 屏幕截图
+		"shutdown",   // 延时关机
+		"md5",        // MD5 加密
+		"sha256",     // SHA256 编码
+		"led",        // LED 滚动文字
+		"reminder",   // 定时提醒
+		"localsend",  // LocalSend 互传
+	}
+}
+
 // settingsFilePath 返回配置文件路径:<用户配置目录>/TengTuToolbox/settings.json
 func settingsFilePath() (string, error) {
 	dir, err := os.UserConfigDir() // 例如 C:\Users\<用户名>\AppData\Roaming
@@ -266,6 +292,27 @@ func normalizeSettings(settings AppSettings) AppSettings {
 		}
 	}
 	settings.TabOrder = ordered
+
+	// 工具顺序:只保留已存在的工具 id,去重;未列出的追加到末尾,保证全部工具都能显示
+	validTools := map[string]bool{}
+	for _, id := range DefaultToolOrder() {
+		validTools[id] = true
+	}
+	seenTools := map[string]bool{}
+	toolOrder := make([]string, 0, len(validTools))
+	for _, id := range settings.ToolOrder {
+		if validTools[id] && !seenTools[id] {
+			seenTools[id] = true
+			toolOrder = append(toolOrder, id)
+		}
+	}
+	for _, id := range DefaultToolOrder() {
+		if !seenTools[id] {
+			toolOrder = append(toolOrder, id) // 设置中未列出的工具追加到末尾
+		}
+	}
+	settings.ToolOrder = toolOrder
+
 	if strings.TrimSpace(settings.APIAddr) == "" {
 		settings.APIAddr = DefaultAPIAddr
 	}
@@ -275,7 +322,7 @@ func normalizeSettings(settings AppSettings) AppSettings {
 	settings.StockTicker = normalizeTicker(settings.StockTicker, "#ff4d4d", 80) // 股票条默认红字
 	settings.NewsTicker = normalizeTicker(settings.NewsTicker, "#3ddc84", 60)   // 新闻条默认绿字
 	settings.Poem = normalizePoemConfig(settings.Poem)                          // 古诗显示配置校正
-	settings.MouseAlert = normalizeMouseAlert(settings.MouseAlert)               // 鼠标提醒配置校正
+	settings.MouseAlert = normalizeMouseAlert(settings.MouseAlert)              // 鼠标提醒配置校正
 
 	if !isHexColor(settings.StockUpColor) {
 		settings.StockUpColor = DefaultStockUpColor // 涨色回填默认红
@@ -302,6 +349,9 @@ func normalizeSettings(settings AppSettings) AppSettings {
 		settings.FontDelta = 0 // 全局字号偏移限定 +-3 px
 	}
 
+	// LocalSend 配置校正:本机名未设置时随机生成、端口兜底、接收目录默认
+	settings.LocalSend = normalizeLocalSend(settings.LocalSend)
+
 	if settings.StockCodes == nil {
 		settings.StockCodes = defaultSettings().StockCodes // 未设置时用默认自选股
 	}
@@ -390,6 +440,36 @@ func normalizeMouseAlert(cfg MouseAlertConfig) MouseAlertConfig {
 	return cfg
 }
 
+// normalizeLocalSend 校正 LocalSend 配置:本机名未设置时随机生成、端口兜底、接收目录默认
+func normalizeLocalSend(cfg LocalSendConfig) LocalSendConfig {
+	if strings.TrimSpace(cfg.Alias) == "" {
+		cfg.Alias = defaultLocalSendAlias() // 本机名未设置:随机生成
+	}
+	if cfg.Port <= 0 {
+		cfg.Port = 53317 // 端口兜底:LocalSend 默认端口
+	}
+	if strings.TrimSpace(cfg.ReceiveDir) == "" {
+		cfg.ReceiveDir = defaultLocalSendReceiveDir() // 接收目录:默认到用户配置目录/LocalSend 接收
+	}
+	return cfg
+}
+
+// defaultLocalSendAlias 随机生成本机名,如「工具箱-3F8A」
+func defaultLocalSendAlias() string {
+	b := make([]byte, 2)
+	_, _ = rand.Read(b)
+	return fmt.Sprintf("工具箱-%s", strings.ToUpper(hex.EncodeToString(b)))
+}
+
+// defaultLocalSendReceiveDir 返回默认接收目录:<用户配置目录>/TengTuToolbox/LocalSend接收
+func defaultLocalSendReceiveDir() string {
+	dir, err := os.UserConfigDir()
+	if err != nil {
+		return "LocalSend接收"
+	}
+	return filepath.Join(dir, "TengTuToolbox", "LocalSend接收")
+}
+
 // isHexColor 判断是否为 #RGB / #RRGGBB 形式的颜色值
 func isHexColor(value string) bool {
 	value = strings.TrimSpace(value)

+ 20 - 0
settingsservice.go

@@ -269,6 +269,26 @@ func (s *SettingsService) PickBrowserPath() (string, error) {
 		PromptForSingleSelection()
 }
 
+// PickSendFiles 弹出多选文件对话框,用于 LocalSend 发送文件。
+// 返回选中的文件路径列表(取消返回空列表)。
+func (s *SettingsService) PickSendFiles() ([]string, error) {
+	if app == nil {
+		return nil, fmt.Errorf("应用未初始化")
+	}
+	paths, err := app.Dialog.OpenFile().
+		CanChooseDirectories(false).
+		CanChooseFiles(true).
+		SetTitle("选择要发送的文件(可多选)").
+		PromptForMultipleSelection()
+	if err != nil {
+		return nil, err
+	}
+	if paths == nil {
+		return []string{}, nil
+	}
+	return paths, nil
+}
+
 // OpenInBrowser 用配置的浏览器打开网址。
 // 若设置页指定了浏览器路径(BrowserPath)且文件存在,则用该浏览器打开;
 // 否则回退到系统默认浏览器(通过 Wails OpenURL 能力)。