Compare commits

15 Commits

Author SHA1 Message Date
a3989aeedd fix: discovery.peers resource competition problem 2026-02-10 23:37:00 +08:00
ea40aa76d0 feat: peercard accept drag event 2026-02-10 22:44:18 +08:00
7c65daeb89 fix save history 2026-02-07 21:39:44 +08:00
e76bcd709c if the file being received already exists locally, it will be renamed 2026-02-07 19:39:14 +08:00
e76ada9b4b add systray 2026-02-07 17:57:48 +08:00
eb23ef9d5d fix unable to transfer file 2026-02-07 14:40:58 +08:00
ed2629cb08 upgrade wails runtime 2026-02-07 14:22:52 +08:00
20a25e8c49 refine i18n, fill in miss parts
fix resetTrust cant recover send button in UI
2026-02-07 14:11:57 +08:00
4b5d2b656b feat: i18n 2026-02-07 04:18:36 +08:00
d1dd75f7ab README 2026-02-07 03:40:45 +08:00
f3adb56bd0 feat: trust peer 2026-02-07 03:17:37 +08:00
d8ffc5eea5 goreleaser 2026-02-06 14:40:57 +08:00
2a0f2901b4 add: goreleaser 2026-02-06 14:31:36 +08:00
6ec897468f add: icon 2026-02-06 04:18:28 +08:00
a2ad297dbc README 2026-02-05 20:55:54 +08:00
64 changed files with 1866 additions and 617 deletions

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@ bin
frontend/dist frontend/dist
frontend/node_modules frontend/node_modules
build/linux/appimage/build build/linux/appimage/build
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
dist

103
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,103 @@
version: 2
project_name: mesh-drop
before:
hooks:
- go mod tidy
- wails3 generate bindings -ts
- sh -c 'cd frontend && npm run build'
- wails3 generate .desktop -name "{{.ProjectName}}" -exec "{{.ProjectName}}" -icon "{{.ProjectName}}.png" -outputfile "goreleaser/{{.ProjectName}}.desktop" -categories "Network;FileTransfer;" -keywords "mesh,transfer,file,network,drop"
builds:
- id: linux-amd64
binary: mesh-drop
goos:
- linux
goarch:
- amd64
env:
- CGO_ENABLED=1
flags:
- -tags
- production
- -trimpath
ldflags:
- -s -w -X mesh-drop/internal/config.Version={{ .Version }}
- id: windows-amd64
binary: mesh-drop
goos:
- windows
goarch:
- amd64
env:
- CGO_ENABLED=1
flags:
- -tags
- production
- -trimpath
ldflags:
- -s -w -H windowsgui -X mesh-drop/internal/config.Version={{ .Version }}
hooks:
pre:
- "wails3 generate icons -input goreleaser/icon.png -windowsfilename goreleaser/icon.ico"
- "wails3 generate syso -arch amd64 -icon goreleaser/icon.ico -manifest goreleaser/wails.exe.manifest -info goreleaser/info.json -out wails_windows_amd64.syso"
post: "rm -f wails_windows_amd64.syso"
archives:
- formats: ["tar.gz"]
format_overrides:
- goos: windows
formats: ["zip"]
nfpms:
- id: default
package_name: mesh-drop
vendor: "nite"
homepage: "https://www.nite07.com"
maintainer: "Nite <nite@nite07.com>"
description: "A mesh-drop application"
license: "MIT"
formats:
- deb
- rpm
- archlinux
contents:
- src: "goreleaser/icon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/mesh-drop.png"
- src: "build/linux/mesh-drop.desktop"
dst: "/usr/share/applications/mesh-drop.desktop"
dependencies:
- libgtk-3-0
- libwebkit2gtk-4.1-0
overrides:
rpm:
dependencies:
- gtk3
- webkit2gtk4.1
archlinux:
dependencies:
- gtk3
- webkit2gtk-4.1
checksum:
name_template: "checksums.txt"
snapshot:
version_template: "{{ .Tag }}-{{ .ShortCommit }}"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
upx:
- enabled: true
compress: best
lzma: true

3
.vscode/launch.json vendored
View File

@@ -9,7 +9,8 @@
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/main.go", "program": "${workspaceFolder}",
"buildFlags": "-tags=gtk4",
"preLaunchTask": "build frontend" "preLaunchTask": "build frontend"
} }
] ]

View File

@@ -1,68 +1,91 @@
# Mesh Drop # Mesh Drop
简易、快速的局域网文件传输工具,基于 Wails 和 Vue 构建。 English | [中文](./README.zh.md)
## 功能特性 Simple, fast LAN file transfer tool, built with Wails and Vue.
- **文件传输**:支持多文件发送,轻松共享。 ## Features
- **文件夹传输**:支持发送整个文件夹结构。
- **文本传输**:快速同步设备间的文本内容。
- **加密传输**:确保数据在传输过程中的安全性。
## 截图 - **File Transfer**: Support multi-file sending, easily share.
- **Folder Transfer**: Support sending entire folder structures.
- **Text Transfer**: Quickly sync text content between devices.
- **Encrypted Transmission**: Ensure data security during transmission.
- **Secure Identity**: Ed25519-based signature verification to prevent spoofing.
## Security Mechanisms
Mesh Drop uses a multi-layered security design to protect users from potential malicious attacks:
1. **Identity**
- Each device generates a unique pair of Ed25519 keys on first startup.
- All presence broadcasts are signed with the private key.
- The receiver verifies the signature with the public key to ensure the identity has not been tampered with.
2. **Trust**
- Uses TOFU (Trust On First Use) strategy.
- Users can choose to "Trust" a Peer. Once trusted, that Peer's public key is pinned.
- Subsequent packets from that Peer ID must be verified by the saved public key, otherwise they will be marked as **Mismatch**.
- **Anti-spoofing**: If someone tries to spoof a trusted Peer ID, the UI will display a clear "Mismatch" security warning and prevent metadata from being overwritten.
3. **Encryption**
- File transfer service uses HTTPS protocol.
- Automatically generates self-signed certificates for communication encryption to prevent eavesdropping.
## Screenshots
| ![Mesh Drop](./screenshot/1.png) | ![Mesh Drop](./screenshot/2.png) | | ![Mesh Drop](./screenshot/1.png) | ![Mesh Drop](./screenshot/2.png) |
| -------------------------------- | -------------------------------- | | -------------------------------- | -------------------------------- |
## 待办事项 ## Todo
- [x] 剪辑板传输 - [x] Clipboard transfer
- [x] 文件夹传输 - [x] Folder transfer
- [x] 取消传输 - [x] Cancel transfer
- [x] 多文件发送 - [x] Multi-file sending
- [x] 加密传输 - [x] Encrypted transmission
- [x] 设置页面 - [x] Settings page
- [x] 单例模式 - [x] Single instance mode
- [x] 系统通知 - [x] System notifications
- [x] 清理历史 - [x] Clear history
- [x] 自动接收 - [x] Auto accept
- [ ] 应用图标 - [x] App icon
- [ ] 系统托盘(最小化到托盘) - [x] Trust Peer
- [ ] 收藏Peer - [x] Multi-language support
- [ ] System tray (minimize to tray) badges https://github.com/wailsapp/wails/issues/4494
## 技术栈 ## Tech Stack
本项目使用现代化的技术栈构建: This project is built using a modern tech stack:
- **后端**: [Go](https://go.dev/) + [Wails v3](https://v3.wails.io/) - **Backend**: [Go](https://go.dev/) + [Wails v3](https://v3.wails.io/)
- **前端**: [Vue 3](https://vuejs.org/) + [TypeScript](https://www.typescriptlang.org/) - **Frontend**: [Vue 3](https://vuejs.org/) + [TypeScript](https://www.typescriptlang.org/)
- **UI 框架**: [Vuetify](https://vuetifyjs.com/) - **UI Framework**: [Vuetify](https://vuetifyjs.com/)
## 开发 ## Development
### 前置条件 ### Prerequisites
在开始之前,请确保您的开发环境已安装以下工具: Before starting, ensure your development environment has the following tools installed:
1. **Go** (版本 >= 1.25) 1. **Go** (version >= 1.25)
2. **Node.js** 2. **Node.js**
3. **Wails CLI** 3. **Wails CLI**
4. **UPX** 4. **UPX**
### 安装依赖 ### Install Dependencies
```bash ```bash
# 进入项目目录 # Enter project directory
cd mesh-drop cd mesh-drop
# 安装前端依赖 (通常 Wails 会自动处理,但手动安装可确保环境清晰) # Install frontend dependencies (Wails usually handles this automatically, but manual installation ensures a clean environment)
cd frontend cd frontend
npm install npm install
cd .. cd ..
``` ```
### 运行开发环境 ### Run Development Environment
```bash ```bash
wails dev wails3 dev
``` ```

91
README.zh.md Normal file
View File

@@ -0,0 +1,91 @@
# Mesh Drop
[English](./README.md) | 中文
简易、快速的局域网文件传输工具,基于 Wails 和 Vue 构建。
## 功能特性
- **文件传输**:支持多文件发送,轻松共享。
- **文件夹传输**:支持发送整个文件夹结构。
- **文本传输**:快速同步设备间的文本内容。
- **加密传输**:确保数据在传输过程中的安全性。
- **安全身份**:基于 Ed25519 的签名验证,防止伪造。
## 安全机制
Mesh Drop 采用多层安全设计来保护用户免受潜在的恶意攻击:
1. **身份验证 (Identity)**
- 每个设备在首次启动时生成一对唯一的 Ed25519 密钥。
- 所有广播包Presence Broadcast都使用私钥签名。
- 接收端通过公钥验证签名,确保身份未被篡改。
2. **信任机制 (Trust)**
- 采用 TOFU (Trust On First Use) 策略。
- 用户可以选择“信任”某个 Peer一旦信任该 Peer 的公钥将被固定Pinning
- 之后收到该 Peer ID 的所有数据包,必须通过已保存公钥的验证,否则会被标记为 **Mismatch**
- **防欺骗**:如果有人试图伪造已信任 Peer 的 IDUI 会显示明显的“Mismatch”安全警告并阻止元数据被覆盖。
3. **传输加密 (Encryption)**
- 文件传输服务使用 HTTPS 协议。
- 自动生成自签名证书进行通信加密,防止传输内容被窃听。
## 截图
| ![Mesh Drop](./screenshot/1.png) | ![Mesh Drop](./screenshot/2.png) |
| -------------------------------- | -------------------------------- |
## 待办事项
- [x] 剪辑板传输
- [x] 文件夹传输
- [x] 取消传输
- [x] 多文件发送
- [x] 加密传输
- [x] 设置页面
- [x] 单例模式
- [x] 系统通知
- [x] 清理历史
- [x] 自动接收
- [x] 应用图标
- [x] 信任Peer
- [x] 多语言
- [ ] 系统托盘(最小化到托盘)徽章 https://github.com/wailsapp/wails/issues/4494
## 技术栈
本项目使用现代化的技术栈构建:
- **后端**: [Go](https://go.dev/) + [Wails v3](https://v3.wails.io/)
- **前端**: [Vue 3](https://vuejs.org/) + [TypeScript](https://www.typescriptlang.org/)
- **UI 框架**: [Vuetify](https://vuetifyjs.com/)
## 开发
### 前置条件
在开始之前,请确保您的开发环境已安装以下工具:
1. **Go** (版本 >= 1.25)
2. **Node.js**
3. **Wails CLI**
4. **UPX**
### 安装依赖
```bash
# 进入项目目录
cd mesh-drop
# 安装前端依赖 (通常 Wails 会自动处理,但手动安装可确保环境清晰)
cd frontend
npm install
cd ..
```
### 运行开发环境
```bash
wails3 dev
```

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 583 533" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-246,-251)">
<g id="Ebene1">
<path d="M246,251L265,784L401,784L506,450L507,450L505,784L641,784L829,251L682,251L596,567L595,567L596,251L478,251L378,568L391,251L246,251Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 698 B

View File

@@ -1,51 +0,0 @@
{
"fill" : {
"automatic-gradient" : "extended-gray:1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"fill-specializations" : [
{
"appearance" : "dark",
"value" : {
"solid" : "srgb:0.92143,0.92145,0.92144,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "srgb:0.83742,0.83744,0.83743,1.00000"
}
}
],
"image-name" : "wails_icon_vector.svg",
"name" : "wails_icon_vector",
"position" : {
"scale" : 1.25,
"translation-in-points" : [
36.890625,
4.96875
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

View File

@@ -51,7 +51,9 @@ tasks:
cmds: cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}} - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars: vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}' VERSION:
sh: git describe --tags --always
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -X mesh-drop/internal/config.Version={{.VERSION}}"{{end}}'
DEFAULT_OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}" DEFAULT_OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}"
OUTPUT: "{{ .OUTPUT | default .DEFAULT_OUTPUT }}" OUTPUT: "{{ .OUTPUT | default .DEFAULT_OUTPUT }}"
env: env:
@@ -59,16 +61,6 @@ tasks:
CGO_ENABLED: 1 CGO_ENABLED: 1
GOARCH: "{{.ARCH | default ARCH}}" GOARCH: "{{.ARCH | default ARCH}}"
build:prod:
summary: Builds the application natively on Linux
deps:
- task: build
cmds:
- upx --best --lzma {{.OUTPUT}}
vars:
DEFAULT_OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}"
OUTPUT: "{{ .OUTPUT | default .DEFAULT_OUTPUT }}"
build:docker: build:docker:
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available) summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
internal: true internal: true
@@ -181,13 +173,14 @@ tasks:
dir: build dir: build
cmds: cmds:
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage - mkdir -p {{.ROOT_DIR}}/build/linux/appimage
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}" - wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}" -keywords "{{.KEYWORDS}}"
vars: vars:
APP_NAME: "{{.APP_NAME}}" APP_NAME: "{{.APP_NAME}}"
EXEC: "{{.APP_NAME}}" EXEC: "{{.APP_NAME}}"
ICON: "{{.APP_NAME}}" ICON: "{{.APP_NAME}}"
CATEGORIES: "Development;" CATEGORIES: '{{.CATEGORIES | default .ENV.CATEGORIES | default "GTK;Utility"}}'
OUTPUTFILE: "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" OUTPUTFILE: "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop"
KEYWORDS: '{{.KEYWORDS | default .ENV.KEYWORDS | default "utility"}}'
run: run:
cmds: cmds:

BIN
build/linux/appimage/mesh-drop Executable file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,13 +0,0 @@
[Desktop Entry]
Version=1.0
Name=My Product
Comment=A mesh-drop application
# The Exec line includes %u to pass the URL to the application
Exec=/usr/local/bin/mesh-drop %u
Terminal=false
Type=Application
Icon=mesh-drop
Categories=Utility;
StartupWMClass=mesh-drop

View File

@@ -3,8 +3,8 @@ Type=Application
Name=mesh-drop Name=mesh-drop
Exec=mesh-drop Exec=mesh-drop
Icon=mesh-drop Icon=mesh-drop
Categories=Development; Categories=GTK;Utility
Terminal=false Terminal=false
Keywords=wails Keywords=utility
Version=1.0 Version=1.0
StartupNotify=false StartupNotify=false

View File

@@ -6,13 +6,13 @@
name: "mesh-drop" name: "mesh-drop"
arch: ${GOARCH} arch: ${GOARCH}
platform: "linux" platform: "linux"
version: "0.1.0" version: "0.0.3"
section: "default" section: "default"
priority: "extra" priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "A mesh-drop application" description: "A mesh-drop application"
vendor: "My Company" vendor: "nite"
homepage: "https://wails.io" homepage: "https://www.nite07.com"
license: "MIT" license: "MIT"
release: "1" release: "1"
@@ -36,8 +36,8 @@ overrides:
depends: depends:
- gtk3 - gtk3
- webkit2gtk4.1 - webkit2gtk4.1
# Arch Linux packages (WebKit 4.1) # Arch Linux packages (WebKit 4.1)
archlinux: archlinux:
depends: depends:
- gtk3 - gtk3

View File

@@ -46,20 +46,17 @@ tasks:
platforms: [windows] platforms: [windows]
- cmd: rm -f *.syso - cmd: rm -f *.syso
platforms: [linux, darwin] platforms: [linux, darwin]
- cmd: upx -t "{{.BIN_DIR}}/{{.APP_NAME}}.exe" || upx "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
platforms: [linux]
vars: vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}' VERSION:
sh: git describe --tags --always
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui -X mesh-drop/internal/config.Version={{.VERSION}}"{{end}}'
env: env:
GOOS: windows GOOS: windows
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}' CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
GOARCH: "{{.ARCH | default ARCH}}" GOARCH: "{{.ARCH | default ARCH}}"
build:prod:
summary: Builds the application for Windows
deps:
- task: build
cmds:
- upx --best --lzma "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
build:docker: build:docker:
summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows) summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
internal: true internal: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -74,7 +74,7 @@ export class NotificationOptions {
"subtitle"?: string; "subtitle"?: string;
"body"?: string; "body"?: string;
"categoryId"?: string; "categoryId"?: string;
"data"?: { [_: string]: any }; "data"?: { [_ in string]?: any };
/** Creates a new NotificationOptions instance. */ /** Creates a new NotificationOptions instance. */
constructor($$source: Partial<NotificationOptions> = {}) { constructor($$source: Partial<NotificationOptions> = {}) {

View File

@@ -2,5 +2,6 @@
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export { export {
File,
FilesDroppedEvent FilesDroppedEvent
} from "./models.js"; } from "./models.js";

View File

@@ -9,10 +9,18 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import * as $models from "./models.js"; import * as $models from "./models.js";
export function AddTrust(peerID: string, publicKey: string): $CancellablePromise<void> {
return $Call.ByID(2986105628, peerID, publicKey);
}
export function GetAutoAccept(): $CancellablePromise<boolean> { export function GetAutoAccept(): $CancellablePromise<boolean> {
return $Call.ByID(2605668438); return $Call.ByID(2605668438);
} }
export function GetCloseToSystray(): $CancellablePromise<boolean> {
return $Call.ByID(3671455511);
}
export function GetHostName(): $CancellablePromise<string> { export function GetHostName(): $CancellablePromise<string> {
return $Call.ByID(972342140); return $Call.ByID(972342140);
} }
@@ -21,6 +29,18 @@ export function GetID(): $CancellablePromise<string> {
return $Call.ByID(4240411568); return $Call.ByID(4240411568);
} }
export function GetLanguage(): $CancellablePromise<$models.Language> {
return $Call.ByID(480133131);
}
export function GetPrivateKey(): $CancellablePromise<string> {
return $Call.ByID(353744619);
}
export function GetPublicKey(): $CancellablePromise<string> {
return $Call.ByID(2506498735);
}
export function GetSaveHistory(): $CancellablePromise<boolean> { export function GetSaveHistory(): $CancellablePromise<boolean> {
return $Call.ByID(2178923392); return $Call.ByID(2178923392);
} }
@@ -29,16 +49,30 @@ export function GetSavePath(): $CancellablePromise<string> {
return $Call.ByID(4081533263); return $Call.ByID(4081533263);
} }
export function GetTrusted(): $CancellablePromise<{ [_ in string]?: string }> {
return $Call.ByID(800326956).then(($result: any) => {
return $$createType0($result);
});
}
export function GetVersion(): $CancellablePromise<string> { export function GetVersion(): $CancellablePromise<string> {
return $Call.ByID(3578438023); return $Call.ByID(3578438023);
} }
export function GetWindowState(): $CancellablePromise<$models.WindowState> { export function GetWindowState(): $CancellablePromise<$models.WindowState> {
return $Call.ByID(341414414).then(($result: any) => { return $Call.ByID(341414414).then(($result: any) => {
return $$createType0($result); return $$createType1($result);
}); });
} }
export function IsTrusted(peerID: string): $CancellablePromise<boolean> {
return $Call.ByID(1255607538, peerID);
}
export function RemoveTrust(peerID: string): $CancellablePromise<void> {
return $Call.ByID(732981195, peerID);
}
/** /**
* Save 保存配置到磁盘 * Save 保存配置到磁盘
*/ */
@@ -50,10 +84,18 @@ export function SetAutoAccept(autoAccept: boolean): $CancellablePromise<void> {
return $Call.ByID(3371961138, autoAccept); return $Call.ByID(3371961138, autoAccept);
} }
export function SetCloseToSystray(closeToSystray: boolean): $CancellablePromise<void> {
return $Call.ByID(2558495467, closeToSystray);
}
export function SetHostName(hostName: string): $CancellablePromise<void> { export function SetHostName(hostName: string): $CancellablePromise<void> {
return $Call.ByID(1580131496, hostName); return $Call.ByID(1580131496, hostName);
} }
export function SetLanguage(language: $models.Language): $CancellablePromise<void> {
return $Call.ByID(933959199, language);
}
export function SetSaveHistory(saveHistory: boolean): $CancellablePromise<void> { export function SetSaveHistory(saveHistory: boolean): $CancellablePromise<void> {
return $Call.ByID(3779587628, saveHistory); return $Call.ByID(3779587628, saveHistory);
} }
@@ -70,4 +112,5 @@ export function SetWindowState(state: $models.WindowState): $CancellablePromise<
} }
// Private type creation functions // Private type creation functions
const $$createType0 = $models.WindowState.createFrom; const $$createType0 = $Create.Map($Create.Any, $Create.Any);
const $$createType1 = $models.WindowState.createFrom;

