16 Commits
v0.0.2 ... main

Author SHA1 Message Date
ae0ab09b48 add: .golangci.yml 2026-02-11 04:21:54 +08:00
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
66 changed files with 2088 additions and 658 deletions

1
.gitignore vendored
View File

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

23
.golangci.yml Normal file
View File

@@ -0,0 +1,23 @@
version: "2"
linters:
default: standard
enable:
- staticcheck
- gosec
exclusions:
rules:
- linters:
- gosec
text: "G304:"
- linters:
- errcheck
text: "is not checked"
formatters:
enable:
- gofmt
- gofumpt
- goimports
- gci
- golines
output:
path-mode: abs

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",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/main.go",
"program": "${workspaceFolder}",
"buildFlags": "-tags=gtk4",
"preLaunchTask": "build frontend"
}
]

View File

@@ -1,68 +1,91 @@
# 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) |
| -------------------------------- | -------------------------------- |
## 待办事项
## Todo
- [x] 剪辑板传输
- [x] 文件夹传输
- [x] 取消传输
- [x] 多文件发送
- [x] 加密传输
- [x] 设置页面
- [x] 单例模式
- [x] 系统通知
- [x] 清理历史
- [x] 自动接收
- [ ] 应用图标
- [ ] 系统托盘(最小化到托盘)
- [ ] 收藏Peer
- [x] Clipboard transfer
- [x] Folder transfer
- [x] Cancel transfer
- [x] Multi-file sending
- [x] Encrypted transmission
- [x] Settings page
- [x] Single instance mode
- [x] System notifications
- [x] Clear history
- [x] Auto accept
- [x] App icon
- [x] Trust 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/)
- **前端**: [Vue 3](https://vuejs.org/) + [TypeScript](https://www.typescriptlang.org/)
- **UI 框架**: [Vuetify](https://vuetifyjs.com/)
- **Backend**: [Go](https://go.dev/) + [Wails v3](https://v3.wails.io/)
- **Frontend**: [Vue 3](https://vuejs.org/) + [TypeScript](https://www.typescriptlang.org/)
- **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**
3. **Wails CLI**
4. **UPX**
### 安装依赖
### Install Dependencies
```bash
# 进入项目目录
# Enter project directory
cd mesh-drop
# 安装前端依赖 (通常 Wails 会自动处理,但手动安装可确保环境清晰)
# Install frontend dependencies (Wails usually handles this automatically, but manual installation ensures a clean environment)
cd frontend
npm install
cd ..
```
### 运行开发环境
### Run Development Environment
```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:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
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}}"
OUTPUT: "{{ .OUTPUT | default .DEFAULT_OUTPUT }}"
env:
@@ -59,16 +61,6 @@ tasks:
CGO_ENABLED: 1
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:
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
internal: true
@@ -181,13 +173,14 @@ tasks:
dir: build
cmds:
- 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:
APP_NAME: "{{.APP_NAME}}"
EXEC: "{{.APP_NAME}}"
ICON: "{{.APP_NAME}}"
CATEGORIES: "Development;"
CATEGORIES: '{{.CATEGORIES | default .ENV.CATEGORIES | default "GTK;Utility"}}'
OUTPUTFILE: "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop"
KEYWORDS: '{{.KEYWORDS | default .ENV.KEYWORDS | default "utility"}}'
run:
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
Exec=mesh-drop
Icon=mesh-drop
Categories=Development;
Categories=GTK;Utility
Terminal=false
Keywords=wails
Keywords=utility
Version=1.0
StartupNotify=false

View File

@@ -6,13 +6,13 @@
name: "mesh-drop"
arch: ${GOARCH}
platform: "linux"
version: "0.1.0"
version: "0.0.3"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "A mesh-drop application"
vendor: "My Company"
homepage: "https://wails.io"
vendor: "nite"
homepage: "https://www.nite07.com"
license: "MIT"
release: "1"

View File

@@ -46,20 +46,17 @@ tasks:
platforms: [windows]
- cmd: rm -f *.syso
platforms: [linux, darwin]
- cmd: upx -t "{{.BIN_DIR}}/{{.APP_NAME}}.exe" || upx "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
platforms: [linux]
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:
GOOS: windows
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
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:
summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
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;
"body"?: string;
"categoryId"?: string;
"data"?: { [_: string]: any };
"data"?: { [_ in string]?: any };
/** Creates a new NotificationOptions instance. */
constructor($$source: Partial<NotificationOptions> = {}) {

View File

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

View File

@@ -9,10 +9,18 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
// @ts-ignore: Unused imports
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> {
return $Call.ByID(2605668438);
}
export function GetCloseToSystray(): $CancellablePromise<boolean> {
return $Call.ByID(3671455511);
}
export function GetHostName(): $CancellablePromise<string> {
return $Call.ByID(972342140);
}
@@ -21,6 +29,18 @@ export function GetID(): $CancellablePromise<string> {
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> {
return $Call.ByID(2178923392);
}
@@ -29,16 +49,30 @@ export function GetSavePath(): $CancellablePromise<string> {
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> {
return $Call.ByID(3578438023);
}
export function GetWindowState(): $CancellablePromise<$models.WindowState> {
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 保存配置到磁盘
*/
@@ -50,10 +84,18 @@ export function SetAutoAccept(autoAccept: boolean): $CancellablePromise<void> {
return $Call.ByID(3371961138, autoAccept);
}
export function SetCloseToSystray(closeToSystray: boolean): $CancellablePromise<void> {
return $Call.ByID(2558495467, closeToSystray);
}
export function SetHostName(hostName: string): $CancellablePromise<void> {
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> {
return $Call.ByID(3779587628, saveHistory);
}
@@ -70,4 +112,5 @@ export function SetWindowState(state: $models.WindowState): $CancellablePromise<
}
// 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 {
Language,
WindowState
} from "./models.js";

View File

@@ -5,32 +5,30 @@
// @ts-ignore: Unused imports
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 定义窗口状态
*/
export class WindowState {
"Width": number;
"Height": number;
"X": number;
"Y": number;
"Maximised": boolean;
"width": number;
"height": number;
/** Creates a new WindowState instance. */
constructor($$source: Partial<WindowState> = {}) {
if (!("Width" in $$source)) {
this["Width"] = 0;
if (!("width" in $$source)) {
this["width"] = 0;
}
if (!("Height" in $$source)) {
this["Height"] = 0;
}
if (!("X" in $$source)) {
this["X"] = 0;
}
if (!("Y" in $$source)) {
this["Y"] = 0;
}
if (!("Maximised" in $$source)) {
this["Maximised"] = false;
if (!("height" in $$source)) {
this["height"] = 0;
}
Object.assign(this, $$source);

View File

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

View File

@@ -17,22 +17,29 @@ export function GetLocalIPInSameSubnet(receiverIP: string): $CancellablePromise<
return $Call.ByID(3089425954, receiverIP);
}
export function GetLocalIPs(): $CancellablePromise<[string[], boolean]> {
return $Call.ByID(2403939179).then(($result: any) => {
$result[0] = $$createType0($result[0]);
export function GetPeerByID(id: string): $CancellablePromise<[$models.Peer | null, boolean]> {
return $Call.ByID(1962377788, id).then(($result: any) => {
$result[0] = $$createType1($result[0]);
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 $$createType2($result);
$result[0] = $$createType1($result[0]);
return $result;
});
}
export function GetPeers(): $CancellablePromise<$models.Peer[]> {
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
const $$createType0 = $Create.Array($Create.Any);
const $$createType1 = $models.Peer.createFrom;
const $$createType2 = $Create.Nullable($$createType1);
const $$createType3 = $Create.Array($$createType1);
const $$createType0 = $models.Peer.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Array($$createType0);

View File

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

View File

@@ -5,6 +5,10 @@
// @ts-ignore: Unused imports
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 {
/**
* 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
*/
@@ -116,9 +80,10 @@ export class Transfer {
/**
* 发送者
*/
"sender": Sender;
"sender": discovery$0.Peer;
/**
* FileName 如果 ContentType 为 file文件名如果 ContentType 为 folder文件夹名如果 ContentType 为 text
* 文件名
*/
"file_name": string;
@@ -177,7 +142,7 @@ export class Transfer {
this["create_time"] = 0;
}
if (!("sender" in $$source)) {
this["sender"] = (new Sender());
this["sender"] = (new discovery$0.Peer());
}
if (!("file_name" in $$source)) {
this["file_name"] = "";
@@ -256,5 +221,5 @@ export enum TransferType {
};
// Private type creation functions
const $$createType0 = Sender.createFrom;
const $$createType0 = discovery$0.Peer.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
// @ts-ignore: Unused imports
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
// @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> {
return $Call.ByID(2987999795);
}
@@ -97,3 +106,5 @@ export function StoreTransfersToList(transfers: ($models.Transfer | null)[]): $C
const $$createType0 = $models.Transfer.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
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
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 {
"files": string[];
"files": File[];
"target": string;
/** Creates a new FilesDroppedEvent instance. */
@@ -25,7 +50,7 @@ export class FilesDroppedEvent {
* Creates a new FilesDroppedEvent instance from a string or object.
*/
static createFrom($$source: any = {}): FilesDroppedEvent {
const $$createField0_0 = $$createType0;
const $$createField0_0 = $$createType1;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("files" in $$parsedSource) {
$$parsedSource["files"] = $$createField0_0($$parsedSource["files"]);
@@ -35,4 +60,5 @@ export class FilesDroppedEvent {
}
// 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">
<title>Mesh Drop</title>
<link rel="stylesheet" href="src/styles/style.css">
<link rel="icon" type="image/svg+xml" href="src/assets/icon.svg">
</head>
<body>

View File

@@ -12,6 +12,7 @@
"@mdi/font": "7.4.47",
"@wailsio/runtime": "^3.0.0-alpha.79",
"vue": "^3.5.21",
"vue-i18n": "^11.2.8",
"vuetify": "^3.10.1"
},
"devDependencies": {
@@ -509,6 +510,50 @@
"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": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1350,6 +1395,12 @@
"@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": {
"version": "3.2.4",
"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": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",

View File

@@ -16,6 +16,7 @@
"@mdi/font": "7.4.47",
"@wailsio/runtime": "^3.0.0-alpha.79",
"vue": "^3.5.21",
"vue-i18n": "^11.2.8",
"vuetify": "^3.10.1"
},
"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>
// --- Vue 核心 ---
import { onMounted, ref, computed } from "vue";
import { useI18n } from "vue-i18n";
// --- 组件 ---
import PeerCard from "./PeerCard.vue";
@@ -25,6 +26,7 @@ const transferList = ref<Transfer[]>([]);
const activeKey = ref("discover");
const drawer = ref(true);
const isMobile = ref(false);
const { t } = useI18n();
// --- 计算属性 ---
const pendingCount = computed(() => {
@@ -35,18 +37,18 @@ const pendingCount = computed(() => {
const menuItems = computed(() => [
{
title: "Discover",
title: t("menu.discover"),
value: "discover",
icon: "mdi-radar",
},
{
title: "Transfers",
title: t("menu.transfers"),
value: "transfers",
icon: "mdi-inbox",
badge: pendingCount.value > 0 ? pendingCount.value : null,
},
{
title: "Settings",
title: t("menu.settings"),
value: "settings",
icon: "mdi-cog",
},
@@ -66,12 +68,10 @@ onMounted(async () => {
// --- 后端集成 & 事件监听 ---
onMounted(async () => {
peers.value = await GetPeers();
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
});
Events.On("peers:update", (event) => {
peers.value = event.data;
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
});
Events.On("transfer:refreshList", async () => {
@@ -150,7 +150,18 @@ const handleCleanFinished = async () => {
<v-container fluid class="pa-4">
<!-- 发现视图 -->
<div v-show="activeKey === 'discover'">
<div v-if="peers.length > 0" class="peer-grid">
<div v-if="peers.length > 0">
<v-alert
icon="mdi-information-outline"
density="compact"
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"
@@ -158,6 +169,7 @@ const handleCleanFinished = async () => {
/>
</div>
</div>
</div>
<div
v-else
@@ -170,7 +182,7 @@ const handleCleanFinished = async () => {
class="mb-4 radar-icon"
style="opacity: 0.5"
></v-icon>
<div class="text-grey">Scanning for peers...</div>
<div class="text-grey">{{ t("discover.scanning") }}</div>
</div>
</div>
@@ -184,7 +196,7 @@ const handleCleanFinished = async () => {
color="error"
@click="handleCleanFinished"
>
Clear Finished
{{ t("transfers.clearFinished") }}
</v-btn>
</div>
<TransferItem
@@ -198,7 +210,7 @@ const handleCleanFinished = async () => {
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>
<div class="text-grey">No transfers yet</div>
<div class="text-grey">{{ t("transfers.noTransfers") }}</div>
</div>
</div>

View File

@@ -1,24 +1,51 @@
<script setup lang="ts">
// --- 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 TextSendModal from "./modals/TextSendModal.vue";
// --- Wails & 后端绑定 ---
import { Dialogs, Clipboard } from "@wailsio/runtime";
import { Dialogs, Clipboard, Events } from "@wailsio/runtime";
import {
SendFolder,
SendText,
} from "../../bindings/mesh-drop/internal/transfer/service";
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<{
peer: Peer;
}>();
const { t } = useI18n();
const emit = defineEmits<{
(e: "transferStarted"): void;
}>();
@@ -27,29 +54,30 @@ const emit = defineEmits<{
const selectedIp = ref<string>("");
const showFileModal = ref(false);
const showTextModal = ref(false);
const isTrusted = ref(false);
const sendOptions = [
const sendOptions = computed(() => [
{
title: "Send Files",
title: t("discover.sendFiles"),
value: "files",
icon: "mdi-file",
},
{
title: "Send Folder",
title: t("discover.sendFolder"),
value: "folder",
icon: "mdi-folder",
},
{
title: "Send Text",
title: t("discover.sendText"),
value: "text",
icon: "mdi-format-font",
},
{
title: "Send Clipboard",
title: t("discover.sendClipboard"),
value: "clipboard",
icon: "mdi-clipboard",
},
];
]);
// --- 计算属性 ---
const ips = computed(() => {
@@ -70,6 +98,10 @@ const osIcon = computed(() => {
}
});
const showMismatch = computed(() => {
return props.peer.trust_mismatch && isTrusted.value;
});
// --- 监听 ---
watch(
ips,
@@ -108,7 +140,7 @@ const handleAction = (key: string) => {
const handleSendFolder = async () => {
if (!selectedIp.value) return;
const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select folder to send",
Title: t("discover.selectFolder"),
CanChooseDirectories: true,
CanChooseFiles: false,
AllowsMultipleSelection: false,
@@ -118,7 +150,7 @@ const handleSendFolder = async () => {
SendFolder(props.peer, selectedIp.value, folderPath as string).catch((e) => {
console.error(e);
alert("Failed to send folder: " + e);
alert(t("discover.sendFolderFailed", { error: e }));
});
emit("transferStarted");
};
@@ -127,19 +159,36 @@ const handleSendClipboard = async () => {
if (!selectedIp.value) return;
const text = await Clipboard.Text();
if (!text) {
alert("Clipboard is empty");
alert(t("discover.clipboardEmpty"));
return;
}
SendText(props.peer, selectedIp.value, text).catch((e) => {
console.error(e);
alert("Failed to send clipboard: " + e);
alert(t("discover.sendClipboardFailed", { error: e }));
});
emit("transferStarted");
};
const handleTrust = () => {
AddTrust(props.peer.id, props.peer.pk);
isTrusted.value = true;
};
const handleUntrust = () => {
RemoveTrust(props.peer.id);
isTrusted.value = false;
};
</script>
<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>
<div class="d-flex align-center">
<v-icon :icon="osIcon" size="24" class="mr-2"></v-icon>
@@ -183,16 +232,41 @@ const handleSendClipboard = async () => {
</v-menu>
<!-- 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>
</template>
<template #actions>
<v-menu>
<v-card-actions>
<!-- 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 }">
<v-btn
v-bind="props"
block
class="flex-grow-1"
color="primary"
variant="tonal"
:disabled="ips.length === 0"
@@ -201,7 +275,7 @@ const handleSendClipboard = async () => {
<template #prepend>
<v-icon icon="mdi-send"></v-icon>
</template>
Send
{{ t("discover.send") }}
</v-btn>
</template>
<v-list>
@@ -218,7 +292,38 @@ const handleSendClipboard = async () => {
</v-list-item>
</v-list>
</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>
<!-- Modals -->
@@ -226,6 +331,7 @@ const handleSendClipboard = async () => {
v-model="showFileModal"
:peer="peer"
:selectedIp="selectedIp"
:files="droppedFiles"
@transferStarted="emit('transferStarted')"
/>
@@ -236,3 +342,70 @@ const handleSendClipboard = async () => {
@transferStarted="emit('transferStarted')"
/>
</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>
// --- Vue 核心 ---
import { onMounted, ref } from "vue";
import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
// --- Wails & 后端绑定 ---
import { Dialogs } from "@wailsio/runtime";
@@ -14,7 +15,12 @@ import {
GetSaveHistory,
SetSaveHistory,
GetVersion,
GetLanguage,
SetLanguage,
SetCloseToSystray,
GetCloseToSystray,
} from "../../bindings/mesh-drop/internal/config/config";
import { Language } from "bindings/mesh-drop/internal/config";
// --- 状态 ---
const savePath = ref("");
@@ -22,6 +28,14 @@ const hostName = ref("");
const autoAccept = ref(false);
const saveHistory = ref(false);
const version = ref("");
const closeToSystray = ref(false);
const { t, locale } = useI18n();
const languages = [
{ title: "English", value: "en" },
{ title: "简体中文", value: "zh-Hans" },
];
// ---生命周期 ---
onMounted(async () => {
@@ -30,12 +44,17 @@ onMounted(async () => {
autoAccept.value = await GetAutoAccept();
saveHistory.value = await GetSaveHistory();
version.value = await GetVersion();
let l = await GetLanguage();
if (l != "") {
locale.value = l;
}
closeToSystray.value = await GetCloseToSystray();
});
// --- 方法 ---
const changeSavePath = async () => {
const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select Save Path",
Title: t("settings.selectSavePath"),
CanChooseDirectories: true,
CanChooseFiles: false,
AllowsMultipleSelection: false,
@@ -46,11 +65,17 @@ const changeSavePath = async () => {
savePath.value = path;
}
};
// 监听语言变化
watch(locale, async (newVal) => {
await SetLanguage(newVal as Language);
});
</script>
<template>
<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>
<v-icon icon="mdi-folder-download"></v-icon>
</template>
@@ -61,11 +86,13 @@ const changeSavePath = async () => {
@click="changeSavePath"
prepend-icon="mdi-pencil"
>
Change
{{ t("settings.change") }}
</v-btn>
</template>
</v-list-item>
<v-list-item title="HostName">
<!-- 主机名 -->
<v-list-item :title="t('settings.hostName')">
<template #prepend>
<v-icon icon="mdi-laptop"></v-icon>
</template>
@@ -79,7 +106,9 @@ const changeSavePath = async () => {
></v-text-field>
</template>
</v-list-item>
<v-list-item title="Save History">
<!-- 保存历史 -->
<v-list-item :title="t('settings.saveHistory')">
<template #prepend>
<v-icon icon="mdi-history"></v-icon>
</template>
@@ -93,7 +122,9 @@ const changeSavePath = async () => {
></v-switch>
</template>
</v-list-item>
<v-list-item title="Auto Accept">
<!-- 自动接受 -->
<v-list-item :title="t('settings.autoAccept')">
<template #prepend>
<v-icon icon="mdi-content-save"></v-icon>
</template>
@@ -107,7 +138,42 @@ const changeSavePath = async () => {
></v-switch>
</template>
</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>
<v-icon icon="mdi-information"></v-icon>
</template>

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,10 @@ body,
/* 标准属性 */
cursor: default;
/* 鼠标指针变为默认箭头,而不是文本输入的 I 形 */
overflow: hidden;
height: 100%;
margin: 0;
padding: 0;
}
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 (
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/spf13/viper v1.21.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.67
github.com/wailsapp/wails/v3 v3.0.0-alpha.68
)
require (
@@ -24,7 +23,6 @@ require (
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/ebitengine/purego v0.9.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/gin-contrib/sse v1.1.0 // 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/universal-translator v0.18.1 // 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-yaml v1.18.0 // 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/quic-go v0.54.0 // 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/sergi/go-diff v1.4.0 // 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/ugorji/go/codec v1.3.0 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // 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/crypto v0.47.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/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
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/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
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/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-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/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
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/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
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/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/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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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/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/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/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.67/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
github.com/wailsapp/wails/v3 v3.0.0-alpha.68 h1:CBSP9rOISKiFv6hmqVj2HsU6f4bSMQmsmuSzPQMUxSE=
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/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
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/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
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
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"sync"
"github.com/google/uuid"
"github.com/spf13/viper"
"mesh-drop/internal/security"
)
// WindowState 定义窗口状态
type WindowState struct {
Width int `mapstructure:"width"`
Height int `mapstructure:"height"`
X int `mapstructure:"x"`
Y int `mapstructure:"y"`
Maximised bool `mapstructure:"maximised"`
Width int `json:"width"`
Height int `json:"height"`
}
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 {
v *viper.Viper
mu sync.RWMutex
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,
data configData
configPath string
}
func GetConfigDir() string {
@@ -58,45 +64,76 @@ func GetUserHomeDir() string {
}
// New 读取配置
func Load() *Config {
v := viper.New()
func Load(defaultState WindowState) *Config {
configDir := GetConfigDir()
err := os.MkdirAll(configDir, 0755)
if err != nil {
slog.Error("Failed to create config directory", "error", err)
}
_ = os.MkdirAll(configDir, 0o750)
configFile := filepath.Join(configDir, "config.json")
// 设置默认值
defaultSavePath := filepath.Join(GetUserHomeDir(), "Downloads")
v.SetDefault("window_state", defaultWindowState)
v.SetDefault("save_path", defaultSavePath)
defaultHostName, err := os.Hostname()
if err != nil {
defaultHostName = "localhost"
}
v.SetDefault("host_name", defaultHostName)
v.SetDefault("id", uuid.New().String())
v.SetDefault("save_history", true)
v.SetConfigFile(configFile)
v.SetConfigType("json")
cfgData := configData{
WindowState: defaultState,
SavePath: defaultSavePath,
AutoAccept: false,
SaveHistory: true,
Language: LanguageEnglish,
CloseToSystray: false,
ID: uuid.New().String(),
HostName: defaultHostName,
TrustedPeer: make(map[string]string),
}
// 尝试读取配置
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
slog.Info("Config file not found, using defaults")
fileBytes, err := os.ReadFile(
configFile,
)
if err != nil {
if !os.IsNotExist(err) {
slog.Error("Failed to read config file", "error", err)
} else {
slog.Warn("Failed to read config file, using defaults", "error", err)
slog.Info("Config file not found, creating new one")
}
}
var config Config
if err := v.Unmarshal(&config); err != nil {
} else {
if err := json.Unmarshal(fileBytes, &cfgData); err != nil {
slog.Error("Failed to unmarshal config", "error", err)
}
}
config.v = v
config := Config{
data: cfgData,
configPath: configFile,
}
// 确保默认保存路径存在
err = os.MkdirAll(defaultSavePath, 0o750)
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
}
@@ -105,79 +142,97 @@ func Load() *Config {
func (c *Config) Save() error {
c.mu.RLock()
defer c.mu.RUnlock()
return c.save()
}
configDir := GetConfigDir()
if err := os.MkdirAll(configDir, 0755); err != nil {
func (c *Config) save() error {
dir := filepath.Dir(c.configPath)
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
if err := c.v.WriteConfig(); err != nil {
slog.Error("Failed to write config", "error", err)
jsonData, err := json.MarshalIndent(c.data, "", " ")
if err != nil {
return err
}
// 设置配置文件权限为 0600 (仅所有者读写)
if c.configPath != "" {
if err := os.WriteFile(c.configPath, jsonData, 0o600); err != nil {
slog.Warn("Failed to write config file", "error", err)
return err
}
}
return nil
}
// SetSavePath 修改配置
func (c *Config) SetSavePath(savePath string) {
// update 是一个辅助函数,用于在锁保护下更新配置并保存
func (c *Config) update(fn func()) {
c.mu.Lock()
defer c.mu.Unlock()
c.SavePath = savePath
c.v.Set("save_path", savePath)
_ = os.MkdirAll(savePath, 0755)
fn()
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, 0o750)
})
}
func (c *Config) GetSavePath() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.SavePath
return c.data.SavePath
}
func (c *Config) SetHostName(hostName string) {
c.mu.Lock()
defer c.mu.Unlock()
c.HostName = hostName
c.v.Set("host_name", hostName)
c.update(func() {
c.data.HostName = hostName
})
}
func (c *Config) GetHostName() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.HostName
return c.data.HostName
}
func (c *Config) GetID() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.ID
return c.data.ID
}
func (c *Config) SetAutoAccept(autoAccept bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.AutoAccept = autoAccept
c.v.Set("auto_accept", autoAccept)
c.update(func() {
c.data.AutoAccept = autoAccept
})
}
func (c *Config) GetAutoAccept() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.AutoAccept
return c.data.AutoAccept
}
func (c *Config) SetSaveHistory(saveHistory bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.SaveHistory = saveHistory
c.v.Set("save_history", saveHistory)
c.update(func() {
c.data.SaveHistory = saveHistory
})
}
func (c *Config) GetSaveHistory() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.SaveHistory
return c.data.SaveHistory
}
func (c *Config) GetVersion() string {
@@ -185,14 +240,77 @@ func (c *Config) GetVersion() string {
}
func (c *Config) SetWindowState(state WindowState) {
c.mu.Lock()
defer c.mu.Unlock()
c.WindowState = state
c.v.Set("window_state", state)
c.update(func() {
c.data.WindowState = state
})
}
func (c *Config) GetWindowState() WindowState {
c.mu.RLock()
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
import "time"
import (
"fmt"
"time"
)
// Peer 代表一个可达的网络端点 (Network Endpoint)。
// 注意:一个物理设备 (Device) 可能通过多个网络接口广播,因此会对应多个 Peer 结构体。
@@ -20,6 +23,12 @@ type Peer struct {
Port int `json:"port"`
OS OS `json:"os"`
PublicKey string `json:"pk"`
// TrustMismatch 指示该节点的公钥与本地信任列表中的公钥不匹配
// 如果为 true说明可能存在 ID 欺骗或密钥轮换
TrustMismatch bool `json:"trust_mismatch"`
}
// RouteState 记录单条路径的状态
@@ -42,4 +51,30 @@ type PresencePacket struct {
Name string `json:"name"`
Port int `json:"port"`
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

@@ -4,19 +4,21 @@ import (
"encoding/json"
"fmt"
"log/slog"
"mesh-drop/internal/config"
"net"
"runtime"
"sort"
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"mesh-drop/internal/config"
"mesh-drop/internal/security"
)
const (
DiscoveryPort = 9988
HeartbeatRate = 3 * time.Second
PeerTimeout = 10 * time.Second
HeartbeatRate = 1 * time.Second
PeerTimeout = 2 * time.Second
)
type Service struct {
@@ -26,13 +28,11 @@ type Service struct {
config *config.Config
FileServerPort int
// key 使用 peer.id 和 peer.ip 组合而成的 hash
// Key: peer.ID
peers map[string]*Peer
peersMutex sync.RWMutex
}
func init() {
application.RegisterEvent[[]Peer]("peers:update")
self Peer
}
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,
FileServerPort: port,
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()
if err != nil {
slog.Error("Failed to get network interfaces", "error", err, "component", "discovery")
@@ -105,7 +112,13 @@ func (s *Service) GetLocalIPInSameSubnet(receiverIP string) (string, bool) {
}
}
}
slog.Error("Failed to get local IP in same subnet", "receiverIP", receiverIP, "component", "discovery")
slog.Error(
"Failed to get local IP in same subnet",
"receiverIP",
receiverIP,
"component",
"discovery",
)
return "", false
}
@@ -122,7 +135,18 @@ func (s *Service) startBroadcasting() {
Name: s.config.GetHostName(),
Port: s.FileServerPort,
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)
for _, iface := range interfaces {
// 过滤掉 Down 的接口和 Loopback 接口
@@ -199,12 +223,54 @@ func (s *Service) startListening() {
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 处理心跳包
func (s *Service) handleHeartbeat(pkt PresencePacket, ip string) {
func (s *Service) handleHeartbeat(pkt PresencePacket, ip string, trustMismatch bool) {
s.peersMutex.Lock()
peer, exists := s.peers[pkt.ID]
@@ -221,17 +287,25 @@ func (s *Service) handleHeartbeat(pkt PresencePacket, ip string) {
},
Port: pkt.Port,
OS: pkt.OS,
PublicKey: pkt.PublicKey,
TrustMismatch: trustMismatch,
}
s.peers[peer.ID] = peer
slog.Info("New device found", "name", pkt.Name, "ip", ip, "component", "discovery")
} else {
// 更新节点
// 只有在没有身份不匹配的情况下才更新元数据,防止欺骗攻击导致 UI 闪烁/篡改
if !trustMismatch {
peer.Name = pkt.Name
peer.OS = pkt.OS
peer.PublicKey = pkt.PublicKey
}
peer.Routes[ip] = &RouteState{
IP: ip,
LastSeen: time.Now(),
}
// 如果之前存在不匹配,即使这次匹配了,也不要重置,防止欺骗攻击
peer.TrustMismatch = peer.TrustMismatch || trustMismatch
}
s.peersMutex.Unlock()
@@ -250,7 +324,6 @@ func (s *Service) startCleanup() {
for id, peer := range s.peers {
for ip, route := range peer.Routes {
// 超过10秒没心跳认为下线
if now.Sub(route.LastSeen) > PeerTimeout {
delete(peer.Routes, ip)
changed = true
@@ -278,16 +351,27 @@ func (s *Service) Start() {
go s.startCleanup()
}
func (s *Service) GetPeerByIP(ip string) *Peer {
func (s *Service) GetPeerByIP(ip string) (*Peer, bool) {
s.peersMutex.RLock()
defer s.peersMutex.RUnlock()
for _, p := range s.peers {
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 {
@@ -296,11 +380,18 @@ func (s *Service) GetPeers() []Peer {
list := make([]Peer, 0)
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
}
func (s *Service) GetID() string {
return s.ID
}
func (s *Service) GetSelf() Peer {
return s.self
}

View File

@@ -52,7 +52,13 @@ func generateSelfSignedCert(certPath, keyPath string) error {
// 在实际的动态环境中,我们可能希望添加所有当前接口的 IP 地址
// 实际上,在客户端跳过 IP 验证对于本地 P2P 来说是很常见的。
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
derBytes, err := x509.CreateCertificate(
rand.Reader,
&template,
&template,
&priv.PublicKey,
priv,
)
if err != nil {
return err
}
@@ -73,7 +79,10 @@ func generateSelfSignedCert(certPath, keyPath string) error {
return err
}
defer keyOut.Close()
if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil {
if err := pem.Encode(
keyOut,
&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)},
); err != nil {
return err
}

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

@@ -10,13 +10,13 @@ import (
"io"
"log/slog"
"math"
"mesh-drop/internal/discovery"
"net/http"
"net/url"
"os"
"path/filepath"
"github.com/google/uuid"
"mesh-drop/internal/discovery"
)
func (s *Service) SendFiles(target *discovery.Peer, targetIP string, filePaths []string) {
@@ -32,10 +32,17 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
file, err := os.Open(filePath)
if err != nil {
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
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
@@ -44,11 +51,7 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
task := NewTransfer(
taskID,
NewSender(
s.discoveryService.GetID(),
s.config.GetHostName(),
WithReceiverIP(targetIP, s.discoveryService),
),
s.discoveryService.GetSelf(),
WithFileName(filepath.Base(filePath)),
WithFileSize(stat.Size()),
WithType(TransferTypeSend),
@@ -58,6 +61,7 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
s.StoreTransferToList(task)
go func() {
defer file.Close()
// 任务结束后清理 ctx
defer func() {
s.cancelMap.Delete(taskID)
@@ -105,17 +109,21 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
size, err := calculateTarSize(ctx, folderPath)
if err != nil {
slog.Error("Failed to calculate folder size", "path", folderPath, "error", err, "component", "transfer-client")
slog.Error(
"Failed to calculate folder size",
"path",
folderPath,
"error",
err,
"component",
"transfer-client",
)
return
}
task := NewTransfer(
taskID,
NewSender(
s.discoveryService.GetID(),
s.config.GetHostName(),
WithReceiverIP(targetIP, s.discoveryService),
),
s.discoveryService.GetSelf(),
WithFileName(filepath.Base(folderPath)),
WithFileSize(size),
WithType(TransferTypeSend),
@@ -145,7 +153,13 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
go func(ctx context.Context) {
defer w.Close()
if err := streamFolderToTar(ctx, w, folderPath); err != nil {
slog.Error("Failed to stream folder to tar", "error", err, "component", "transfer-client")
slog.Error(
"Failed to stream folder to tar",
"error",
err,
"component",
"transfer-client",
)
w.CloseWithError(err)
}
}(ctx)
@@ -164,11 +178,7 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
r := bytes.NewReader([]byte(text))
task := NewTransfer(
taskID,
NewSender(
s.discoveryService.GetID(),
s.config.GetHostName(),
WithReceiverIP(targetIP, s.discoveryService),
),
s.discoveryService.GetSelf(),
WithFileSize(int64(len(text))),
WithType(TransferTypeSend),
WithContentType(ContentTypeText),
@@ -211,7 +221,12 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
}
// ask 向接收端发送传输请求
func (s *Service) ask(ctx context.Context, target *discovery.Peer, targetIP string, task *Transfer) (TransferAskResponse, error) {
func (s *Service) ask(
ctx context.Context,
target *discovery.Peer,
targetIP string,
task *Transfer,
) (TransferAskResponse, error) {
if err := ctx.Err(); err != nil {
return TransferAskResponse{}, err
}
@@ -244,7 +259,14 @@ func (s *Service) ask(ctx context.Context, target *discovery.Peer, targetIP stri
}
// processTransfer 传输数据
func (s *Service) processTransfer(ctx context.Context, askResp TransferAskResponse, target *discovery.Peer, targetIP string, task *Transfer, payload io.Reader) {
func (s *Service) processTransfer(
ctx context.Context,
askResp TransferAskResponse,
target *discovery.Peer,
targetIP string,
task *Transfer,
payload io.Reader,
) {
defer func() {
s.NotifyTransferListUpdate()
}()
@@ -252,7 +274,9 @@ func (s *Service) processTransfer(ctx context.Context, askResp TransferAskRespon
if err := ctx.Err(); err != nil {
return
}
uploadUrl, _ := url.Parse(fmt.Sprintf("https://%s:%d/transfer/upload/%s", targetIP, target.Port, task.ID))
uploadUrl, _ := url.Parse(
fmt.Sprintf("https://%s:%d/transfer/upload/%s", targetIP, target.Port, task.ID),
)
query := uploadUrl.Query()
query.Add("token", askResp.Token)
uploadUrl.RawQuery = query.Encode()
@@ -285,7 +309,15 @@ func (s *Service) processTransfer(ctx context.Context, askResp TransferAskRespon
} else {
task.Status = TransferStatusError
task.ErrorMsg = fmt.Sprintf("Failed to upload file: %v", err)
slog.Error("Failed to upload file", "url", uploadUrl.String(), "error", err, "component", "transfer-client")
slog.Error(
"Failed to upload file",
"url",
uploadUrl.String(),
"error",
err,
"component",
"transfer-client",
)
}
return
}
@@ -396,7 +428,15 @@ func streamFolderToTar(ctx context.Context, w io.Writer, srcPath string) error {
if relPath == "." {
return nil
}
slog.Debug("Processing file", "path", path, "relPath", relPath, "component", "transfer-client")
slog.Debug(
"Processing file",
"path",
path,
"relPath",
relPath,
"component",
"transfer-client",
)
header, err := tar.FileInfoHeader(info, "")
if err != nil {

View File

@@ -3,34 +3,42 @@ package transfer
import (
"encoding/json"
"log/slog"
"mesh-drop/internal/config"
"os"
"path/filepath"
"mesh-drop/internal/config"
)
func (s *Service) SaveHistory() {
// 将 pending 状态的任务改为 canceled
transferList := s.GetTransferList()
for _, task := range transferList {
if task.Status == TransferStatusPending {
task.Status = TransferStatusCanceled
}
if !s.config.GetSaveHistory() {
return
}
configDir := config.GetConfigDir()
historyPath := filepath.Join(configDir, "history.json")
historyJson, err := json.Marshal(transferList)
tempPath := historyPath + ".tmp"
// 序列化传输列表
historyJson, err := json.MarshalIndent(s.GetTransferList(), "", " ")
if err != nil {
slog.Error("Failed to marshal history", "error", err, "component", "transfer")
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, 0o600); err != nil {
slog.Error("Failed to write temp history file", "error", err, "component", "transfer")
return
}
defer file.Close()
_, err = file.Write(historyJson)
if err != nil {
slog.Error("Failed to write history", "error", err)
// 原子性重命名
if err := os.Rename(tempPath, historyPath); err != nil {
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() {

View File

@@ -1,9 +1,9 @@
package transfer
import (
"log/slog"
"mesh-drop/internal/discovery"
"time"
"mesh-drop/internal/discovery"
)
type TransferStatus string
@@ -37,7 +37,8 @@ const (
type Transfer struct {
ID string `json:"id" binding:"required"` // 传输会话 ID
CreateTime int64 `json:"create_time"` // 创建时间
Sender Sender `json:"sender" binding:"required"` // 发送者
Sender discovery.Peer `json:"sender" binding:"required"` // 发送者
// FileName 如果 ContentType 为 file文件名如果 ContentType 为 folder文件夹名如果 ContentType 为 text
FileName string `json:"file_name"` // 文件名
FileSize int64 `json:"file_size"` // 文件大小 (字节)
SavePath string `json:"savePath"` // 保存路径
@@ -53,7 +54,7 @@ type Transfer struct {
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{
ID: id,
CreateTime: time.Now().UnixMilli(),
@@ -122,41 +123,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 用户前端传输进度
type Progress struct {
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
}
@@ -43,7 +43,14 @@ func (s *Service) handleAsk(c *gin.Context) {
task.DecisionChan = make(chan Decision, 1)
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{
ID: task.ID,
Accepted: true,
@@ -54,7 +61,7 @@ func (s *Service) handleAsk(c *gin.Context) {
_ = s.notifier.SendNotification(notifications.NotificationOptions{
ID: uuid.New().String(),
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 +81,11 @@ func (s *Service) handleAsk(c *gin.Context) {
})
} else {
task.Status = TransferStatusRejected
c.JSON(http.StatusOK, TransferAskResponse{
ID: task.ID,
Accepted: false,
Message: "Transfer rejected",
})
}
case <-c.Request.Context().Done():
// 发送端放弃
@@ -164,6 +176,22 @@ func (s *Service) handleUpload(c *gin.Context) {
switch task.ContentType {
case ContentTypeFile:
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)
if err != nil {
// 接收方无法创建文件,直接报错,任务结束
@@ -178,17 +206,17 @@ func (s *Service) handleUpload(c *gin.Context) {
return
}
defer file.Close()
s.receive(c, task, file, ctxReader)
s.receive(c, task, Writer{w: file, filePath: destPath}, ctxReader)
case ContentTypeText:
var buf bytes.Buffer
s.receive(c, task, &buf, ctxReader)
s.receive(c, task, Writer{w: &buf, filePath: ""}, ctxReader)
task.Text = buf.String()
case ContentTypeFolder:
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 := &PassThroughReader{
Reader: ctxReader,
@@ -208,7 +236,13 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
if err != nil {
// 发送端断线,任务取消
if c.Request.Context().Err() != nil {
slog.Info("Sender canceled transfer (Network/Context disconnected)", "id", task.ID, "raw_err", err)
slog.Info(
"Sender canceled transfer (Network/Context disconnected)",
"id",
task.ID,
"raw_err",
err,
)
task.ErrorMsg = "Sender disconnected"
task.Status = TransferStatusCanceled
return
@@ -237,6 +271,11 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
slog.Error("Failed to write file", "error", err, "component", "transfer")
task.Status = TransferStatusError
task.ErrorMsg = fmt.Errorf("failed to write file: %v", err).Error()
// 删除文件
if task.ContentType == ContentTypeFile && writer.GetFilePath() != "" {
_ = os.Remove(writer.GetFilePath())
}
return
}
@@ -249,12 +288,25 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
task.Status = TransferStatusCompleted
}
func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer, ctxReader io.Reader) {
func (s *Service) receiveFolder(
c *gin.Context,
savePath string,
task *Transfer,
ctxReader io.Reader,
) {
defer s.NotifyTransferListUpdate()
// 创建根目录
destPath := filepath.Join(savePath, task.FileName)
if err := os.MkdirAll(destPath, 0755); err != nil {
// 如果文件已存在则在文件名后追加序号
_, 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, 0o750); err != nil {
c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID,
Message: "Receiver failed to create folder",
@@ -286,7 +338,13 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
return false
}
if c.Request.Context().Err() != nil {
slog.Info("Transfer canceled by sender (Network disconnect)", "id", task.ID, "stage", stage)
slog.Info(
"Transfer canceled by sender (Network disconnect)",
"id",
task.ID,
"stage",
stage,
)
task.Status = TransferStatusCanceled
task.ErrorMsg = "Sender disconnected"
// 发送端已断开,无需也不应再发送 c.JSON
@@ -318,6 +376,14 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
return true
}
// 获取绝对路径以防止 Zip Slip (G305)
// 必须先转换成绝对路径再判断
absDestPath, err := filepath.Abs(destPath)
if err != nil {
handleError(err, "resolve_abs_path")
return
}
tr := tar.NewReader(reader)
for {
header, err := tr.Next()
@@ -328,32 +394,52 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
return
}
target := filepath.Join(destPath, header.Name)
// 确保路径没有越界
if !strings.HasPrefix(target, filepath.Clean(destPath)+string(os.PathSeparator)) {
// 非法路径
target := filepath.Join(destPath, filepath.Clean(header.Name))
absTarget, err := filepath.Abs(target)
if err != nil {
slog.Error("Failed to resolve absolute path", "path", target, "error", err)
continue
}
// 确保路径在目标目录内
if !strings.HasPrefix(absTarget, absDestPath+string(os.PathSeparator)) {
slog.Warn(
"Zip Slip attempt detected",
"header_name",
header.Name,
"resolved_path",
absTarget,
)
continue
}
// 使用安全的绝对路径
target = absTarget
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0755); err != nil {
if err := os.MkdirAll(target, 0o750); err != nil {
slog.Error("Failed to create dir", "path", target, "error", err)
}
case tar.TypeReg:
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
f, err := os.OpenFile(
target,
os.O_CREATE|os.O_RDWR,
os.FileMode(header.Mode),
) //nolint:gosec
if err != nil {
slog.Error("Failed to create file", "path", target, "error", err)
continue
}
// nolint: gosec
if _, err := io.Copy(f, tr); err != nil {
f.Close()
_ = f.Close()
if handleError(err, "write_file_content") {
return
}
}
f.Close()
_ = f.Close()
}
}

View File

@@ -5,9 +5,6 @@ import (
"crypto/tls"
"fmt"
"log/slog"
"mesh-drop/internal/config"
"mesh-drop/internal/discovery"
"mesh-drop/internal/security"
"net/http"
"path/filepath"
"sort"
@@ -16,6 +13,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"mesh-drop/internal/config"
"mesh-drop/internal/discovery"
"mesh-drop/internal/security"
)
type Service struct {
@@ -26,7 +26,7 @@ type Service struct {
// pendingRequests 存储等待用户确认的通道
// Key: TransferID, Value: *Transfer
transferList sync.Map
transfers sync.Map
discoveryService *discovery.Service
@@ -37,12 +37,18 @@ type Service struct {
httpClient *http.Client
}
func NewService(config *config.Config, app *application.App, notifier *notifications.NotificationService, port int, discoveryService *discovery.Service) *Service {
func NewService(
config *config.Config,
app *application.App,
notifier *notifications.NotificationService,
port int,
discoveryService *discovery.Service,
) *Service {
gin.SetMode(gin.ReleaseMode)
// 配置自定义 HTTP 客户端以跳过自签名证书验证
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
}
httpClient := &http.Client{
Transport: tr,
@@ -59,10 +65,6 @@ func NewService(config *config.Config, app *application.App, notifier *notificat
}
}
func init() {
application.RegisterEvent[application.Void]("transfer:refreshList")
}
func (s *Service) GetPort() int {
return s.port
}
@@ -94,9 +96,13 @@ func (s *Service) Start() {
}()
}
func (s *Service) GetTransferSyncMap() *sync.Map {
return &s.transfers
}
func (s *Service) GetTransferList() []*Transfer {
var requests []*Transfer = make([]*Transfer, 0)
s.transferList.Range(func(key, value any) bool {
requests := make([]*Transfer, 0)
s.transfers.Range(func(key, value any) bool {
transfer := value.(*Transfer)
requests = append(requests, transfer)
return true
@@ -109,7 +115,7 @@ func (s *Service) GetTransferList() []*Transfer {
}
func (s *Service) GetTransfer(transferID string) (*Transfer, bool) {
val, ok := s.transferList.Load(transferID)
val, ok := s.transfers.Load(transferID)
if !ok {
return nil, false
}
@@ -130,13 +136,13 @@ func (s *Service) CancelTransfer(transferID string) {
func (s *Service) StoreTransfersToList(transfers []*Transfer) {
for _, transfer := range transfers {
s.transferList.Store(transfer.ID, transfer)
s.transfers.Store(transfer.ID, transfer)
}
s.NotifyTransferListUpdate()
}
func (s *Service) StoreTransferToList(transfer *Transfer) {
s.transferList.Store(transfer.ID, transfer)
s.transfers.Store(transfer.ID, transfer)
s.NotifyTransferListUpdate()
}
@@ -146,13 +152,13 @@ func (s *Service) NotifyTransferListUpdate() {
// CleanTransferList 清理完成的 transfer
func (s *Service) CleanFinishedTransferList() {
s.transferList.Range(func(key, value any) bool {
s.transfers.Range(func(key, value any) bool {
task := value.(*Transfer)
if task.Status == TransferStatusCompleted ||
task.Status == TransferStatusError ||
task.Status == TransferStatusCanceled ||
task.Status == TransferStatusRejected {
s.transferList.Delete(key)
s.transfers.Delete(key)
}
return true
})
@@ -160,6 +166,6 @@ func (s *Service) CleanFinishedTransferList() {
}
func (s *Service) DeleteTransfer(transferID string) {
s.transferList.Delete(transferID)
s.transfers.Delete(transferID)
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
}

222
main.go
View File

@@ -3,44 +3,109 @@ package main
import (
"embed"
"log/slog"
"mesh-drop/internal/config"
"mesh-drop/internal/discovery"
"mesh-drop/internal/transfer"
"os"
"path/filepath"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"mesh-drop/internal/config"
"mesh-drop/internal/discovery"
"mesh-drop/internal/transfer"
)
//go:embed all:frontend/dist
var assets embed.FS
//go:embed build/appicon.png
var icon []byte
type File struct {
Name string `json:"name"`
Path string `json:"path"`
}
type FilesDroppedEvent struct {
Files []string `json:"files"`
Files []File `json:"files"`
Target string `json:"target"`
}
func main() {
conf := config.Load()
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{
Name: "mesh-drop",
Name: "MeshDrop",
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets),
},
SingleInstance: &application.SingleInstanceOptions{
UniqueID: "com.nite07.mesh-drop",
},
Icon: icon,
})
// 创建保存路径
err := os.MkdirAll(conf.SavePath, 0755)
if err != nil {
slog.Error("Failed to create save path", "path", conf.SavePath, "error", err)
// 获取默认屏幕大小
defaultWidth := 1024
defaultHeight := 768
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()
authorized, err := notifier.RequestNotificationAuthorization()
if err != nil {
@@ -53,78 +118,123 @@ func main() {
port := 9989
// 初始化发现服务
discoveryService := discovery.NewService(conf, app, port)
discoveryService := discovery.NewService(a.conf, a.app, port)
discoveryService.Start()
// 初始化传输服务
transferService := transfer.NewService(conf, app, notifier, port, discoveryService)
transferService := transfer.NewService(a.conf, a.app, notifier, port, discoveryService)
transferService.Start()
// 加载传输历史
if conf.GetSaveHistory() {
if a.conf.GetSaveHistory() {
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))
app.RegisterService(application.NewService(transferService))
app.RegisterService(application.NewService(conf))
app.RegisterService(application.NewService(notifier))
a.app.RegisterService(application.NewService(discoveryService))
a.app.RegisterService(application.NewService(transferService))
a.app.RegisterService(application.NewService(a.conf))
a.app.RegisterService(application.NewService(notifier))
}
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "mesh drop",
Width: conf.WindowState.Width,
Height: conf.WindowState.Height,
X: conf.WindowState.X,
Y: conf.WindowState.Y,
EnableFileDrop: true,
Linux: application.LinuxWindow{
WebviewGpuPolicy: application.WebviewGpuPolicyAlways,
},
})
func (a *App) registerCustomEvents() {
application.RegisterEvent[FilesDroppedEvent]("files-dropped")
application.RegisterEvent[[]discovery.Peer]("peers:update")
application.RegisterEvent[application.Void]("transfer:refreshList")
}
func (a *App) setupEvents() {
// 窗口文件拖拽事件
window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
files := event.Context().DroppedFiles()
a.mainWindows.OnWindowEvent(
events.Common.WindowFilesDropped,
func(event *application.WindowEvent) {
files := make([]File, 0)
for _, file := range event.Context().DroppedFiles() {
files = append(files, File{
Name: filepath.Base(file),
Path: file,
})
}
details := event.Context().DropTargetDetails()
app.Event.Emit("files-dropped", FilesDroppedEvent{
a.app.Event.Emit("files-dropped", FilesDroppedEvent{
Files: files,
Target: details.ElementID,
})
})
},
)
// 窗口关闭事件
window.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) {
x, y := window.Position()
width, height := window.Size()
conf.SetWindowState(config.WindowState{
X: x,
Y: y,
Width: width,
Height: height,
a.mainWindows.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) {
if a.conf.GetCloseToSystray() {
event.Cancel()
a.mainWindows.Hide()
return
}
w, h := a.mainWindows.Size()
a.conf.SetWindowState(config.WindowState{
Width: w,
Height: h,
})
slog.Info("Window closed", "width", w, "height", h)
})
// 应用关闭事件
a.app.OnShutdown(func() {
// 保存传输历史
if conf.GetSaveHistory() {
transferService.SaveHistory()
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()
}
})
}
// 保存配置
err := conf.Save()
if err != nil {
slog.Error("Failed to save config", "error", err)
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{
Level: slog.LevelDebug,
}))
slog.SetDefault(logger)
err = app.Run()
func (a *App) Run() {
a.registerServices()
a.setupSystray()
a.registerCustomEvents()
a.setupEvents()
err := a.app.Run()
if err != nil {
panic(err)
}
}
func main() {
app := NewApp()
app.Run()
}