View File

@@ -7,5 +7,6 @@ export {
}; };
export { export {
Language,
WindowState WindowState
} from "./models.js"; } from "./models.js";

View File

@@ -5,32 +5,30 @@
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime"; import { Create as $Create } from "@wailsio/runtime";
export enum Language {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
LanguageEnglish = "en",
LanguageChinese = "zh-Hans",
};
/** /**
* WindowState 定义窗口状态 * WindowState 定义窗口状态
*/ */
export class WindowState { export class WindowState {
"Width": number; "width": number;
"Height": number; "height": number;
"X": number;
"Y": number;
"Maximised": boolean;
/** Creates a new WindowState instance. */ /** Creates a new WindowState instance. */
constructor($$source: Partial<WindowState> = {}) { constructor($$source: Partial<WindowState> = {}) {
if (!("Width" in $$source)) { if (!("width" in $$source)) {
this["Width"] = 0; this["width"] = 0;
} }
if (!("Height" in $$source)) { if (!("height" in $$source)) {
this["Height"] = 0; this["height"] = 0;
}
if (!("X" in $$source)) {
this["X"] = 0;
}
if (!("Y" in $$source)) {
this["Y"] = 0;
}
if (!("Maximised" in $$source)) {
this["Maximised"] = false;
} }
Object.assign(this, $$source); Object.assign(this, $$source);

View File

@@ -40,13 +40,20 @@ export class Peer {
* Routes 记录了设备的 IP 地址和状态。 * Routes 记录了设备的 IP 地址和状态。
* Key: ip, Value: *RouteState * Key: ip, Value: *RouteState
*/ */
"routes": { [_: string]: RouteState | null }; "routes": { [_ in string]?: RouteState | null };
/** /**
* Port 是文件传输服务的监听端口。 * Port 是文件传输服务的监听端口。
*/ */
"port": number; "port": number;
"os": OS; "os": OS;
"pk": string;
/**
* TrustMismatch 指示该节点的公钥与本地信任列表中的公钥不匹配
* 如果为 true说明可能存在 ID 欺骗或密钥轮换
*/
"trust_mismatch": boolean;
/** Creates a new Peer instance. */ /** Creates a new Peer instance. */
constructor($$source: Partial<Peer> = {}) { constructor($$source: Partial<Peer> = {}) {
@@ -65,6 +72,12 @@ export class Peer {
if (!("os" in $$source)) { if (!("os" in $$source)) {
this["os"] = OS.$zero; this["os"] = OS.$zero;
} }
if (!("pk" in $$source)) {
this["pk"] = "";
}
if (!("trust_mismatch" in $$source)) {
this["trust_mismatch"] = false;
}
Object.assign(this, $$source); Object.assign(this, $$source);
} }

View File

@@ -17,22 +17,29 @@ export function GetLocalIPInSameSubnet(receiverIP: string): $CancellablePromise<
return $Call.ByID(3089425954, receiverIP); return $Call.ByID(3089425954, receiverIP);
} }
export function GetLocalIPs(): $CancellablePromise<[string[], boolean]> { export function GetPeerByID(id: string): $CancellablePromise<[$models.Peer | null, boolean]> {
return $Call.ByID(2403939179).then(($result: any) => { return $Call.ByID(1962377788, id).then(($result: any) => {
$result[0] = $$createType0($result[0]); $result[0] = $$createType1($result[0]);
return $result; return $result;
}); });
} }
export function GetPeerByIP(ip: string): $CancellablePromise<$models.Peer | null> { export function GetPeerByIP(ip: string): $CancellablePromise<[$models.Peer | null, boolean]> {
return $Call.ByID(1626825408, ip).then(($result: any) => { return $Call.ByID(1626825408, ip).then(($result: any) => {
return $$createType2($result); $result[0] = $$createType1($result[0]);
return $result;
}); });
} }
export function GetPeers(): $CancellablePromise<$models.Peer[]> { export function GetPeers(): $CancellablePromise<$models.Peer[]> {
return $Call.ByID(3041084029).then(($result: any) => { return $Call.ByID(3041084029).then(($result: any) => {
return $$createType3($result); return $$createType2($result);
});
}
export function GetSelf(): $CancellablePromise<$models.Peer> {
return $Call.ByID(3599633538).then(($result: any) => {
return $$createType0($result);
}); });
} }
@@ -41,7 +48,6 @@ export function Start(): $CancellablePromise<void> {
} }
// Private type creation functions // Private type creation functions
const $$createType0 = $Create.Array($Create.Any); const $$createType0 = $models.Peer.createFrom;
const $$createType1 = $models.Peer.createFrom; const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Nullable($$createType1); const $$createType2 = $Create.Array($$createType0);
const $$createType3 = $Create.Array($$createType1);

View File

@@ -9,7 +9,6 @@ export {
export { export {
ContentType, ContentType,
Progress, Progress,
Sender,
Transfer, Transfer,
TransferStatus, TransferStatus,
TransferType TransferType

View File

@@ -5,6 +5,10 @@
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime"; import { Create as $Create } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as discovery$0 from "../discovery/models.js";
export enum ContentType { export enum ContentType {
/** /**
* The Go zero value for the underlying type of the enum. * The Go zero value for the underlying type of the enum.
@@ -59,46 +63,6 @@ export class Progress {
} }
} }
export class Sender {
/**
* 发送者 ID
*/
"id": string;
/**
* 发送者名称
*/
"name": string;
/**
* 发送者 IP
*/
"ip": string;
/** Creates a new Sender instance. */
constructor($$source: Partial<Sender> = {}) {
if (!("id" in $$source)) {
this["id"] = "";
}
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("ip" in $$source)) {
this["ip"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new Sender instance from a string or object.
*/
static createFrom($$source: any = {}): Sender {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Sender($$parsedSource as Partial<Sender>);
}
}
/** /**
* Transfer * Transfer
*/ */
@@ -116,9 +80,10 @@ export class Transfer {
/** /**
* 发送者 * 发送者
*/ */
"sender": Sender; "sender": discovery$0.Peer;
/** /**
* FileName 如果 ContentType 为 file文件名如果 ContentType 为 folder文件夹名如果 ContentType 为 text
* 文件名 * 文件名
*/ */
"file_name": string; "file_name": string;
@@ -177,7 +142,7 @@ export class Transfer {
this["create_time"] = 0; this["create_time"] = 0;
} }
if (!("sender" in $$source)) { if (!("sender" in $$source)) {
this["sender"] = (new Sender()); this["sender"] = (new discovery$0.Peer());
} }
if (!("file_name" in $$source)) { if (!("file_name" in $$source)) {
this["file_name"] = ""; this["file_name"] = "";
@@ -256,5 +221,5 @@ export enum TransferType {
}; };
// Private type creation functions // Private type creation functions
const $$createType0 = Sender.createFrom; const $$createType0 = discovery$0.Peer.createFrom;
const $$createType1 = Progress.createFrom; const $$createType1 = Progress.createFrom;

View File

@@ -8,6 +8,9 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import * as discovery$0 from "../discovery/models.js"; import * as discovery$0 from "../discovery/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as sync$0 from "../../../sync/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
@@ -45,6 +48,12 @@ export function GetTransferList(): $CancellablePromise<($models.Transfer | null)
}); });
} }
export function GetTransferSyncMap(): $CancellablePromise<sync$0.Map | null> {
return $Call.ByID(2986557111).then(($result: any) => {
return $$createType4($result);
});
}
export function LoadHistory(): $CancellablePromise<void> { export function LoadHistory(): $CancellablePromise<void> {
return $Call.ByID(2987999795); return $Call.ByID(2987999795);
} }
@@ -97,3 +106,5 @@ export function StoreTransfersToList(transfers: ($models.Transfer | null)[]): $C
const $$createType0 = $models.Transfer.createFrom; const $$createType0 = $models.Transfer.createFrom;
const $$createType1 = $Create.Nullable($$createType0); const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Array($$createType1); const $$createType2 = $Create.Array($$createType1);
const $$createType3 = sync$0.Map.createFrom;
const $$createType4 = $Create.Nullable($$createType3);

View File

@@ -5,8 +5,33 @@
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime"; import { Create as $Create } from "@wailsio/runtime";
export class File {
"name": string;
"path": string;
/** Creates a new File instance. */
constructor($$source: Partial<File> = {}) {
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("path" in $$source)) {
this["path"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new File instance from a string or object.
*/
static createFrom($$source: any = {}): File {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new File($$parsedSource as Partial<File>);
}
}
export class FilesDroppedEvent { export class FilesDroppedEvent {
"files": string[]; "files": File[];
"target": string; "target": string;
/** Creates a new FilesDroppedEvent instance. */ /** Creates a new FilesDroppedEvent instance. */
@@ -25,7 +50,7 @@ export class FilesDroppedEvent {
* Creates a new FilesDroppedEvent instance from a string or object. * Creates a new FilesDroppedEvent instance from a string or object.
*/ */
static createFrom($$source: any = {}): FilesDroppedEvent { static createFrom($$source: any = {}): FilesDroppedEvent {
const $$createField0_0 = $$createType0; const $$createField0_0 = $$createType1;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("files" in $$parsedSource) { if ("files" in $$parsedSource) {
$$parsedSource["files"] = $$createField0_0($$parsedSource["files"]); $$parsedSource["files"] = $$createField0_0($$parsedSource["files"]);
@@ -35,4 +60,5 @@ export class FilesDroppedEvent {
} }
// Private type creation functions // Private type creation functions
const $$createType0 = $Create.Array($Create.Any); const $$createType0 = File.createFrom;
const $$createType1 = $Create.Array($$createType0);

View File

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

View File

@@ -0,0 +1,52 @@
// 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";
/**
* Map is like a Go map[any]any but is safe for concurrent use
* by multiple goroutines without additional locking or coordination.
* Loads, stores, and deletes run in amortized constant time.
*
* The Map type is specialized. Most code should use a plain Go map instead,
* with separate locking or coordination, for better type safety and to make it
* easier to maintain other invariants along with the map content.
*
* The Map type is optimized for two common use cases: (1) when the entry for a given
* key is only ever written once but read many times, as in caches that only grow,
* or (2) when multiple goroutines read, write, and overwrite entries for disjoint
* sets of keys. In these two cases, use of a Map may significantly reduce lock
* contention compared to a Go map paired with a separate [Mutex] or [RWMutex].
*
* The zero Map is empty and ready for use. A Map must not be copied after first use.
*
* In the terminology of [the Go memory model], Map arranges that a write operation
* “synchronizes before” any read operation that observes the effect of the write, where
* read and write operations are defined as follows.
* [Map.Load], [Map.LoadAndDelete], [Map.LoadOrStore], [Map.Swap], [Map.CompareAndSwap],
* and [Map.CompareAndDelete] are read operations;
* [Map.Delete], [Map.LoadAndDelete], [Map.Store], and [Map.Swap] are write operations;
* [Map.LoadOrStore] is a write operation when it returns loaded set to false;
* [Map.CompareAndSwap] is a write operation when it returns swapped set to true;
* and [Map.CompareAndDelete] is a write operation when it returns deleted set to true.
*
* [the Go memory model]: https://go.dev/ref/mem
*/
export class Map {
/** Creates a new Map instance. */
constructor($$source: Partial<Map> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new Map instance from a string or object.
*/
static createFrom($$source: any = {}): Map {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Map($$parsedSource as Partial<Map>);
}
}

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mesh Drop</title> <title>Mesh Drop</title>
<link rel="stylesheet" href="src/styles/style.css"> <link rel="stylesheet" href="src/styles/style.css">
<link rel="icon" type="image/svg+xml" href="src/assets/icon.svg">
</head> </head>
<body> <body>

View File

@@ -12,6 +12,7 @@
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"@wailsio/runtime": "^3.0.0-alpha.79", "@wailsio/runtime": "^3.0.0-alpha.79",
"vue": "^3.5.21", "vue": "^3.5.21",
"vue-i18n": "^11.2.8",
"vuetify": "^3.10.1" "vuetify": "^3.10.1"
}, },
"devDependencies": { "devDependencies": {
@@ -509,6 +510,50 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@intlify/core-base": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz",
"integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.2.8",
"@intlify/shared": "11.2.8"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz",
"integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.2.8",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz",
"integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1350,6 +1395,12 @@
"@vue/shared": "3.5.27" "@vue/shared": "3.5.27"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/language-core": { "node_modules/@vue/language-core": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.4.tgz",
@@ -3165,6 +3216,26 @@
} }
} }
}, },
"node_modules/vue-i18n": {
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
"integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.2.8",
"@intlify/shared": "11.2.8",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-tsc": { "node_modules/vue-tsc": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz", "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",

View File

@@ -16,6 +16,7 @@
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"@wailsio/runtime": "^3.0.0-alpha.79", "@wailsio/runtime": "^3.0.0-alpha.79",
"vue": "^3.5.21", "vue": "^3.5.21",
"vue-i18n": "^11.2.8",
"vuetify": "^3.10.1" "vuetify": "^3.10.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,16 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="512" height="512" rx="100" ry="100" fill="#ffffff"/>
<g stroke="#0055ff" stroke-width="28" stroke-linecap="round" fill="none">
<circle cx="256" cy="256" r="28" fill="#0055ff" stroke="none"/>
<path d="M 190 200 A 70 70 0 0 0 190 312" />
<path d="M 140 160 A 130 130 0 0 0 140 352" />
<path d="M 90 120 A 190 190 0 0 0 90 392" />
<path d="M 322 200 A 70 70 0 0 1 322 312" />
<path d="M 372 160 A 130 130 0 0 1 372 352" />
<path d="M 422 120 A 190 190 0 0 1 422 392" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 633 B

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
// --- Vue 核心 --- // --- Vue 核心 ---
import { onMounted, ref, computed } from "vue"; import { onMounted, ref, computed } from "vue";
import { useI18n } from "vue-i18n";
// --- 组件 --- // --- 组件 ---
import PeerCard from "./PeerCard.vue"; import PeerCard from "./PeerCard.vue";
@@ -25,6 +26,7 @@ const transferList = ref<Transfer[]>([]);
const activeKey = ref("discover"); const activeKey = ref("discover");
const drawer = ref(true); const drawer = ref(true);
const isMobile = ref(false); const isMobile = ref(false);
const { t } = useI18n();
// --- 计算属性 --- // --- 计算属性 ---
const pendingCount = computed(() => { const pendingCount = computed(() => {
@@ -35,18 +37,18 @@ const pendingCount = computed(() => {
const menuItems = computed(() => [ const menuItems = computed(() => [
{ {
title: "Discover", title: t("menu.discover"),
value: "discover", value: "discover",
icon: "mdi-radar", icon: "mdi-radar",
}, },
{ {
title: "Transfers", title: t("menu.transfers"),
value: "transfers", value: "transfers",
icon: "mdi-inbox", icon: "mdi-inbox",
badge: pendingCount.value > 0 ? pendingCount.value : null, badge: pendingCount.value > 0 ? pendingCount.value : null,
}, },
{ {
title: "Settings", title: t("menu.settings"),
value: "settings", value: "settings",
icon: "mdi-cog", icon: "mdi-cog",
}, },
@@ -66,12 +68,10 @@ onMounted(async () => {
// --- 后端集成 & 事件监听 --- // --- 后端集成 & 事件监听 ---
onMounted(async () => { onMounted(async () => {
peers.value = await GetPeers(); peers.value = await GetPeers();
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
}); });
Events.On("peers:update", (event) => { Events.On("peers:update", (event) => {
peers.value = event.data; peers.value = event.data;
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
}); });
Events.On("transfer:refreshList", async () => { Events.On("transfer:refreshList", async () => {
@@ -150,12 +150,24 @@ const handleCleanFinished = async () => {
<v-container fluid class="pa-4"> <v-container fluid class="pa-4">
<!-- 发现视图 --> <!-- 发现视图 -->
<div v-show="activeKey === 'discover'"> <div v-show="activeKey === 'discover'">
<div v-if="peers.length > 0" class="peer-grid"> <div v-if="peers.length > 0">
<div v-for="peer in peers" :key="peer.id"> <v-alert
<PeerCard icon="mdi-information-outline"
:peer="peer" density="compact"
@transferStarted="activeKey = 'transfers'" variant="tonal"
/> color="primary"
class="mb-4 text-body-2"
closable
>
{{ t("discover.dragDropHint") }}
</v-alert>
<div class="peer-grid">
<div v-for="peer in peers" :key="peer.id">
<PeerCard
:peer="peer"
@transferStarted="activeKey = 'transfers'"
/>
</div>
</div> </div>
</div> </div>
@@ -170,7 +182,7 @@ const handleCleanFinished = async () => {
class="mb-4 radar-icon" class="mb-4 radar-icon"
style="opacity: 0.5" style="opacity: 0.5"
></v-icon> ></v-icon>
<div class="text-grey">Scanning for peers...</div> <div class="text-grey">{{ t("discover.scanning") }}</div>
</div> </div>
</div> </div>
@@ -184,7 +196,7 @@ const handleCleanFinished = async () => {
color="error" color="error"
@click="handleCleanFinished" @click="handleCleanFinished"
> >
Clear Finished {{ t("transfers.clearFinished") }}
</v-btn> </v-btn>
</div> </div>
<TransferItem <TransferItem
@@ -198,7 +210,7 @@ const handleCleanFinished = async () => {
class="empty-state d-flex flex-column justify-center align-center" class="empty-state d-flex flex-column justify-center align-center"
> >
<v-icon icon="mdi-inbox" size="100" class="mb-4 text-grey"></v-icon> <v-icon icon="mdi-inbox" size="100" class="mb-4 text-grey"></v-icon>
<div class="text-grey">No transfers yet</div> <div class="text-grey">{{ t("transfers.noTransfers") }}</div>
</div> </div>
</div> </div>

View File

@@ -1,24 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
// --- Vue 核心 --- // --- Vue 核心 ---
import { computed, ref, watch } from "vue"; import { computed, ref, watch, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
// --- 组件 --- // --- 组件 ---
import FileSendModal from "./modals/FileSendModal.vue"; import FileSendModal from "./modals/FileSendModal.vue";
import TextSendModal from "./modals/TextSendModal.vue"; import TextSendModal from "./modals/TextSendModal.vue";
// --- Wails & 后端绑定 --- // --- Wails & 后端绑定 ---
import { Dialogs, Clipboard } from "@wailsio/runtime"; import { Dialogs, Clipboard, Events } from "@wailsio/runtime";
import { import {
SendFolder, SendFolder,
SendText, SendText,
} from "../../bindings/mesh-drop/internal/transfer/service"; } from "../../bindings/mesh-drop/internal/transfer/service";
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models"; import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
import {
IsTrusted,
AddTrust,
RemoveTrust,
} from "../../bindings/mesh-drop/internal/config/config";
import { File } from "bindings/mesh-drop/models";
// --- 生命周期 ---
const droppedFiles = ref<File[]>([]);
onMounted(async () => {
try {
isTrusted.value = await IsTrusted(props.peer.id);
} catch (err) {
console.error("Failed to check trusted peer status:", err);
}
Events.On("files-dropped", (event) => {
droppedFiles.value = event.data.files;
showFileModal.value = true;
});
});
onUnmounted(() => {
Events.Off("files-dropped");
});
// --- 属性 & 事件 --- // --- 属性 & 事件 ---
const props = defineProps<{ const props = defineProps<{
peer: Peer; peer: Peer;
}>(); }>();
const { t } = useI18n();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "transferStarted"): void; (e: "transferStarted"): void;
}>(); }>();
@@ -27,29 +54,30 @@ const emit = defineEmits<{
const selectedIp = ref<string>(""); const selectedIp = ref<string>("");
const showFileModal = ref(false); const showFileModal = ref(false);
const showTextModal = ref(false); const showTextModal = ref(false);
const isTrusted = ref(false);
const sendOptions = [ const sendOptions = computed(() => [
{ {
title: "Send Files", title: t("discover.sendFiles"),
value: "files", value: "files",
icon: "mdi-file", icon: "mdi-file",
}, },
{ {
title: "Send Folder", title: t("discover.sendFolder"),
value: "folder", value: "folder",
icon: "mdi-folder", icon: "mdi-folder",
}, },
{ {
title: "Send Text", title: t("discover.sendText"),
value: "text", value: "text",
icon: "mdi-format-font", icon: "mdi-format-font",
}, },
{ {
title: "Send Clipboard", title: t("discover.sendClipboard"),
value: "clipboard", value: "clipboard",
icon: "mdi-clipboard", icon: "mdi-clipboard",
}, },
]; ]);
// --- 计算属性 --- // --- 计算属性 ---
const ips = computed(() => { const ips = computed(() => {
@@ -70,6 +98,10 @@ const osIcon = computed(() => {
} }
}); });
const showMismatch = computed(() => {
return props.peer.trust_mismatch && isTrusted.value;
});
// --- 监听 --- // --- 监听 ---
watch( watch(
ips, ips,
@@ -108,7 +140,7 @@ const handleAction = (key: string) => {
const handleSendFolder = async () => { const handleSendFolder = async () => {
if (!selectedIp.value) return; if (!selectedIp.value) return;
const opts: Dialogs.OpenFileDialogOptions = { const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select folder to send", Title: t("discover.selectFolder"),
CanChooseDirectories: true, CanChooseDirectories: true,
CanChooseFiles: false, CanChooseFiles: false,
AllowsMultipleSelection: false, AllowsMultipleSelection: false,
@@ -118,7 +150,7 @@ const handleSendFolder = async () => {
SendFolder(props.peer, selectedIp.value, folderPath as string).catch((e) => { SendFolder(props.peer, selectedIp.value, folderPath as string).catch((e) => {
console.error(e); console.error(e);
alert("Failed to send folder: " + e); alert(t("discover.sendFolderFailed", { error: e }));
}); });
emit("transferStarted"); emit("transferStarted");
}; };
@@ -127,19 +159,36 @@ const handleSendClipboard = async () => {
if (!selectedIp.value) return; if (!selectedIp.value) return;
const text = await Clipboard.Text(); const text = await Clipboard.Text();
if (!text) { if (!text) {
alert("Clipboard is empty"); alert(t("discover.clipboardEmpty"));
return; return;
} }
SendText(props.peer, selectedIp.value, text).catch((e) => { SendText(props.peer, selectedIp.value, text).catch((e) => {
console.error(e); console.error(e);
alert("Failed to send clipboard: " + e); alert(t("discover.sendClipboardFailed", { error: e }));
}); });
emit("transferStarted"); emit("transferStarted");
}; };
const handleTrust = () => {
AddTrust(props.peer.id, props.peer.pk);
isTrusted.value = true;
};
const handleUntrust = () => {
RemoveTrust(props.peer.id);
isTrusted.value = false;
};
</script> </script>
<template> <template>
<v-card hover link class="peer-card pa-2"> <v-card
hover
link
class="peer-card pa-2"
:ripple="false"
data-file-drop-target
:id="`drop-zone-peer-${peer.id}`"
>
<template #title> <template #title>
<div class="d-flex align-center"> <div class="d-flex align-center">
<v-icon :icon="osIcon" size="24" class="mr-2"></v-icon> <v-icon :icon="osIcon" size="24" class="mr-2"></v-icon>
@@ -183,16 +232,41 @@ const handleSendClipboard = async () => {
</v-menu> </v-menu>
<!-- No Route --> <!-- No Route -->
<v-chip v-else color="warning" size="small" label> No Route </v-chip> <v-chip v-else color="warning" size="small" label>
{{ t("discover.noRoute") }}
</v-chip>
</div>
<!-- 拖放提示覆盖层 -->
<div class="drag-drop-overlay">
<v-icon
icon="mdi-file-upload-outline"
size="48"
color="primary"
style="opacity: 0.8"
></v-icon>
</div> </div>
</template> </template>
<template #actions> <v-card-actions>
<v-menu> <!-- Trust Mismatch Warning -->
<v-btn
v-if="showMismatch"
class="flex-grow-1"
color="warning"
variant="tonal"
prepend-icon="mdi-alert"
:ripple="false"
style="pointer-events: none; min-width: 0"
>
<span class="text-truncate">{{ t("discover.mismatch") }}</span>
</v-btn>
<v-menu v-else>
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn <v-btn
v-bind="props" v-bind="props"
block class="flex-grow-1"
color="primary" color="primary"
variant="tonal" variant="tonal"
:disabled="ips.length === 0" :disabled="ips.length === 0"
@@ -201,7 +275,7 @@ const handleSendClipboard = async () => {
<template #prepend> <template #prepend>
<v-icon icon="mdi-send"></v-icon> <v-icon icon="mdi-send"></v-icon>
</template> </template>
Send {{ t("discover.send") }}
</v-btn> </v-btn>
</template> </template>
<v-list> <v-list>
@@ -218,7 +292,38 @@ const handleSendClipboard = async () => {
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</template>
<!-- Trust Mismatch Reset Override -->
<v-btn
v-if="showMismatch"
variant="tonal"
color="error"
@click="handleUntrust"
>
<v-icon icon="mdi-delete"></v-icon>
<v-tooltip activator="parent" location="bottom">{{
t("discover.resetTrust")
}}</v-tooltip>
</v-btn>
<v-btn
v-else-if="!isTrusted"
variant="tonal"
color="primary"
@click="handleTrust"
>
<v-icon icon="mdi-star-outline"></v-icon>
<v-tooltip activator="parent" location="bottom">{{
t("discover.trustPeer")
}}</v-tooltip>
</v-btn>
<v-btn v-else variant="tonal" color="primary" @click="handleUntrust">
<v-icon icon="mdi-star"></v-icon>
<v-tooltip activator="parent" location="bottom">{{
t("discover.untrustPeer")
}}</v-tooltip>
</v-btn>
</v-card-actions>
</v-card> </v-card>
<!-- Modals --> <!-- Modals -->
@@ -226,6 +331,7 @@ const handleSendClipboard = async () => {
v-model="showFileModal" v-model="showFileModal"
:peer="peer" :peer="peer"
:selectedIp="selectedIp" :selectedIp="selectedIp"
:files="droppedFiles"
@transferStarted="emit('transferStarted')" @transferStarted="emit('transferStarted')"
/> />
@@ -236,3 +342,70 @@ const handleSendClipboard = async () => {
@transferStarted="emit('transferStarted')" @transferStarted="emit('transferStarted')"
/> />
</template> </template>
<style scoped>
.peer-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.peer-card::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgb(var(--v-theme-primary));
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 1;
}
.peer-card.file-drop-target-active {
transform: translateY(-4px);
box-shadow: 0 8px 24px -4px rgba(var(--v-theme-primary), 0.24) !important;
border-color: rgb(var(--v-theme-primary)) !important;
}
.peer-card.file-drop-target-active::after {
opacity: 0.12;
}
.drag-drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
background: rgba(var(--v-theme-surface), 0.8);
backdrop-filter: blur(2px);
}
.peer-card.file-drop-target-active .drag-drop-overlay {
opacity: 1;
}
.drag-drop-content {
color: rgb(var(--v-theme-primary));
font-weight: 500;
display: flex;
flex-direction: column;
align-items: center;
transform: translateY(10px);
transition: transform 0.3s ease;
}
.peer-card.file-drop-target-active .drag-drop-content {
transform: translateY(0);
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
// --- Vue 核心 --- // --- Vue 核心 ---
import { onMounted, ref } from "vue"; import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
// --- Wails & 后端绑定 --- // --- Wails & 后端绑定 ---
import { Dialogs } from "@wailsio/runtime"; import { Dialogs } from "@wailsio/runtime";
@@ -14,7 +15,12 @@ import {
GetSaveHistory, GetSaveHistory,
SetSaveHistory, SetSaveHistory,
GetVersion, GetVersion,
GetLanguage,
SetLanguage,
SetCloseToSystray,
GetCloseToSystray,
} from "../../bindings/mesh-drop/internal/config/config"; } from "../../bindings/mesh-drop/internal/config/config";
import { Language } from "bindings/mesh-drop/internal/config";
// --- 状态 --- // --- 状态 ---
const savePath = ref(""); const savePath = ref("");
@@ -22,6 +28,14 @@ const hostName = ref("");
const autoAccept = ref(false); const autoAccept = ref(false);
const saveHistory = ref(false); const saveHistory = ref(false);
const version = ref(""); const version = ref("");
const closeToSystray = ref(false);
const { t, locale } = useI18n();
const languages = [
{ title: "English", value: "en" },
{ title: "简体中文", value: "zh-Hans" },
];
// ---生命周期 --- // ---生命周期 ---
onMounted(async () => { onMounted(async () => {
@@ -30,12 +44,17 @@ onMounted(async () => {
autoAccept.value = await GetAutoAccept(); autoAccept.value = await GetAutoAccept();
saveHistory.value = await GetSaveHistory(); saveHistory.value = await GetSaveHistory();
version.value = await GetVersion(); version.value = await GetVersion();
let l = await GetLanguage();
if (l != "") {
locale.value = l;
}
closeToSystray.value = await GetCloseToSystray();
}); });
// --- 方法 --- // --- 方法 ---
const changeSavePath = async () => { const changeSavePath = async () => {
const opts: Dialogs.OpenFileDialogOptions = { const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select Save Path", Title: t("settings.selectSavePath"),
CanChooseDirectories: true, CanChooseDirectories: true,
CanChooseFiles: false, CanChooseFiles: false,
AllowsMultipleSelection: false, AllowsMultipleSelection: false,
@@ -46,11 +65,17 @@ const changeSavePath = async () => {
savePath.value = path; savePath.value = path;
} }
}; };
// 监听语言变化
watch(locale, async (newVal) => {
await SetLanguage(newVal as Language);
});
</script> </script>
<template> <template>
<v-list lines="one" bg-color="transparent"> <v-list lines="one" bg-color="transparent">
<v-list-item title="Save Path" :subtitle="savePath"> <!-- 保存路径 -->
<v-list-item :title="t('settings.savePath')" :subtitle="savePath">
<template #prepend> <template #prepend>
<v-icon icon="mdi-folder-download"></v-icon> <v-icon icon="mdi-folder-download"></v-icon>
</template> </template>
@@ -61,11 +86,13 @@ const changeSavePath = async () => {
@click="changeSavePath" @click="changeSavePath"
prepend-icon="mdi-pencil" prepend-icon="mdi-pencil"
> >
Change {{ t("settings.change") }}
</v-btn> </v-btn>
</template> </template>
</v-list-item> </v-list-item>
<v-list-item title="HostName">
<!-- 主机名 -->
<v-list-item :title="t('settings.hostName')">
<template #prepend> <template #prepend>
<v-icon icon="mdi-laptop"></v-icon> <v-icon icon="mdi-laptop"></v-icon>
</template> </template>
@@ -79,7 +106,9 @@ const changeSavePath = async () => {
></v-text-field> ></v-text-field>
</template> </template>
</v-list-item> </v-list-item>
<v-list-item title="Save History">
<!-- 保存历史 -->
<v-list-item :title="t('settings.saveHistory')">
<template #prepend> <template #prepend>
<v-icon icon="mdi-history"></v-icon> <v-icon icon="mdi-history"></v-icon>
</template> </template>
@@ -93,7 +122,9 @@ const changeSavePath = async () => {
></v-switch> ></v-switch>
</template> </template>
</v-list-item> </v-list-item>
<v-list-item title="Auto Accept">
<!-- 自动接受 -->
<v-list-item :title="t('settings.autoAccept')">
<template #prepend> <template #prepend>
<v-icon icon="mdi-content-save"></v-icon> <v-icon icon="mdi-content-save"></v-icon>
</template> </template>
@@ -107,7 +138,42 @@ const changeSavePath = async () => {
></v-switch> ></v-switch>
</template> </template>
</v-list-item> </v-list-item>
<v-list-item title="Version">
<!-- 关闭窗口时最小化到托盘 -->
<v-list-item :title="t('settings.closeToSystray')">
<template #prepend>
<v-icon icon="mdi-tray"></v-icon>
</template>
<template #append>
<v-switch
v-model="closeToSystray"
color="primary"
inset
hide-details
@update:modelValue="SetCloseToSystray(closeToSystray)"
></v-switch>
</template>
</v-list-item>
<!-- 语言 -->
<v-list-item :title="t('settings.language')">
<template #prepend>
<v-icon icon="mdi-translate"></v-icon>
</template>
<template #append>
<v-select
v-model="locale"
:items="languages"
variant="underlined"
density="compact"
hide-details
width="150"
></v-select>
</template>
</v-list-item>
<!-- 版本 -->
<v-list-item :title="t('settings.version')">
<template #prepend> <template #prepend>
<v-icon icon="mdi-information"></v-icon> <v-icon icon="mdi-information"></v-icon>
</template> </template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
// --- Vue 核心 --- // --- Vue 核心 ---
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
// --- Wails & 后端绑定 --- // --- Wails & 后端绑定 ---
import { Dialogs, Clipboard } from "@wailsio/runtime"; import { Dialogs, Clipboard } from "@wailsio/runtime";
@@ -16,6 +17,8 @@ const props = defineProps<{
transfer: Transfer; transfer: Transfer;
}>(); }>();
const { t } = useI18n();
// --- 状态 --- // --- 状态 ---
const showContentDialog = ref(false); const showContentDialog = ref(false);
@@ -106,7 +109,7 @@ const rejectTransfer = () => {
const acceptToFolder = async () => { const acceptToFolder = async () => {
const opts: Dialogs.OpenFileDialogOptions = { const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select Folder to save the file", Title: t("transfers.selectSavePath"),
CanChooseDirectories: true, CanChooseDirectories: true,
CanChooseFiles: false, CanChooseFiles: false,
AllowsMultipleSelection: false, AllowsMultipleSelection: false,
@@ -178,7 +181,9 @@ const handleCopy = async () => {
></v-icon> ></v-icon>
{{ {{
props.transfer.file_name || props.transfer.file_name ||
(props.transfer.content_type === "text" ? "Text" : "Folder") (props.transfer.content_type === "text"
? t("transfers.text")
: t("transfers.folder"))
}} }}
</div> </div>
@@ -187,9 +192,23 @@ const handleCopy = async () => {
v-if=" v-if="
props.transfer.sender.name && props.transfer.type === 'receive' props.transfer.sender.name && props.transfer.type === 'receive'
" "
prepend-icon="mdi-account" :color="
props.transfer.sender.trust_mismatch ? 'warning' : undefined
"
:prepend-icon="
props.transfer.sender.trust_mismatch
? 'mdi-alert'
: 'mdi-account'
"
> >
{{ props.transfer.sender.name }} {{ props.transfer.sender.name }}
<v-tooltip
v-if="props.transfer.sender.trust_mismatch"
activator="parent"
location="bottom"
>
{{ t("transfers.securityAlert") }}
</v-tooltip>
</v-chip> </v-chip>
<v-chip <v-chip
@@ -212,25 +231,25 @@ const handleCopy = async () => {
v-if="props.transfer.status === 'completed'" v-if="props.transfer.status === 'completed'"
class="text-success" class="text-success"
> >
&nbsp;- Completed &nbsp;- {{ t("transfers.completed") }}
</span> </span>
<span v-if="props.transfer.status === 'error'" class="text-error"> <span v-if="props.transfer.status === 'error'" class="text-error">
&nbsp;- {{ props.transfer.error_msg || "Error" }} &nbsp;- {{ props.transfer.error_msg || t("common.error") }}
</span> </span>
<span v-if="props.transfer.status === 'canceled'" class="text-info"> <span v-if="props.transfer.status === 'canceled'" class="text-info">
&nbsp;- Canceled &nbsp;- {{ t("transfers.cancelled") }}
</span> </span>
<span <span
v-if="props.transfer.status === 'rejected'" v-if="props.transfer.status === 'rejected'"
class="text-error" class="text-error"
> >
&nbsp;- Rejected &nbsp;- {{ t("transfers.rejected") }}
</span> </span>
<span <span
v-if="props.transfer.status === 'pending'" v-if="props.transfer.status === 'pending'"
class="text-warning" class="text-warning"
> >
&nbsp;- Waiting for accept &nbsp;- {{ t("transfers.waitingForAccept") }}
</span> </span>
</div> </div>
@@ -250,7 +269,9 @@ const handleCopy = async () => {
<v-btn-group density="compact" variant="tonal" divided rounded="xl"> <v-btn-group density="compact" variant="tonal" divided rounded="xl">
<v-btn v-if="canAccept" color="success" @click="acceptTransfer"> <v-btn v-if="canAccept" color="success" @click="acceptTransfer">
<v-icon icon="mdi-content-save"></v-icon> <v-icon icon="mdi-content-save"></v-icon>
<v-tooltip activator="parent" location="bottom">Accept</v-tooltip> <v-tooltip activator="parent" location="bottom">{{
t("common.accept")
}}</v-tooltip>
</v-btn> </v-btn>
<v-btn <v-btn
@@ -260,13 +281,15 @@ const handleCopy = async () => {
> >
<v-icon icon="mdi-folder-arrow-right"></v-icon> <v-icon icon="mdi-folder-arrow-right"></v-icon>
<v-tooltip activator="parent" location="bottom"> <v-tooltip activator="parent" location="bottom">
Save to Folder {{ t("transfers.saveToFolder") }}
</v-tooltip> </v-tooltip>
</v-btn> </v-btn>
<v-btn v-if="canAccept" color="error" @click="rejectTransfer"> <v-btn v-if="canAccept" color="error" @click="rejectTransfer">
<v-icon icon="mdi-close"></v-icon> <v-icon icon="mdi-close"></v-icon>
<v-tooltip activator="parent" location="bottom">Reject</v-tooltip> <v-tooltip activator="parent" location="bottom">{{
t("common.reject")
}}</v-tooltip>
</v-btn> </v-btn>
<v-btn <v-btn
@@ -276,13 +299,15 @@ const handleCopy = async () => {
> >
<v-icon icon="mdi-eye"></v-icon> <v-icon icon="mdi-eye"></v-icon>
<v-tooltip activator="parent" location="bottom"> <v-tooltip activator="parent" location="bottom">
View Content {{ t("transfers.viewContent") }}
</v-tooltip> </v-tooltip>
</v-btn> </v-btn>
<v-btn v-if="canCopy" color="success" @click="handleCopy"> <v-btn v-if="canCopy" color="success" @click="handleCopy">
<v-icon icon="mdi-content-copy"></v-icon> <v-icon icon="mdi-content-copy"></v-icon>
<v-tooltip activator="parent" location="bottom">Copy</v-tooltip> <v-tooltip activator="parent" location="bottom">{{
t("common.copy")
}}</v-tooltip>
</v-btn> </v-btn>
<v-btn <v-btn
@@ -296,7 +321,9 @@ const handleCopy = async () => {
@click="handleDelete" @click="handleDelete"
> >
<v-icon icon="mdi-delete"></v-icon> <v-icon icon="mdi-delete"></v-icon>
<v-tooltip activator="parent" location="bottom">Delete</v-tooltip> <v-tooltip activator="parent" location="bottom">{{
t("common.delete")
}}</v-tooltip>
</v-btn> </v-btn>
<v-btn <v-btn
@@ -305,7 +332,9 @@ const handleCopy = async () => {
@click="CancelTransfer(props.transfer.id)" @click="CancelTransfer(props.transfer.id)"
> >
<v-icon icon="mdi-stop"></v-icon> <v-icon icon="mdi-stop"></v-icon>
<v-tooltip activator="parent" location="bottom">Cancel</v-tooltip> <v-tooltip activator="parent" location="bottom">{{
t("common.cancel")
}}</v-tooltip>
</v-btn> </v-btn>
</v-btn-group> </v-btn-group>
</div> </div>
@@ -314,7 +343,7 @@ const handleCopy = async () => {
</v-card> </v-card>
<v-dialog v-model="showContentDialog" width="600"> <v-dialog v-model="showContentDialog" width="600">
<v-card title="Text Content"> <v-card :title="t('transfers.textContent')">
<v-card-text> <v-card-text>
<v-textarea <v-textarea
:model-value="props.transfer.text" :model-value="props.transfer.text"

View File

@@ -1,17 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
// --- Vue 核心 --- // --- Vue 核心 ---
import { computed, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
// --- Wails & 后端绑定 --- // --- Wails & 后端绑定 ---
import { Events, Dialogs } from "@wailsio/runtime"; import { Events, Dialogs, Window } from "@wailsio/runtime";
import { SendFiles } from "../../../bindings/mesh-drop/internal/transfer/service"; import { SendFiles } from "../../../bindings/mesh-drop/internal/transfer/service";
import { Peer } from "../../../bindings/mesh-drop/internal/discovery/models"; import { Peer } from "../../../bindings/mesh-drop/internal/discovery/models";
import { File } from "bindings/mesh-drop/models";
onMounted(() => {});
// --- 属性 & 事件 --- // --- 属性 & 事件 ---
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
peer: Peer; peer: Peer;
selectedIp: string; selectedIp: string;
files: File[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -20,7 +25,7 @@ const emit = defineEmits<{
}>(); }>();
// --- 状态 --- // --- 状态 ---
const fileList = ref<{ name: string; path: string }[]>([]); const { t } = useI18n();
// --- 计算属性 --- // --- 计算属性 ---
const show = computed({ const show = computed({
@@ -32,43 +37,39 @@ const show = computed({
watch(show, (newVal) => { watch(show, (newVal) => {
if (newVal) { if (newVal) {
Events.On("files-dropped", (event) => { Events.On("files-dropped", (event) => {
const files: string[] = event.data.files || []; const files: File[] = event.data.files || [];
files.forEach((f) => { files.forEach((f) => {
if (!fileList.value.find((existing) => existing.path === f)) { if (!props.files.find((existing) => existing.path === f.path)) {
fileList.value.push({ props.files.push(f);
name: f.split(/[\/]/).pop() || f,
path: f,
});
} }
}); });
}); });
} else { } else {
Events.Off("files-dropped"); Events.Off("files-dropped");
fileList.value = [];
} }
}); });
// --- 方法 --- // --- 方法 ---
const openFileDialog = async () => { const openFileDialog = async () => {
const files = await Dialogs.OpenFile({ const files = await Dialogs.OpenFile({
Title: "Select files to send", Title: t("modal.fileSend.selectTitle"),
AllowsMultipleSelection: true, AllowsMultipleSelection: true,
}); });
if (files) { if (files) {
if (Array.isArray(files)) { if (Array.isArray(files)) {
files.forEach((f) => { files.forEach((f) => {
if (!fileList.value.find((existing) => existing.path === f)) { if (!props.files.find((existing) => existing.path === f)) {
fileList.value.push({ props.files.push({
name: f.split(/[\\/]/).pop() || f, name: f.split(/[\/]/).pop() || f,
path: f, path: f,
}); });
} }
}); });
} else { } else {
const f = files as string; const f = files as string;
if (!fileList.value.find((existing) => existing.path === f)) { if (!props.files.find((existing) => existing.path === f)) {
fileList.value.push({ props.files.push({
name: f.split(/[\\/]/).pop() || f, name: f.split(/[\\/]/).pop() || f,
path: f, path: f,
}); });
@@ -78,12 +79,12 @@ const openFileDialog = async () => {
}; };
const handleRemoveFile = (index: number) => { const handleRemoveFile = (index: number) => {
fileList.value.splice(index, 1); props.files.splice(index, 1);
}; };
const handleSendFiles = async () => { const handleSendFiles = async () => {
if (fileList.value.length === 0 || !props.selectedIp) return; if (props.files.length === 0 || !props.selectedIp) return;
const paths = fileList.value.map((f) => f.path); const paths = props.files.map((f) => f.path);
try { try {
await SendFiles(props.peer, props.selectedIp, paths); await SendFiles(props.peer, props.selectedIp, paths);
@@ -91,20 +92,21 @@ const handleSendFiles = async () => {
show.value = false; show.value = false;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert("Failed to send files: " + e); alert(t("modal.fileSend.failed", { error: e }));
} }
}; };
</script> </script>
<template> <template>
<v-dialog v-model="show" width="600" persistent eager> <v-dialog v-model="show" width="600" persistent eager>
<v-card title="Send Files"> <v-card :title="$t('modal.fileSend.title')">
<v-card-text> <v-card-text>
<div <div
v-if="fileList.length === 0" v-if="props.files.length === 0"
class="drop-zone pa-10 text-center rounded-lg border-dashed" class="drop-zone pa-10 text-center rounded-lg"
@click="openFileDialog" @click="openFileDialog"
data-file-drop-target data-file-drop-target
id="drop-zone-area"
> >
<v-icon <v-icon
icon="mdi-cloud-upload" icon="mdi-cloud-upload"
@@ -113,7 +115,7 @@ const handleSendFiles = async () => {
class="mb-2" class="mb-2"
></v-icon> ></v-icon>
<div class="text-body-1 text-medium-emphasis"> <div class="text-body-1 text-medium-emphasis">
Click to select files {{ $t("modal.fileSend.dragDrop") }}
</div> </div>
</div> </div>
@@ -125,9 +127,10 @@ const handleSendFiles = async () => {
max-height="400" max-height="400"
style="overflow-y: auto" style="overflow-y: auto"
data-file-drop-target data-file-drop-target
id="drop-zone-list"
> >
<v-list-item <v-list-item
v-for="(file, index) in fileList" v-for="(file, index) in props.files"
:key="file.path" :key="file.path"
:title="file.name" :title="file.name"
:subtitle="file.path" :subtitle="file.path"
@@ -147,26 +150,28 @@ const handleSendFiles = async () => {
<v-btn <v-btn
block block
variant="outlined" variant="tonal"
style="border-style: dashed"
prepend-icon="mdi-plus" prepend-icon="mdi-plus"
@click="openFileDialog" @click="openFileDialog"
class="mt-2" class="mt-2"
> >
Add more files {{ $t("modal.fileSend.addMore") }}
</v-btn> </v-btn>
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn variant="text" @click="show = false">Cancel</v-btn> <v-btn variant="text" @click="show = false">{{
$t("common.cancel")
}}</v-btn>
<v-btn <v-btn
color="primary" color="primary"
@click="handleSendFiles" @click="handleSendFiles"
:disabled="fileList.length === 0" :disabled="props.files.length === 0"
> >
Send {{ fileList.length > 0 ? `(${fileList.length})` : "" }} {{ $t("modal.fileSend.sendSrc") }}
{{ props.files.length > 0 ? `(${props.files.length})` : "" }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
@@ -175,17 +180,32 @@ const handleSendFiles = async () => {
<style scoped> <style scoped>
.drop-zone { .drop-zone {
border: 2px dashed #666; /* Use a darker color or theme var */ border: 2px solid transparent;
border-radius: 12px;
background-color: rgba(var(--v-theme-on-surface), 0.04);
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
} }
.drop-zone:hover { .drop-zone:hover {
border-color: #38bdf8; background-color: rgba(var(--v-theme-primary), 0.08);
background-color: rgba(56, 189, 248, 0.05);
} }
.drop-zone.file-drop-target-active { .drop-zone.file-drop-target-active {
border-color: #38bdf8; border-color: rgb(var(--v-theme-primary));
background-color: rgba(56, 189, 248, 0.1); background-color: rgba(var(--v-theme-primary), 0.12);
transform: scale(1.01);
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.15);
}
#drop-zone-list {
transition: all 0.3s ease;
}
#drop-zone-list.file-drop-target-active {
box-shadow: inset 0 0 0 2px rgb(var(--v-theme-primary));
background-color: rgba(var(--v-theme-primary), 0.04);
} }
</style> </style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
// --- Vue 核心 --- // --- Vue 核心 ---
import { computed, ref } from "vue"; import { computed, ref, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
// --- Wails & 后端绑定 --- // --- Wails & 后端绑定 ---
import { SendText } from "../../../bindings/mesh-drop/internal/transfer/service"; import { SendText } from "../../../bindings/mesh-drop/internal/transfer/service";
@@ -19,7 +20,9 @@ const emit = defineEmits<{
}>(); }>();
// --- 状态 --- // --- 状态 ---
const { t } = useI18n();
const textContent = ref(""); const textContent = ref("");
const textareaRef = ref();
// --- 计算属性 --- // --- 计算属性 ---
const show = computed({ const show = computed({
@@ -27,6 +30,14 @@ const show = computed({
set: (value) => emit("update:modelValue", value), set: (value) => emit("update:modelValue", value),
}); });
// --- 监听 ---
watch(show, async (val) => {
if (val) {
await nextTick();
textareaRef.value?.focus();
}
});
// --- 方法 --- // --- 方法 ---
const executeSendText = async () => { const executeSendText = async () => {
if (!props.selectedIp || !textContent.value) return; if (!props.selectedIp || !textContent.value) return;
@@ -38,32 +49,35 @@ const executeSendText = async () => {
textContent.value = ""; textContent.value = "";
} catch (e) { } catch (e) {
console.error(e); console.error(e);
alert("Failed to send text: " + e); alert(t("modal.textSend.failed", { error: e }));
} }
}; };
</script> </script>
<template> <template>
<v-dialog v-model="show" width="500" persistent eager> <v-dialog v-model="show" width="500" persistent eager>
<v-card title="Send Text"> <v-card :title="$t('modal.textSend.title')">
<v-card-text> <v-card-text>
<v-textarea <v-textarea
ref="textareaRef"
v-model="textContent" v-model="textContent"
label="Content" :label="$t('modal.textSend.contentLabel')"
placeholder="Type something to send..." :placeholder="$t('modal.textSend.placeholder')"
rows="4" rows="4"
auto-grow auto-grow
></v-textarea> ></v-textarea>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn variant="text" @click="show = false">Cancel</v-btn> <v-btn variant="text" @click="show = false">{{
$t("common.cancel")
}}</v-btn>
<v-btn <v-btn
color="primary" color="primary"
@click="executeSendText" @click="executeSendText"
:disabled="!textContent" :disabled="!textContent"
> >
Send {{ $t("modal.textSend.send") }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>

View File

@@ -0,0 +1,85 @@
{
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"loading": "Loading...",
"success": "Success",
"error": "Error",
"accept": "Accept",
"reject": "Reject",
"copy": "Copy"
},
"menu": {
"discover": "Discover",
"transfers": "Transfers",
"settings": "Settings"
},
"discover": {
"scanning": "Scanning for peers...",
"noPeers": "No peers found",
"send": "Send",
"sendFiles": "Send Files",
"sendFolder": "Send Folder",
"sendText": "Send Text",
"sendClipboard": "Send Clipboard",
"selectFolder": "Select Folder",
"clipboardEmpty": "Clipboard is empty",
"noRoute": "No Route",
"mismatch": "Trust Mismatch",
"resetTrust": "Reset Trust",
"trustPeer": "Trust Peer",
"untrustPeer": "Untrust Peer",
"sendFolderFailed": "Failed to send folder: {error}",
"sendClipboardFailed": "Failed to send clipboard: {error}",
"dragDropHint": "Drag and drop files here to send"
},
"transfers": {
"noTransfers": "No transfers yet",
"clearFinished": "Clear Finished",
"pending": "Pending",
"transferring": "Transferring",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled",
"selectSavePath": "Select Save Path",
"text": "Text",
"folder": "Folder",
"securityAlert": "Security Alert",
"rejected": "Rejected",
"waitingForAccept": "Waiting for accept",
"saveToFolder": "Save to Folder",
"viewContent": "View Content",
"textContent": "Text Content"
},
"settings": {
"savePath": "Save Path",
"change": "Change",
"hostName": "Host Name",
"saveHistory": "Save History",
"autoAccept": "Auto Accept",
"version": "Version",
"language": "Language",
"selectSavePath": "Select Save Path",
"closeToSystray": "Close to Systray"
},
"modal": {
"fileSend": {
"title": "Send Files",
"selectTitle": "Select files to send",
"dragDrop": "Click to select files",
"addMore": "Add more files",
"sendSrc": "Send",
"failed": "Failed to send files: {error}"
},
"textSend": {
"title": "Send Text",
"contentLabel": "Content",
"placeholder": "Type something to send...",
"send": "Send",
"failed": "Failed to send text: {error}"
}
}
}

View File

@@ -0,0 +1,85 @@
{
"common": {
"confirm": "确定",
"cancel": "取消",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"loading": "加载中...",
"success": "成功",
"error": "错误",
"accept": "接收",
"reject": "拒绝",
"copy": "复制"
},
"menu": {
"discover": "发现",
"transfers": "传输",
"settings": "设置"
},
"discover": {
"scanning": "正在扫描设备...",
"noPeers": "未发现设备",
"send": "发送",
"sendFiles": "发送文件",
"sendFolder": "发送文件夹",
"sendText": "发送文本",
"sendClipboard": "发送剪贴板",
"selectFolder": "选择文件夹",
"clipboardEmpty": "剪贴板为空",
"noRoute": "不可达",
"mismatch": "信任不匹配",
"resetTrust": "重置信任",
"trustPeer": "信任设备",
"untrustPeer": "取消信任",
"sendFolderFailed": "发送文件夹失败: {error}",
"sendClipboardFailed": "发送剪贴板失败: {error}",
"dragDropHint": "拖放文件到此处快速发送"
},
"transfers": {
"noTransfers": "暂无传输记录",
"clearFinished": "清除已完成",
"pending": "等待中",
"transferring": "传输中",
"completed": "已完成",
"failed": "失败",
"cancelled": "已取消",
"selectSavePath": "选择保存路径",
"text": "文本",
"folder": "文件夹",
"securityAlert": "安全警告",
"rejected": "已拒绝",
"waitingForAccept": "等待接收",
"saveToFolder": "保存到文件夹",
"viewContent": "查看内容",
"textContent": "文本内容"
},
"settings": {
"savePath": "保存路径",
"change": "更改",
"hostName": "设备名称",
"saveHistory": "保存历史记录",
"autoAccept": "自动接收",
"version": "版本",
"language": "语言",
"selectSavePath": "选择保存路径",
"closeToSystray": "关闭窗口时最小化到托盘"
},
"modal": {
"fileSend": {
"title": "发送文件",
"selectTitle": "选择要发送的文件",
"dragDrop": "点击选择文件",
"addMore": "添加更多文件",
"sendSrc": "发送",
"failed": "发送文件失败: {error}"
},
"textSend": {
"title": "发送文本",
"contentLabel": "内容",
"placeholder": "输入要发送的内容...",
"send": "发送",
"failed": "发送文本失败: {error}"
}
}
}

View File

@@ -0,0 +1,15 @@
import { createI18n } from "vue-i18n";
import en from "../locales/en.json";
import zhHans from "../locales/zh-Hans.json";
const i18n = createI18n({
legacy: false, // use Composition API
locale: navigator.language.startsWith("zh") ? "zh-Hans" : "en",
fallbackLocale: "en",
messages: {
en,
"zh-Hans": zhHans,
},
});
export default i18n;

View File

@@ -5,11 +5,13 @@
*/ */
// Plugins // Plugins
import vuetify from './vuetify' import vuetify from "./vuetify";
import i18n from "./i18n";
// Types // Types
import type { App } from 'vue' import type { App } from "vue";
export function registerPlugins (app: App) { export function registerPlugins(app: App) {
app.use(vuetify) app.use(vuetify);
app.use(i18n);
} }

View File

@@ -7,6 +7,10 @@ body,
/* 标准属性 */ /* 标准属性 */
cursor: default; cursor: default;
/* 鼠标指针变为默认箭头,而不是文本输入的 I 形 */ /* 鼠标指针变为默认箭头,而不是文本输入的 I 形 */
overflow: hidden;
height: 100%;
margin: 0;
padding: 0;
} }
input, input,

View File

@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/plugins/index.ts","./src/plugins/vuetify.ts","./src/App.vue","./src/components/MainLayout.vue","./src/components/PeerCard.vue","./src/components/SettingsView.vue","./src/components/TransferItem.vue","./src/components/modals/FileSendModal.vue","./src/components/modals/TextSendModal.vue","./bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts","./bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts","./bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts","./bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts","./bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts","./bindings/mesh-drop/index.ts","./bindings/mesh-drop/models.ts","./bindings/mesh-drop/internal/config/config.ts","./bindings/mesh-drop/internal/config/index.ts","./bindings/mesh-drop/internal/config/models.ts","./bindings/mesh-drop/internal/discovery/index.ts","./bindings/mesh-drop/internal/discovery/models.ts","./bindings/mesh-drop/internal/discovery/service.ts","./bindings/mesh-drop/internal/transfer/index.ts","./bindings/mesh-drop/internal/transfer/models.ts","./bindings/mesh-drop/internal/transfer/service.ts","./bindings/time/index.ts","./bindings/time/models.ts"],"version":"5.9.3"} {"root":["./src/main.ts","./src/vite-env.d.ts","./src/plugins/i18n.ts","./src/plugins/index.ts","./src/plugins/vuetify.ts","./src/App.vue","./src/components/MainLayout.vue","./src/components/PeerCard.vue","./src/components/SettingsView.vue","./src/components/TransferItem.vue","./src/components/modals/FileSendModal.vue","./src/components/modals/TextSendModal.vue","./bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts","./bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts","./bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts","./bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts","./bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/notificationservice.ts","./bindings/mesh-drop/index.ts","./bindings/mesh-drop/models.ts","./bindings/mesh-drop/internal/config/config.ts","./bindings/mesh-drop/internal/config/index.ts","./bindings/mesh-drop/internal/config/models.ts","./bindings/mesh-drop/internal/discovery/index.ts","./bindings/mesh-drop/internal/discovery/models.ts","./bindings/mesh-drop/internal/discovery/service.ts","./bindings/mesh-drop/internal/transfer/index.ts","./bindings/mesh-drop/internal/transfer/models.ts","./bindings/mesh-drop/internal/transfer/service.ts","./bindings/sync/index.ts","./bindings/sync/models.ts","./bindings/time/index.ts","./bindings/time/models.ts"],"version":"5.9.3"}

12
go.mod
View File

@@ -5,8 +5,7 @@ go 1.25
require ( require (
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/spf13/viper v1.21.0 github.com/wailsapp/wails/v3 v3.0.0-alpha.68
github.com/wailsapp/wails/v3 v3.0.0-alpha.67
) )
require ( require (
@@ -24,7 +23,6 @@ require (
github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
@@ -34,7 +32,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
@@ -58,21 +55,14 @@ require (
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/samber/lo v1.52.0 // indirect github.com/samber/lo v1.52.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect github.com/skeema/knownhosts v1.3.2 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect golang.org/x/mod v0.32.0 // indirect

26
go.sum
View File

@@ -36,10 +36,6 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -68,8 +64,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
@@ -140,8 +134,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
@@ -149,16 +141,6 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -170,22 +152,18 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.67 h1:cUpNk00Hvu9DMBI6bpF4xxwwzf3yT1n9l7D1WUvMrQ8= github.com/wailsapp/wails/v3 v3.0.0-alpha.68 h1:CBSP9rOISKiFv6hmqVj2HsU6f4bSMQmsmuSzPQMUxSE=
github.com/wailsapp/wails/v3 v3.0.0-alpha.67/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw= github.com/wailsapp/wails/v3 v3.0.0-alpha.68/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=

BIN
goreleaser/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
goreleaser/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

15
goreleaser/info.json Normal file
View File

@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "0.1.0"
},
"info": {
"0000": {
"ProductVersion": "0.1.0",
"CompanyName": "Nite",
"FileDescription": "MeshDrop - A cross-platform file transfer application",
"LegalCopyright": "© 2026, Nite",
"ProductName": "MeshDrop",
"Comments": "A cross-platform file transfer application"
}
}
}

10
goreleaser/mesh-drop.desktop Executable file
View File

@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=mesh-drop
Exec=mesh-drop
Icon=mesh-drop.png
Categories=Network;FileTransfer;
Terminal=false
Keywords=mesh,transfer,file,network,drop
Version=1.0
StartupNotify=false

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.nite07.meshdrop" version="0.1.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

View File

@@ -1,44 +1,50 @@
package config package config
import ( import (
"encoding/json"
"log/slog" "log/slog"
"mesh-drop/internal/security"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/spf13/viper"
) )
// WindowState 定义窗口状态 // WindowState 定义窗口状态
type WindowState struct { type WindowState struct {
Width int `mapstructure:"width"` Width int `json:"width"`
Height int `mapstructure:"height"` Height int `json:"height"`
X int `mapstructure:"x"`
Y int `mapstructure:"y"`
Maximised bool `mapstructure:"maximised"`
} }
const Version = "0.0.2" var Version = "next"
type Language string
const (
LanguageEnglish Language = "en"
LanguageChinese Language = "zh-Hans"
)
type configData struct {
WindowState WindowState `json:"window_state"`
ID string `json:"id"`
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"`
SavePath string `json:"save_path"`
HostName string `json:"host_name"`
AutoAccept bool `json:"auto_accept"`
SaveHistory bool `json:"save_history"`
TrustedPeer map[string]string `json:"trusted_peer"` // ID -> PublicKey
Language Language `json:"language"`
CloseToSystray bool `json:"close_to_systray"`
}
type Config struct { type Config struct {
v *viper.Viper mu sync.RWMutex
mu sync.RWMutex data configData
configPath string
WindowState WindowState `mapstructure:"window_state"`
ID string `mapstructure:"id"`
SavePath string `mapstructure:"save_path"`
HostName string `mapstructure:"host_name"`
AutoAccept bool `mapstructure:"auto_accept"`
SaveHistory bool `mapstructure:"save_history"`
}
// 默认窗口配置
var defaultWindowState = WindowState{
Width: 1024,
Height: 768,
X: -1,
Y: -1,
} }
func GetConfigDir() string { func GetConfigDir() string {
@@ -58,45 +64,74 @@ func GetUserHomeDir() string {
} }
// New 读取配置 // New 读取配置
func Load() *Config { func Load(defaultState WindowState) *Config {
v := viper.New()
configDir := GetConfigDir() configDir := GetConfigDir()
err := os.MkdirAll(configDir, 0755) _ = os.MkdirAll(configDir, 0755)
if err != nil {
slog.Error("Failed to create config directory", "error", err)
}
configFile := filepath.Join(configDir, "config.json") configFile := filepath.Join(configDir, "config.json")
// 设置默认值 // 设置默认值
defaultSavePath := filepath.Join(GetUserHomeDir(), "Downloads") defaultSavePath := filepath.Join(GetUserHomeDir(), "Downloads")
v.SetDefault("window_state", defaultWindowState)
v.SetDefault("save_path", defaultSavePath)
defaultHostName, err := os.Hostname() defaultHostName, err := os.Hostname()
if err != nil { if err != nil {
defaultHostName = "localhost" defaultHostName = "localhost"
} }
v.SetDefault("host_name", defaultHostName)
v.SetDefault("id", uuid.New().String())
v.SetDefault("save_history", true)
v.SetConfigFile(configFile) cfgData := configData{
v.SetConfigType("json") WindowState: defaultState,
SavePath: defaultSavePath,
AutoAccept: false,
SaveHistory: true,
Language: LanguageEnglish,
CloseToSystray: false,
ID: uuid.New().String(),
HostName: defaultHostName,
TrustedPeer: make(map[string]string),
}
// 尝试读取配置 fileBytes, err := os.ReadFile(configFile)
if err := v.ReadInConfig(); err != nil { if err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok { if !os.IsNotExist(err) {
slog.Info("Config file not found, using defaults") slog.Error("Failed to read config file", "error", err)
} else { } else {
slog.Warn("Failed to read config file, using defaults", "error", err) slog.Info("Config file not found, creating new one")
}
} else {
if err := json.Unmarshal(fileBytes, &cfgData); err != nil {
slog.Error("Failed to unmarshal config", "error", err)
} }
} }
var config Config config := Config{
if err := v.Unmarshal(&config); err != nil { data: cfgData,
slog.Error("Failed to unmarshal config", "error", err) configPath: configFile,
} }
config.v = v // 确保默认保存路径存在
err = os.MkdirAll(defaultSavePath, 0755)
if err != nil {
slog.Error("Failed to create default save path", "path", defaultSavePath, "error", err)
}
// 如果没有密钥对,生成新的
if config.data.PrivateKey == "" || config.data.PublicKey == "" {
priv, pub, err := security.GenerateKey()
if err != nil {
slog.Error("Failed to generate identity keys", "error", err)
} else {
config.data.PrivateKey = priv
config.data.PublicKey = pub
}
}
// 初始化 TrustedPeer map if nil
if config.data.TrustedPeer == nil {
config.data.TrustedPeer = make(map[string]string)
}
// 保存
if err := config.Save(); err != nil {
slog.Error("Failed to save config", "error", err)
}
return &config return &config
} }
@@ -105,79 +140,97 @@ func Load() *Config {
func (c *Config) Save() error { func (c *Config) Save() error {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return c.save()
}
configDir := GetConfigDir() func (c *Config) save() error {
if err := os.MkdirAll(configDir, 0755); err != nil { dir := filepath.Dir(c.configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
} }
if err := c.v.WriteConfig(); err != nil { jsonData, err := json.MarshalIndent(c.data, "", " ")
slog.Error("Failed to write config", "error", err) if err != nil {
return err return err
} }
// 设置配置文件权限为 0600 (仅所有者读写)
if c.configPath != "" {
if err := os.WriteFile(c.configPath, jsonData, 0600); err != nil {
slog.Warn("Failed to write config file", "error", err)
return err
}
}
return nil return nil
} }
// SetSavePath 修改配置 // update 是一个辅助函数,用于在锁保护下更新配置并保存
func (c *Config) SetSavePath(savePath string) { func (c *Config) update(fn func()) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.SavePath = savePath fn()
c.v.Set("save_path", savePath)
_ = os.MkdirAll(savePath, 0755) if err := c.save(); err != nil {
slog.Error("Failed to save config", "error", err)
}
}
// SetSavePath 修改配置
func (c *Config) SetSavePath(savePath string) {
c.update(func() {
c.data.SavePath = savePath
_ = os.MkdirAll(savePath, 0755)
})
} }
func (c *Config) GetSavePath() string { func (c *Config) GetSavePath() string {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return c.SavePath return c.data.SavePath
} }
func (c *Config) SetHostName(hostName string) { func (c *Config) SetHostName(hostName string) {
c.mu.Lock() c.update(func() {
defer c.mu.Unlock() c.data.HostName = hostName
c.HostName = hostName })
c.v.Set("host_name", hostName)
} }
func (c *Config) GetHostName() string { func (c *Config) GetHostName() string {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return c.HostName return c.data.HostName
} }
func (c *Config) GetID() string { func (c *Config) GetID() string {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return c.ID return c.data.ID
} }
func (c *Config) SetAutoAccept(autoAccept bool) { func (c *Config) SetAutoAccept(autoAccept bool) {
c.mu.Lock() c.update(func() {
defer c.mu.Unlock() c.data.AutoAccept = autoAccept
c.AutoAccept = autoAccept })
c.v.Set("auto_accept", autoAccept)
} }
func (c *Config) GetAutoAccept() bool { func (c *Config) GetAutoAccept() bool {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return c.AutoAccept return c.data.AutoAccept
} }
func (c *Config) SetSaveHistory(saveHistory bool) { func (c *Config) SetSaveHistory(saveHistory bool) {
c.mu.Lock() c.update(func() {
defer c.mu.Unlock() c.data.SaveHistory = saveHistory
c.SaveHistory = saveHistory })
c.v.Set("save_history", saveHistory)
} }
func (c *Config) GetSaveHistory() bool { func (c *Config) GetSaveHistory() bool {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return c.SaveHistory return c.data.SaveHistory
} }
func (c *Config) GetVersion() string { func (c *Config) GetVersion() string {
@@ -185,14 +238,77 @@ func (c *Config) GetVersion() string {
} }
func (c *Config) SetWindowState(state WindowState) { func (c *Config) SetWindowState(state WindowState) {
c.mu.Lock() c.update(func() {
defer c.mu.Unlock() c.data.WindowState = state
c.WindowState = state })
c.v.Set("window_state", state)
} }
func (c *Config) GetWindowState() WindowState { func (c *Config) GetWindowState() WindowState {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
return c.WindowState return c.data.WindowState
}
func (c *Config) AddTrust(peerID string, publicKey string) {
c.update(func() {
if c.data.TrustedPeer == nil {
c.data.TrustedPeer = make(map[string]string)
}
c.data.TrustedPeer[peerID] = publicKey
})
}
func (c *Config) GetTrusted() map[string]string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data.TrustedPeer
}
func (c *Config) RemoveTrust(peerID string) {
c.update(func() {
delete(c.data.TrustedPeer, peerID)
})
}
func (c *Config) IsTrusted(peerID string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
_, exists := c.data.TrustedPeer[peerID]
return exists
}
func (c *Config) SetLanguage(language Language) {
c.update(func() {
c.data.Language = language
})
}
func (c *Config) GetLanguage() Language {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data.Language
}
func (c *Config) SetCloseToSystray(closeToSystray bool) {
c.update(func() {
c.data.CloseToSystray = closeToSystray
})
}
func (c *Config) GetCloseToSystray() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data.CloseToSystray
}
func (c *Config) GetPrivateKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data.PrivateKey
}
func (c *Config) GetPublicKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data.PublicKey
} }

View File

@@ -1,6 +1,9 @@
package discovery package discovery
import "time" import (
"fmt"
"time"
)
// Peer 代表一个可达的网络端点 (Network Endpoint)。 // Peer 代表一个可达的网络端点 (Network Endpoint)。
// 注意:一个物理设备 (Device) 可能通过多个网络接口广播,因此会对应多个 Peer 结构体。 // 注意:一个物理设备 (Device) 可能通过多个网络接口广播,因此会对应多个 Peer 结构体。
@@ -20,6 +23,12 @@ type Peer struct {
Port int `json:"port"` Port int `json:"port"`
OS OS `json:"os"` OS OS `json:"os"`
PublicKey string `json:"pk"`
// TrustMismatch 指示该节点的公钥与本地信任列表中的公钥不匹配
// 如果为 true说明可能存在 ID 欺骗或密钥轮换
TrustMismatch bool `json:"trust_mismatch"`
} }
// RouteState 记录单条路径的状态 // RouteState 记录单条路径的状态
@@ -38,8 +47,34 @@ const (
// PresencePacket 是 UDP 广播的载荷 // PresencePacket 是 UDP 广播的载荷
type PresencePacket struct { type PresencePacket struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Port int `json:"port"` Port int `json:"port"`
OS OS `json:"os"` OS OS `json:"os"`
PublicKey string `json:"pk"`
Signature string `json:"sig"`
}
// SignPayload 生成用于签名的确定性数据
func (p *PresencePacket) SignPayload() []byte {
// 使用固定格式拼接字段,避免 JSON 序列化的不确定性
// 格式: id|name|port|os|pk
return fmt.Appendf(nil, "%s|%s|%d|%s|%s", p.ID, p.Name, p.Port, p.OS, p.PublicKey)
}
// DeepCopy 返回 Peer 的深拷贝
func (p Peer) DeepCopy() *Peer {
newPeer := p // 结构体浅拷贝 (值类型字段已复制)
// 手动深拷贝引用类型字段 (Routes)
if p.Routes != nil {
newPeer.Routes = make(map[string]*RouteState, len(p.Routes))
for k, v := range p.Routes {
// RouteState 只有值类型字段,但它是指针,所以需要新建对象并解引用赋值
stateCopy := *v
newPeer.Routes[k] = &stateCopy
}
}
return &newPeer
} }

View File

@@ -5,8 +5,10 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"mesh-drop/internal/config" "mesh-drop/internal/config"
"mesh-drop/internal/security"
"net" "net"
"runtime" "runtime"
"sort"
"sync" "sync"
"time" "time"
@@ -15,8 +17,8 @@ import (
const ( const (
DiscoveryPort = 9988 DiscoveryPort = 9988
HeartbeatRate = 3 * time.Second HeartbeatRate = 1 * time.Second
PeerTimeout = 10 * time.Second PeerTimeout = 2 * time.Second
) )
type Service struct { type Service struct {
@@ -26,13 +28,11 @@ type Service struct {
config *config.Config config *config.Config
FileServerPort int FileServerPort int
// key 使用 peer.id 和 peer.ip 组合而成的 hash // Key: peer.ID
peers map[string]*Peer peers map[string]*Peer
peersMutex sync.RWMutex peersMutex sync.RWMutex
}
func init() { self Peer
application.RegisterEvent[[]Peer]("peers:update")
} }
func NewService(config *config.Config, app *application.App, port int) *Service { func NewService(config *config.Config, app *application.App, port int) *Service {
@@ -42,10 +42,17 @@ func NewService(config *config.Config, app *application.App, port int) *Service
config: config, config: config,
FileServerPort: port, FileServerPort: port,
peers: make(map[string]*Peer), peers: make(map[string]*Peer),
self: Peer{
ID: config.GetID(),
Name: config.GetHostName(),
Port: port,
OS: OS(runtime.GOOS),
PublicKey: config.GetPublicKey(),
},
} }
} }
func (s *Service) GetLocalIPs() ([]string, bool) { func GetLocalIPs() ([]string, bool) {
interfaces, err := net.Interfaces() interfaces, err := net.Interfaces()
if err != nil { if err != nil {
slog.Error("Failed to get network interfaces", "error", err, "component", "discovery") slog.Error("Failed to get network interfaces", "error", err, "component", "discovery")
@@ -118,11 +125,22 @@ func (s *Service) startBroadcasting() {
continue continue
} }
packet := PresencePacket{ packet := PresencePacket{
ID: s.ID, ID: s.ID,
Name: s.config.GetHostName(), Name: s.config.GetHostName(),
Port: s.FileServerPort, Port: s.FileServerPort,
OS: OS(runtime.GOOS), OS: OS(runtime.GOOS),
PublicKey: s.config.GetPublicKey(),
} }
// 签名
sigData := packet.SignPayload()
sig, err := security.Sign(s.config.GetPrivateKey(), sigData)
if err != nil {
slog.Error("Failed to sign discovery packet", "error", err)
continue
}
packet.Signature = sig
data, _ := json.Marshal(packet) data, _ := json.Marshal(packet)
for _, iface := range interfaces { for _, iface := range interfaces {
// 过滤掉 Down 的接口和 Loopback 接口 // 过滤掉 Down 的接口和 Loopback 接口
@@ -199,12 +217,40 @@ func (s *Service) startListening() {
continue continue
} }
s.handleHeartbeat(packet, remoteAddr.IP.String()) // 验证签名
sig := packet.Signature
sigData := packet.SignPayload()
valid, err := security.Verify(packet.PublicKey, sigData, sig)
if err != nil || !valid {
slog.Warn("Received invalid discovery packet signature", "id", packet.ID, "ip", remoteAddr.IP.String())
continue
}
// 验证身份一致性 (防止 ID 欺骗)
trustMismatch := false
trustedKeys := s.config.GetTrusted()
if knownKey, ok := trustedKeys[packet.ID]; ok {
if knownKey != packet.PublicKey {
slog.Warn("SECURITY ALERT: Peer ID mismatch with known public key (Spoofing attempt?)", "id", packet.ID, "known_key", knownKey, "received_key", packet.PublicKey)
trustMismatch = true
// 当发现 ID 欺骗时,不更新 peer而是标记为 trustMismatch
// 用户可以手动重新添加信任
}
} else {
// 不存在于信任列表
// 存在之前在信任列表,但是不匹配被用户手动重置了,此时需要将 peer.TrustMismatch 标记为 false
// 否则在 handleHeartbeat 里会一直标记为不匹配
if peer, ok := s.peers[packet.ID]; ok {
peer.TrustMismatch = false
}
}
s.handleHeartbeat(packet, remoteAddr.IP.String(), trustMismatch)
} }
} }
// handleHeartbeat 处理心跳包 // handleHeartbeat 处理心跳包
func (s *Service) handleHeartbeat(pkt PresencePacket, ip string) { func (s *Service) handleHeartbeat(pkt PresencePacket, ip string, trustMismatch bool) {
s.peersMutex.Lock() s.peersMutex.Lock()
peer, exists := s.peers[pkt.ID] peer, exists := s.peers[pkt.ID]
@@ -219,19 +265,27 @@ func (s *Service) handleHeartbeat(pkt PresencePacket, ip string) {
LastSeen: time.Now(), LastSeen: time.Now(),
}, },
}, },
Port: pkt.Port, Port: pkt.Port,
OS: pkt.OS, OS: pkt.OS,
PublicKey: pkt.PublicKey,
TrustMismatch: trustMismatch,
} }
s.peers[peer.ID] = peer s.peers[peer.ID] = peer
slog.Info("New device found", "name", pkt.Name, "ip", ip, "component", "discovery") slog.Info("New device found", "name", pkt.Name, "ip", ip, "component", "discovery")
} else { } else {
// 更新节点 // 更新节点
peer.Name = pkt.Name // 只有在没有身份不匹配的情况下才更新元数据,防止欺骗攻击导致 UI 闪烁/篡改
peer.OS = pkt.OS if !trustMismatch {
peer.Name = pkt.Name
peer.OS = pkt.OS
peer.PublicKey = pkt.PublicKey
}
peer.Routes[ip] = &RouteState{ peer.Routes[ip] = &RouteState{
IP: ip, IP: ip,
LastSeen: time.Now(), LastSeen: time.Now(),
} }
// 如果之前存在不匹配,即使这次匹配了,也不要重置,防止欺骗攻击
peer.TrustMismatch = peer.TrustMismatch || trustMismatch
} }
s.peersMutex.Unlock() s.peersMutex.Unlock()
@@ -250,7 +304,6 @@ func (s *Service) startCleanup() {
for id, peer := range s.peers { for id, peer := range s.peers {
for ip, route := range peer.Routes { for ip, route := range peer.Routes {
// 超过10秒没心跳认为下线
if now.Sub(route.LastSeen) > PeerTimeout { if now.Sub(route.LastSeen) > PeerTimeout {
delete(peer.Routes, ip) delete(peer.Routes, ip)
changed = true changed = true
@@ -278,16 +331,27 @@ func (s *Service) Start() {
go s.startCleanup() go s.startCleanup()
} }
func (s *Service) GetPeerByIP(ip string) *Peer { func (s *Service) GetPeerByIP(ip string) (*Peer, bool) {
s.peersMutex.RLock() s.peersMutex.RLock()
defer s.peersMutex.RUnlock() defer s.peersMutex.RUnlock()
for _, p := range s.peers { for _, p := range s.peers {
if p.Routes[ip] != nil { if p.Routes[ip] != nil {
return p return p.DeepCopy(), true
} }
} }
return nil return nil, false
}
func (s *Service) GetPeerByID(id string) (*Peer, bool) {
s.peersMutex.RLock()
defer s.peersMutex.RUnlock()
peer, ok := s.peers[id]
if !ok {
return nil, false
}
return peer.DeepCopy(), true
} }
func (s *Service) GetPeers() []Peer { func (s *Service) GetPeers() []Peer {
@@ -296,11 +360,18 @@ func (s *Service) GetPeers() []Peer {
list := make([]Peer, 0) list := make([]Peer, 0)
for _, p := range s.peers { for _, p := range s.peers {
list = append(list, *p) list = append(list, *p.DeepCopy())
} }
sort.Slice(list, func(i, j int) bool {
return list[i].Name < list[j].Name
})
return list return list
} }
func (s *Service) GetID() string { func (s *Service) GetID() string {
return s.ID return s.ID
} }
func (s *Service) GetSelf() Peer {
return s.self
}

View File

@@ -0,0 +1,56 @@
package security
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"fmt"
)
// GenerateKey 生成新的 Ed25519 密钥对
// 返回 base64 编码的私钥和公钥
func GenerateKey() (string, string, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", "", err
}
return base64.StdEncoding.EncodeToString(priv), base64.StdEncoding.EncodeToString(pub), nil
}
// Sign 使用私钥对数据进行签名
// privKeyStr: base64 编码的私钥
// data: 要签名的数据
// 返回: base64 编码的签名
func Sign(privKeyStr string, data []byte) (string, error) {
privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyStr)
if err != nil {
return "", fmt.Errorf("invalid private key: %w", err)
}
if len(privKeyBytes) != ed25519.PrivateKeySize {
return "", fmt.Errorf("invalid private key length")
}
signature := ed25519.Sign(ed25519.PrivateKey(privKeyBytes), data)
return base64.StdEncoding.EncodeToString(signature), nil
}
// Verify 使用公钥验证签名
// pubKeyStr: base64 编码的公钥
// data: 原始数据
// sigStr: base64 编码的签名
func Verify(pubKeyStr string, data []byte, sigStr string) (bool, error) {
pubKeyBytes, err := base64.StdEncoding.DecodeString(pubKeyStr)
if err != nil {
return false, fmt.Errorf("invalid public key: %w", err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return false, fmt.Errorf("invalid public key length")
}
sigBytes, err := base64.StdEncoding.DecodeString(sigStr)
if err != nil {
return false, fmt.Errorf("invalid signature: %w", err)
}
return ed25519.Verify(ed25519.PublicKey(pubKeyBytes), data, sigBytes), nil
}

View File

@@ -35,7 +35,6 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
slog.Error("Failed to open file", "path", filePath, "error", err, "component", "transfer-client") slog.Error("Failed to open file", "path", filePath, "error", err, "component", "transfer-client")
return return
} }
defer file.Close()
stat, err := file.Stat() stat, err := file.Stat()
if err != nil { if err != nil {
@@ -44,11 +43,7 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
task := NewTransfer( task := NewTransfer(
taskID, taskID,
NewSender( s.discoveryService.GetSelf(),
s.discoveryService.GetID(),
s.config.GetHostName(),
WithReceiverIP(targetIP, s.discoveryService),
),
WithFileName(filepath.Base(filePath)), WithFileName(filepath.Base(filePath)),
WithFileSize(stat.Size()), WithFileSize(stat.Size()),
WithType(TransferTypeSend), WithType(TransferTypeSend),
@@ -58,6 +53,7 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
s.StoreTransferToList(task) s.StoreTransferToList(task)
go func() { go func() {
defer file.Close()
// 任务结束后清理 ctx // 任务结束后清理 ctx
defer func() { defer func() {
s.cancelMap.Delete(taskID) s.cancelMap.Delete(taskID)
@@ -111,11 +107,7 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
task := NewTransfer( task := NewTransfer(
taskID, taskID,
NewSender( s.discoveryService.GetSelf(),
s.discoveryService.GetID(),
s.config.GetHostName(),
WithReceiverIP(targetIP, s.discoveryService),
),
WithFileName(filepath.Base(folderPath)), WithFileName(filepath.Base(folderPath)),
WithFileSize(size), WithFileSize(size),
WithType(TransferTypeSend), WithType(TransferTypeSend),
@@ -164,11 +156,7 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
r := bytes.NewReader([]byte(text)) r := bytes.NewReader([]byte(text))
task := NewTransfer( task := NewTransfer(
taskID, taskID,
NewSender( s.discoveryService.GetSelf(),
s.discoveryService.GetID(),
s.config.GetHostName(),
WithReceiverIP(targetIP, s.discoveryService),
),
WithFileSize(int64(len(text))), WithFileSize(int64(len(text))),
WithType(TransferTypeSend), WithType(TransferTypeSend),
WithContentType(ContentTypeText), WithContentType(ContentTypeText),

View File

@@ -9,28 +9,35 @@ import (
) )
func (s *Service) SaveHistory() { func (s *Service) SaveHistory() {
// 将 pending 状态的任务改为 canceled if !s.config.GetSaveHistory() {
transferList := s.GetTransferList() return
for _, task := range transferList {
if task.Status == TransferStatusPending {
task.Status = TransferStatusCanceled
}
} }
configDir := config.GetConfigDir() configDir := config.GetConfigDir()
historyPath := filepath.Join(configDir, "history.json") historyPath := filepath.Join(configDir, "history.json")
historyJson, err := json.Marshal(transferList) tempPath := historyPath + ".tmp"
// 序列化传输列表
historyJson, err := json.MarshalIndent(s.GetTransferList(), "", " ")
if err != nil { if err != nil {
slog.Error("Failed to marshal history", "error", err, "component", "transfer")
return return
} }
file, err := os.OpenFile(historyPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil { // 写入临时文件
if err := os.WriteFile(tempPath, historyJson, 0644); err != nil {
slog.Error("Failed to write temp history file", "error", err, "component", "transfer")
return return
} }
defer file.Close()
_, err = file.Write(historyJson) // 原子性重命名
if err != nil { if err := os.Rename(tempPath, historyPath); err != nil {
slog.Error("Failed to write history", "error", err) slog.Error("Failed to rename temp history file", "error", err, "component", "transfer")
// 清理临时文件
_ = os.Remove(tempPath)
return
} }
slog.Info("History saved successfully", "path", historyPath, "component", "transfer")
} }
func (s *Service) LoadHistory() { func (s *Service) LoadHistory() {

View File

@@ -1,7 +1,6 @@
package transfer package transfer
import ( import (
"log/slog"
"mesh-drop/internal/discovery" "mesh-drop/internal/discovery"
"time" "time"
) )
@@ -35,25 +34,26 @@ const (
// Transfer // Transfer
type Transfer struct { type Transfer struct {
ID string `json:"id" binding:"required"` // 传输会话 ID ID string `json:"id" binding:"required"` // 传输会话 ID
CreateTime int64 `json:"create_time"` // 创建时间 CreateTime int64 `json:"create_time"` // 创建时间
Sender Sender `json:"sender" binding:"required"` // 发送者 Sender discovery.Peer `json:"sender" binding:"required"` // 发送者
FileName string `json:"file_name"` // 文件名 // FileName 如果 ContentType 为 file文件名如果 ContentType 为 folder文件夹名如果 ContentType 为 text
FileSize int64 `json:"file_size"` // 文件大小 (字节) FileName string `json:"file_name"` // 文件
SavePath string `json:"savePath"` // 保存路径 FileSize int64 `json:"file_size"` // 文件大小 (字节)
Status TransferStatus `json:"status"` // 传输状态 SavePath string `json:"savePath"` // 保存路径
Progress Progress `json:"progress"` // 传输进度 Status TransferStatus `json:"status"` // 传输状态
Type TransferType `json:"type"` // 进度类型 Progress Progress `json:"progress"` // 传输进度
ContentType ContentType `json:"content_type"` // 内容类型 Type TransferType `json:"type"` // 进度类型
Text string `json:"text"` // 文本内容 ContentType ContentType `json:"content_type"` // 内容类型
ErrorMsg string `json:"error_msg"` // 错误信息 Text string `json:"text"` // 文本内容
Token string `json:"token"` // 用于上传的凭证 ErrorMsg string `json:"error_msg"` // 错误信息
DecisionChan chan Decision `json:"-"` // 用户决策通道 Token string `json:"token"` // 用于上传的凭证
DecisionChan chan Decision `json:"-"` // 用户决策通道
} }
type TransferOption func(*Transfer) type TransferOption func(*Transfer)
func NewTransfer(id string, sender Sender, opts ...TransferOption) *Transfer { func NewTransfer(id string, sender discovery.Peer, opts ...TransferOption) *Transfer {
t := &Transfer{ t := &Transfer{
ID: id, ID: id,
CreateTime: time.Now().UnixMilli(), CreateTime: time.Now().UnixMilli(),
@@ -122,41 +122,6 @@ func WithToken(token string) TransferOption {
} }
} }
type Sender struct {
ID string `json:"id" binding:"required"` // 发送者 ID
Name string `json:"name" binding:"required"` // 发送者名称
IP string `json:"ip" binding:"required"` // 发送者 IP
}
type NewSenderOption func(*Sender)
func NewSender(id string, name string, opts ...NewSenderOption) Sender {
s := &Sender{
ID: id,
Name: name,
}
for _, opt := range opts {
opt(s)
}
return *s
}
func WithIP(ip string) NewSenderOption {
return func(s *Sender) {
s.IP = ip
}
}
func WithReceiverIP(ip string, discoveryService *discovery.Service) NewSenderOption {
return func(s *Sender) {
ip, ok := discoveryService.GetLocalIPInSameSubnet(ip)
if !ok {
slog.Error("Failed to get local IP in same subnet", "ip", ip, "component", "transfer-client")
}
s.IP = ip
}
}
// Progress 用户前端传输进度 // Progress 用户前端传输进度
type Progress struct { type Progress struct {
Current int64 `json:"current"` // 当前进度 Current int64 `json:"current"` // 当前进度

View File

@@ -32,7 +32,7 @@ func (s *Service) handleAsk(c *gin.Context) {
} }
// 检查是否已经存在 // 检查是否已经存在
if _, exists := s.transferList.Load(task.ID); exists { if _, exists := s.transfers.Load(task.ID); exists {
// 如果已经存在,说明是网络重试,直接忽略 // 如果已经存在,说明是网络重试,直接忽略
return return
} }
@@ -43,7 +43,13 @@ func (s *Service) handleAsk(c *gin.Context) {
task.DecisionChan = make(chan Decision, 1) task.DecisionChan = make(chan Decision, 1)
s.StoreTransferToList(&task) s.StoreTransferToList(&task)
if s.config.GetAutoAccept() { // 从本地获取 peer 检查是否 mismatch
peer, ok := s.discoveryService.GetPeerByID(task.Sender.ID)
if ok {
task.Sender.TrustMismatch = peer.TrustMismatch
}
if s.config.GetAutoAccept() || (s.config.IsTrusted(task.Sender.ID) && !task.Sender.TrustMismatch) {
task.DecisionChan <- Decision{ task.DecisionChan <- Decision{
ID: task.ID, ID: task.ID,
Accepted: true, Accepted: true,
@@ -54,7 +60,7 @@ func (s *Service) handleAsk(c *gin.Context) {
_ = s.notifier.SendNotification(notifications.NotificationOptions{ _ = s.notifier.SendNotification(notifications.NotificationOptions{
ID: uuid.New().String(), ID: uuid.New().String(),
Title: "File Transfer Request", Title: "File Transfer Request",
Body: fmt.Sprintf("%s(%s) wants to transfer %s", task.Sender.Name, task.Sender.IP, task.FileName), Body: fmt.Sprintf("%s wants to transfer %s", task.Sender.Name, task.FileName),
}) })
} }
@@ -74,6 +80,11 @@ func (s *Service) handleAsk(c *gin.Context) {
}) })
} else { } else {
task.Status = TransferStatusRejected task.Status = TransferStatusRejected
c.JSON(http.StatusOK, TransferAskResponse{
ID: task.ID,
Accepted: false,
Message: "Transfer rejected",
})
} }
case <-c.Request.Context().Done(): case <-c.Request.Context().Done():
// 发送端放弃 // 发送端放弃
@@ -164,6 +175,14 @@ func (s *Service) handleUpload(c *gin.Context) {
switch task.ContentType { switch task.ContentType {
case ContentTypeFile: case ContentTypeFile:
destPath := filepath.Join(savePath, task.FileName) destPath := filepath.Join(savePath, task.FileName)
// 如果文件已存在则在文件名后追加序号
_, err := os.Stat(destPath)
counter := 1
for err == nil {
destPath = filepath.Join(savePath, fmt.Sprintf("%s (%d)%s", strings.TrimSuffix(task.FileName, filepath.Ext(task.FileName)), counter, filepath.Ext(task.FileName)))
counter++
_, err = os.Stat(destPath)
}
file, err := os.Create(destPath) file, err := os.Create(destPath)
if err != nil { if err != nil {
// 接收方无法创建文件,直接报错,任务结束 // 接收方无法创建文件,直接报错,任务结束
@@ -178,17 +197,17 @@ func (s *Service) handleUpload(c *gin.Context) {
return return
} }
defer file.Close() defer file.Close()
s.receive(c, task, file, ctxReader) s.receive(c, task, Writer{w: file, filePath: destPath}, ctxReader)
case ContentTypeText: case ContentTypeText:
var buf bytes.Buffer var buf bytes.Buffer
s.receive(c, task, &buf, ctxReader) s.receive(c, task, Writer{w: &buf, filePath: ""}, ctxReader)
task.Text = buf.String() task.Text = buf.String()
case ContentTypeFolder: case ContentTypeFolder:
s.receiveFolder(c, savePath, task, ctxReader) s.receiveFolder(c, savePath, task, ctxReader)
} }
} }
func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxReader io.Reader) { func (s *Service) receive(c *gin.Context, task *Transfer, writer Writer, ctxReader io.Reader) {
// 包装 reader用于计算进度 // 包装 reader用于计算进度
reader := &PassThroughReader{ reader := &PassThroughReader{
Reader: ctxReader, Reader: ctxReader,
@@ -237,6 +256,11 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
slog.Error("Failed to write file", "error", err, "component", "transfer") slog.Error("Failed to write file", "error", err, "component", "transfer")
task.Status = TransferStatusError task.Status = TransferStatusError
task.ErrorMsg = fmt.Errorf("failed to write file: %v", err).Error() task.ErrorMsg = fmt.Errorf("failed to write file: %v", err).Error()
// 删除文件
if task.ContentType == ContentTypeFile && writer.GetFilePath() != "" {
_ = os.Remove(writer.GetFilePath())
}
return return
} }
@@ -254,6 +278,14 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
// 创建根目录 // 创建根目录
destPath := filepath.Join(savePath, task.FileName) destPath := filepath.Join(savePath, task.FileName)
// 如果文件已存在则在文件名后追加序号
_, err := os.Stat(destPath)
counter := 1
for err == nil {
destPath = filepath.Join(savePath, fmt.Sprintf("%s (%d)", task.FileName, counter))
counter++
_, err = os.Stat(destPath)
}
if err := os.MkdirAll(destPath, 0755); err != nil { if err := os.MkdirAll(destPath, 0755); err != nil {
c.JSON(http.StatusInternalServerError, TransferUploadResponse{ c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID, ID: task.ID,

View File

@@ -26,7 +26,7 @@ type Service struct {
// pendingRequests 存储等待用户确认的通道 // pendingRequests 存储等待用户确认的通道
// Key: TransferID, Value: *Transfer // Key: TransferID, Value: *Transfer
transferList sync.Map transfers sync.Map
discoveryService *discovery.Service discoveryService *discovery.Service
@@ -59,10 +59,6 @@ func NewService(config *config.Config, app *application.App, notifier *notificat
} }
} }
func init() {
application.RegisterEvent[application.Void]("transfer:refreshList")
}
func (s *Service) GetPort() int { func (s *Service) GetPort() int {
return s.port return s.port
} }
@@ -94,9 +90,13 @@ func (s *Service) Start() {
}() }()
} }
func (s *Service) GetTransferSyncMap() *sync.Map {
return &s.transfers
}
func (s *Service) GetTransferList() []*Transfer { func (s *Service) GetTransferList() []*Transfer {
var requests []*Transfer = make([]*Transfer, 0) var requests []*Transfer = make([]*Transfer, 0)
s.transferList.Range(func(key, value any) bool { s.transfers.Range(func(key, value any) bool {
transfer := value.(*Transfer) transfer := value.(*Transfer)
requests = append(requests, transfer) requests = append(requests, transfer)
return true return true
@@ -109,7 +109,7 @@ func (s *Service) GetTransferList() []*Transfer {
} }
func (s *Service) GetTransfer(transferID string) (*Transfer, bool) { func (s *Service) GetTransfer(transferID string) (*Transfer, bool) {
val, ok := s.transferList.Load(transferID) val, ok := s.transfers.Load(transferID)
if !ok { if !ok {
return nil, false return nil, false
} }
@@ -130,13 +130,13 @@ func (s *Service) CancelTransfer(transferID string) {
func (s *Service) StoreTransfersToList(transfers []*Transfer) { func (s *Service) StoreTransfersToList(transfers []*Transfer) {
for _, transfer := range transfers { for _, transfer := range transfers {
s.transferList.Store(transfer.ID, transfer) s.transfers.Store(transfer.ID, transfer)
} }
s.NotifyTransferListUpdate() s.NotifyTransferListUpdate()
} }
func (s *Service) StoreTransferToList(transfer *Transfer) { func (s *Service) StoreTransferToList(transfer *Transfer) {
s.transferList.Store(transfer.ID, transfer) s.transfers.Store(transfer.ID, transfer)
s.NotifyTransferListUpdate() s.NotifyTransferListUpdate()
} }
@@ -146,13 +146,13 @@ func (s *Service) NotifyTransferListUpdate() {
// CleanTransferList 清理完成的 transfer // CleanTransferList 清理完成的 transfer
func (s *Service) CleanFinishedTransferList() { func (s *Service) CleanFinishedTransferList() {
s.transferList.Range(func(key, value any) bool { s.transfers.Range(func(key, value any) bool {
task := value.(*Transfer) task := value.(*Transfer)
if task.Status == TransferStatusCompleted || if task.Status == TransferStatusCompleted ||
task.Status == TransferStatusError || task.Status == TransferStatusError ||
task.Status == TransferStatusCanceled || task.Status == TransferStatusCanceled ||
task.Status == TransferStatusRejected { task.Status == TransferStatusRejected {
s.transferList.Delete(key) s.transfers.Delete(key)
} }
return true return true
}) })
@@ -160,6 +160,6 @@ func (s *Service) CleanFinishedTransferList() {
} }
func (s *Service) DeleteTransfer(transferID string) { func (s *Service) DeleteTransfer(transferID string) {
s.transferList.Delete(transferID) s.transfers.Delete(transferID)
s.NotifyTransferListUpdate() s.NotifyTransferListUpdate()
} }

View File

@@ -0,0 +1,16 @@
package transfer
import "io"
type Writer struct {
w io.Writer
filePath string
}
func (w Writer) Write(p []byte) (n int, err error) {
return w.w.Write(p)
}
func (w Writer) GetFilePath() string {
return w.filePath
}

211
main.go
View File

@@ -7,6 +7,7 @@ import (
"mesh-drop/internal/discovery" "mesh-drop/internal/discovery"
"mesh-drop/internal/transfer" "mesh-drop/internal/transfer"
"os" "os"
"path/filepath"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events" "github.com/wailsapp/wails/v3/pkg/events"
@@ -16,31 +17,85 @@ import (
//go:embed all:frontend/dist //go:embed all:frontend/dist
var assets embed.FS var assets embed.FS
type FilesDroppedEvent struct { //go:embed build/appicon.png
Files []string `json:"files"` var icon []byte
Target string `json:"target"`
type File struct {
Name string `json:"name"`
Path string `json:"path"`
} }
func main() { type FilesDroppedEvent struct {
conf := config.Load() Files []File `json:"files"`
Target string `json:"target"`
}
type App struct {
app *application.App
mainWindows *application.WebviewWindow
conf *config.Config
discoveryService *discovery.Service
transferService *transfer.Service
notifier *notifications.NotificationService
}
func init() {
// 设置日志
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
slog.SetDefault(logger)
}
func NewApp() *App {
app := application.New(application.Options{ app := application.New(application.Options{
Name: "mesh-drop", Name: "MeshDrop",
Assets: application.AssetOptions{ Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets), Handler: application.AssetFileServerFS(assets),
}, },
SingleInstance: &application.SingleInstanceOptions{ SingleInstance: &application.SingleInstanceOptions{
UniqueID: "com.nite07.mesh-drop", UniqueID: "com.nite07.mesh-drop",
}, },
Icon: icon,
}) })
// 创建保存路径 // 获取默认屏幕大小
err := os.MkdirAll(conf.SavePath, 0755) defaultWidth := 1024
if err != nil { defaultHeight := 768
slog.Error("Failed to create save path", "path", conf.SavePath, "error", err)
screen := app.Screen.GetPrimary()
if screen != nil {
defaultWidth = int(float64(screen.Size.Width) * 0.8)
defaultHeight = int(float64(screen.Size.Height) * 0.8)
slog.Info("Primary screen found", "width", screen.Size.Width, "height", screen.Size.Height, "defaultWidth", defaultWidth, "defaultHeight", defaultHeight)
} else {
slog.Info("No primary screen found, using defaults")
} }
// 通知 conf := config.Load(config.WindowState{
Width: defaultWidth,
Height: defaultHeight,
})
win := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "MeshDrop",
Width: conf.GetWindowState().Width,
Height: conf.GetWindowState().Height,
EnableFileDrop: true,
Linux: application.LinuxWindow{
WebviewGpuPolicy: application.WebviewGpuPolicyAlways,
},
})
return &App{
app: app,
mainWindows: win,
conf: conf,
}
}
func (a *App) registerServices() {
// 初始化通知服务
notifier := notifications.New() notifier := notifications.New()
authorized, err := notifier.RequestNotificationAuthorization() authorized, err := notifier.RequestNotificationAuthorization()
if err != nil { if err != nil {
@@ -53,78 +108,120 @@ func main() {
port := 9989 port := 9989
// 初始化发现服务 // 初始化发现服务
discoveryService := discovery.NewService(conf, app, port) discoveryService := discovery.NewService(a.conf, a.app, port)
discoveryService.Start() discoveryService.Start()
// 初始化传输服务 // 初始化传输服务
transferService := transfer.NewService(conf, app, notifier, port, discoveryService) transferService := transfer.NewService(a.conf, a.app, notifier, port, discoveryService)
transferService.Start() transferService.Start()
// 加载传输历史 // 加载传输历史
if conf.GetSaveHistory() { if a.conf.GetSaveHistory() {
transferService.LoadHistory() transferService.LoadHistory()
} }
slog.Info("Backend Service Started", "discovery_port", discovery.DiscoveryPort, "transfer_port", port) a.discoveryService = discoveryService
a.transferService = transferService
a.notifier = notifier
app.RegisterService(application.NewService(discoveryService)) a.app.RegisterService(application.NewService(discoveryService))
app.RegisterService(application.NewService(transferService)) a.app.RegisterService(application.NewService(transferService))
app.RegisterService(application.NewService(conf)) a.app.RegisterService(application.NewService(a.conf))
app.RegisterService(application.NewService(notifier)) a.app.RegisterService(application.NewService(notifier))
}
window := app.Window.NewWithOptions(application.WebviewWindowOptions{ func (a *App) registerCustomEvents() {
Title: "mesh drop", application.RegisterEvent[FilesDroppedEvent]("files-dropped")
Width: conf.WindowState.Width, application.RegisterEvent[[]discovery.Peer]("peers:update")
Height: conf.WindowState.Height, application.RegisterEvent[application.Void]("transfer:refreshList")
X: conf.WindowState.X, }
Y: conf.WindowState.Y,
EnableFileDrop: true,
Linux: application.LinuxWindow{
WebviewGpuPolicy: application.WebviewGpuPolicyAlways,
},
})
func (a *App) setupEvents() {
// 窗口文件拖拽事件 // 窗口文件拖拽事件
window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) { a.mainWindows.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
files := event.Context().DroppedFiles() files := make([]File, 0)
for _, file := range event.Context().DroppedFiles() {
files = append(files, File{
Name: filepath.Base(file),
Path: file,
})
}
details := event.Context().DropTargetDetails() details := event.Context().DropTargetDetails()
app.Event.Emit("files-dropped", FilesDroppedEvent{ a.app.Event.Emit("files-dropped", FilesDroppedEvent{
Files: files, Files: files,
Target: details.ElementID, Target: details.ElementID,
}) })
}) })
// 窗口关闭事件 // 窗口关闭事件
window.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) { a.mainWindows.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) {
x, y := window.Position() if a.conf.GetCloseToSystray() {
width, height := window.Size() event.Cancel()
conf.SetWindowState(config.WindowState{ a.mainWindows.Hide()
X: x, return
Y: y,
Width: width,
Height: height,
})
// 保存传输历史
if conf.GetSaveHistory() {
transferService.SaveHistory()
} }
// 保存配置 w, h := a.mainWindows.Size()
err := conf.Save()
if err != nil { a.conf.SetWindowState(config.WindowState{
slog.Error("Failed to save config", "error", err) Width: w,
Height: h,
})
slog.Info("Window closed", "width", w, "height", h)
})
// 应用关闭事件
a.app.OnShutdown(func() {
// 保存传输历史
if a.conf.GetSaveHistory() {
// 将 pending 状态的任务改为 canceled
a.transferService.GetTransferSyncMap().Range(func(key, value any) bool {
t := value.(*transfer.Transfer)
if t.Status == transfer.TransferStatusPending {
t.Status = transfer.TransferStatusCanceled
}
return true
})
// 保存传输历史
a.transferService.SaveHistory()
}
})
}
func (a *App) setupSystray() {
systray := a.app.SystemTray.New()
systray.SetIcon(icon)
systray.SetLabel("Mesh Drop")
menu := a.app.NewMenu()
menu.Add("Quit").OnClick(func(ctx *application.Context) {
a.app.Quit()
})
systray.OnClick(func() {
if a.mainWindows.IsVisible() {
a.mainWindows.Hide()
} else {
a.mainWindows.Show()
a.mainWindows.Focus()
} }
}) })
application.RegisterEvent[FilesDroppedEvent]("files-dropped") systray.SetMenu(menu)
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ func (a *App) Run() {
Level: slog.LevelDebug, a.registerServices()
})) a.setupSystray()
slog.SetDefault(logger) a.registerCustomEvents()
a.setupEvents()
err = app.Run() err := a.app.Run()
if err != nil { if err != nil {
panic(err) panic(err)
} }
} }
func main() {
app := NewApp()
app.Run()
}