Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ae0ab09b48
|
|||
|
a3989aeedd
|
|||
|
ea40aa76d0
|
|||
|
7c65daeb89
|
|||
|
e76bcd709c
|
|||
|
e76ada9b4b
|
|||
|
eb23ef9d5d
|
|||
|
ed2629cb08
|
|||
|
20a25e8c49
|
|||
|
4b5d2b656b
|
|||
|
d1dd75f7ab
|
|||
|
f3adb56bd0
|
|||
|
d8ffc5eea5
|
|||
|
2a0f2901b4
|
|||
|
6ec897468f
|
|||
|
a2ad297dbc
|
1
.gitignore
vendored
@@ -4,3 +4,4 @@ frontend/dist
|
|||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
build/linux/appimage/build
|
build/linux/appimage/build
|
||||||
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||||
|
dist
|
||||||
23
.golangci.yml
Normal 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
@@ -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
@@ -9,7 +9,8 @@
|
|||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"program": "${workspaceFolder}/main.go",
|
"program": "${workspaceFolder}",
|
||||||
|
"buildFlags": "-tags=gtk4",
|
||||||
"preLaunchTask": "build frontend"
|
"preLaunchTask": "build frontend"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
93
README.md
@@ -1,68 +1,91 @@
|
|||||||
# Mesh Drop
|
# Mesh Drop
|
||||||
|
|
||||||
简易、快速的局域网文件传输工具,基于 Wails 和 Vue 构建。
|
English | [中文](./README.zh.md)
|
||||||
|
|
||||||
## 功能特性
|
Simple, fast LAN file transfer tool, built with Wails and Vue.
|
||||||
|
|
||||||
- **文件传输**:支持多文件发送,轻松共享。
|
## Features
|
||||||
- **文件夹传输**:支持发送整个文件夹结构。
|
|
||||||
- **文本传输**:快速同步设备间的文本内容。
|
|
||||||
- **加密传输**:确保数据在传输过程中的安全性。
|
|
||||||
|
|
||||||
## 截图
|
- **File Transfer**: Support multi-file sending, easily share.
|
||||||
|
- **Folder Transfer**: Support sending entire folder structures.
|
||||||
|
- **Text Transfer**: Quickly sync text content between devices.
|
||||||
|
- **Encrypted Transmission**: Ensure data security during transmission.
|
||||||
|
- **Secure Identity**: Ed25519-based signature verification to prevent spoofing.
|
||||||
|
|
||||||
|
## Security Mechanisms
|
||||||
|
|
||||||
|
Mesh Drop uses a multi-layered security design to protect users from potential malicious attacks:
|
||||||
|
|
||||||
|
1. **Identity**
|
||||||
|
- Each device generates a unique pair of Ed25519 keys on first startup.
|
||||||
|
- All presence broadcasts are signed with the private key.
|
||||||
|
- The receiver verifies the signature with the public key to ensure the identity has not been tampered with.
|
||||||
|
|
||||||
|
2. **Trust**
|
||||||
|
- Uses TOFU (Trust On First Use) strategy.
|
||||||
|
- Users can choose to "Trust" a Peer. Once trusted, that Peer's public key is pinned.
|
||||||
|
- Subsequent packets from that Peer ID must be verified by the saved public key, otherwise they will be marked as **Mismatch**.
|
||||||
|
- **Anti-spoofing**: If someone tries to spoof a trusted Peer ID, the UI will display a clear "Mismatch" security warning and prevent metadata from being overwritten.
|
||||||
|
|
||||||
|
3. **Encryption**
|
||||||
|
- File transfer service uses HTTPS protocol.
|
||||||
|
- Automatically generates self-signed certificates for communication encryption to prevent eavesdropping.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|  |  |
|
|  |  |
|
||||||
| -------------------------------- | -------------------------------- |
|
| -------------------------------- | -------------------------------- |
|
||||||
|
|
||||||
## 待办事项
|
## Todo
|
||||||
|
|
||||||
- [x] 剪辑板传输
|
- [x] Clipboard transfer
|
||||||
- [x] 文件夹传输
|
- [x] Folder transfer
|
||||||
- [x] 取消传输
|
- [x] Cancel transfer
|
||||||
- [x] 多文件发送
|
- [x] Multi-file sending
|
||||||
- [x] 加密传输
|
- [x] Encrypted transmission
|
||||||
- [x] 设置页面
|
- [x] Settings page
|
||||||
- [x] 单例模式
|
- [x] Single instance mode
|
||||||
- [x] 系统通知
|
- [x] System notifications
|
||||||
- [x] 清理历史
|
- [x] Clear history
|
||||||
- [x] 自动接收
|
- [x] Auto accept
|
||||||
- [ ] 应用图标
|
- [x] App icon
|
||||||
- [ ] 系统托盘(最小化到托盘)
|
- [x] Trust Peer
|
||||||
- [ ] 收藏Peer
|
- [x] Multi-language support
|
||||||
|
- [ ] System tray (minimize to tray) badges https://github.com/wailsapp/wails/issues/4494
|
||||||
|
|
||||||
## 技术栈
|
## Tech Stack
|
||||||
|
|
||||||
本项目使用现代化的技术栈构建:
|
This project is built using a modern tech stack:
|
||||||
|
|
||||||
- **后端**: [Go](https://go.dev/) + [Wails v3](https://v3.wails.io/)
|
- **Backend**: [Go](https://go.dev/) + [Wails v3](https://v3.wails.io/)
|
||||||
- **前端**: [Vue 3](https://vuejs.org/) + [TypeScript](https://www.typescriptlang.org/)
|
- **Frontend**: [Vue 3](https://vuejs.org/) + [TypeScript](https://www.typescriptlang.org/)
|
||||||
- **UI 框架**: [Vuetify](https://vuetifyjs.com/)
|
- **UI Framework**: [Vuetify](https://vuetifyjs.com/)
|
||||||
|
|
||||||
## 开发
|
## Development
|
||||||
|
|
||||||
### 前置条件
|
### Prerequisites
|
||||||
|
|
||||||
在开始之前,请确保您的开发环境已安装以下工具:
|
Before starting, ensure your development environment has the following tools installed:
|
||||||
|
|
||||||
1. **Go** (版本 >= 1.25)
|
1. **Go** (version >= 1.25)
|
||||||
2. **Node.js**
|
2. **Node.js**
|
||||||
3. **Wails CLI**
|
3. **Wails CLI**
|
||||||
4. **UPX**
|
4. **UPX**
|
||||||
|
|
||||||
### 安装依赖
|
### Install Dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 进入项目目录
|
# Enter project directory
|
||||||
cd mesh-drop
|
cd mesh-drop
|
||||||
|
|
||||||
# 安装前端依赖 (通常 Wails 会自动处理,但手动安装可确保环境清晰)
|
# Install frontend dependencies (Wails usually handles this automatically, but manual installation ensures a clean environment)
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
cd ..
|
cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
### 运行开发环境
|
### Run Development Environment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wails dev
|
wails3 dev
|
||||||
```
|
```
|
||||||
|
|||||||
91
README.zh.md
Normal file
@@ -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 的 ID,UI 会显示明显的“Mismatch”安全警告,并阻止元数据被覆盖。
|
||||||
|
|
||||||
|
3. **传输加密 (Encryption)**
|
||||||
|
- 文件传输服务使用 HTTPS 协议。
|
||||||
|
- 自动生成自签名证书进行通信加密,防止传输内容被窃听。
|
||||||
|
|
||||||
|
## 截图
|
||||||
|
|
||||||
|
|  |  |
|
||||||
|
| -------------------------------- | -------------------------------- |
|
||||||
|
|
||||||
|
## 待办事项
|
||||||
|
|
||||||
|
- [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
|
||||||
|
```
|
||||||
@@ -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 |
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 24 KiB |
@@ -51,7 +51,9 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
|
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
|
||||||
vars:
|
vars:
|
||||||
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
|
VERSION:
|
||||||
|
sh: git describe --tags --always
|
||||||
|
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -X mesh-drop/internal/config.Version={{.VERSION}}"{{end}}'
|
||||||
DEFAULT_OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}"
|
DEFAULT_OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}"
|
||||||
OUTPUT: "{{ .OUTPUT | default .DEFAULT_OUTPUT }}"
|
OUTPUT: "{{ .OUTPUT | default .DEFAULT_OUTPUT }}"
|
||||||
env:
|
env:
|
||||||
@@ -59,16 +61,6 @@ tasks:
|
|||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
GOARCH: "{{.ARCH | default ARCH}}"
|
GOARCH: "{{.ARCH | default ARCH}}"
|
||||||
|
|
||||||
build:prod:
|
|
||||||
summary: Builds the application natively on Linux
|
|
||||||
deps:
|
|
||||||
- task: build
|
|
||||||
cmds:
|
|
||||||
- upx --best --lzma {{.OUTPUT}}
|
|
||||||
vars:
|
|
||||||
DEFAULT_OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}"
|
|
||||||
OUTPUT: "{{ .OUTPUT | default .DEFAULT_OUTPUT }}"
|
|
||||||
|
|
||||||
build:docker:
|
build:docker:
|
||||||
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
|
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
|
||||||
internal: true
|
internal: true
|
||||||
@@ -181,13 +173,14 @@ tasks:
|
|||||||
dir: build
|
dir: build
|
||||||
cmds:
|
cmds:
|
||||||
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
|
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
|
||||||
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
|
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}" -keywords "{{.KEYWORDS}}"
|
||||||
vars:
|
vars:
|
||||||
APP_NAME: "{{.APP_NAME}}"
|
APP_NAME: "{{.APP_NAME}}"
|
||||||
EXEC: "{{.APP_NAME}}"
|
EXEC: "{{.APP_NAME}}"
|
||||||
ICON: "{{.APP_NAME}}"
|
ICON: "{{.APP_NAME}}"
|
||||||
CATEGORIES: "Development;"
|
CATEGORIES: '{{.CATEGORIES | default .ENV.CATEGORIES | default "GTK;Utility"}}'
|
||||||
OUTPUTFILE: "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop"
|
OUTPUTFILE: "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop"
|
||||||
|
KEYWORDS: '{{.KEYWORDS | default .ENV.KEYWORDS | default "utility"}}'
|
||||||
|
|
||||||
run:
|
run:
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
BIN
build/linux/appimage/mesh-drop
Executable file
BIN
build/linux/appimage/mesh-drop.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
@@ -3,8 +3,8 @@ Type=Application
|
|||||||
Name=mesh-drop
|
Name=mesh-drop
|
||||||
Exec=mesh-drop
|
Exec=mesh-drop
|
||||||
Icon=mesh-drop
|
Icon=mesh-drop
|
||||||
Categories=Development;
|
Categories=GTK;Utility
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Keywords=wails
|
Keywords=utility
|
||||||
Version=1.0
|
Version=1.0
|
||||||
StartupNotify=false
|
StartupNotify=false
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
name: "mesh-drop"
|
name: "mesh-drop"
|
||||||
arch: ${GOARCH}
|
arch: ${GOARCH}
|
||||||
platform: "linux"
|
platform: "linux"
|
||||||
version: "0.1.0"
|
version: "0.0.3"
|
||||||
section: "default"
|
section: "default"
|
||||||
priority: "extra"
|
priority: "extra"
|
||||||
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||||
description: "A mesh-drop application"
|
description: "A mesh-drop application"
|
||||||
vendor: "My Company"
|
vendor: "nite"
|
||||||
homepage: "https://wails.io"
|
homepage: "https://www.nite07.com"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
release: "1"
|
release: "1"
|
||||||
|
|
||||||
|
|||||||
@@ -46,20 +46,17 @@ tasks:
|
|||||||
platforms: [windows]
|
platforms: [windows]
|
||||||
- cmd: rm -f *.syso
|
- cmd: rm -f *.syso
|
||||||
platforms: [linux, darwin]
|
platforms: [linux, darwin]
|
||||||
|
- cmd: upx -t "{{.BIN_DIR}}/{{.APP_NAME}}.exe" || upx "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
|
||||||
|
platforms: [linux]
|
||||||
vars:
|
vars:
|
||||||
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}'
|
VERSION:
|
||||||
|
sh: git describe --tags --always
|
||||||
|
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui -X mesh-drop/internal/config.Version={{.VERSION}}"{{end}}'
|
||||||
env:
|
env:
|
||||||
GOOS: windows
|
GOOS: windows
|
||||||
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
|
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
|
||||||
GOARCH: "{{.ARCH | default ARCH}}"
|
GOARCH: "{{.ARCH | default ARCH}}"
|
||||||
|
|
||||||
build:prod:
|
|
||||||
summary: Builds the application for Windows
|
|
||||||
deps:
|
|
||||||
- task: build
|
|
||||||
cmds:
|
|
||||||
- upx --best --lzma "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
|
|
||||||
|
|
||||||
build:docker:
|
build:docker:
|
||||||
summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
|
summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
|
||||||
internal: true
|
internal: true
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 25 KiB |
@@ -74,7 +74,7 @@ export class NotificationOptions {
|
|||||||
"subtitle"?: string;
|
"subtitle"?: string;
|
||||||
"body"?: string;
|
"body"?: string;
|
||||||
"categoryId"?: string;
|
"categoryId"?: string;
|
||||||
"data"?: { [_: string]: any };
|
"data"?: { [_ in string]?: any };
|
||||||
|
|
||||||
/** Creates a new NotificationOptions instance. */
|
/** Creates a new NotificationOptions instance. */
|
||||||
constructor($$source: Partial<NotificationOptions> = {}) {
|
constructor($$source: Partial<NotificationOptions> = {}) {
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
File,
|
||||||
FilesDroppedEvent
|
FilesDroppedEvent
|
||||||
} from "./models.js";
|
} from "./models.js";
|
||||||
|
|||||||
@@ -9,10 +9,18 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import * as $models from "./models.js";
|
import * as $models from "./models.js";
|
||||||
|
|
||||||
|
export function AddTrust(peerID: string, publicKey: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(2986105628, peerID, publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAutoAccept(): $CancellablePromise<boolean> {
|
export function GetAutoAccept(): $CancellablePromise<boolean> {
|
||||||
return $Call.ByID(2605668438);
|
return $Call.ByID(2605668438);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetCloseToSystray(): $CancellablePromise<boolean> {
|
||||||
|
return $Call.ByID(3671455511);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetHostName(): $CancellablePromise<string> {
|
export function GetHostName(): $CancellablePromise<string> {
|
||||||
return $Call.ByID(972342140);
|
return $Call.ByID(972342140);
|
||||||
}
|
}
|
||||||
@@ -21,6 +29,18 @@ export function GetID(): $CancellablePromise<string> {
|
|||||||
return $Call.ByID(4240411568);
|
return $Call.ByID(4240411568);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLanguage(): $CancellablePromise<$models.Language> {
|
||||||
|
return $Call.ByID(480133131);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetPrivateKey(): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(353744619);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetPublicKey(): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(2506498735);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSaveHistory(): $CancellablePromise<boolean> {
|
export function GetSaveHistory(): $CancellablePromise<boolean> {
|
||||||
return $Call.ByID(2178923392);
|
return $Call.ByID(2178923392);
|
||||||
}
|
}
|
||||||
@@ -29,16 +49,30 @@ export function GetSavePath(): $CancellablePromise<string> {
|
|||||||
return $Call.ByID(4081533263);
|
return $Call.ByID(4081533263);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetTrusted(): $CancellablePromise<{ [_ in string]?: string }> {
|
||||||
|
return $Call.ByID(800326956).then(($result: any) => {
|
||||||
|
return $$createType0($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function GetVersion(): $CancellablePromise<string> {
|
export function GetVersion(): $CancellablePromise<string> {
|
||||||
return $Call.ByID(3578438023);
|
return $Call.ByID(3578438023);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetWindowState(): $CancellablePromise<$models.WindowState> {
|
export function GetWindowState(): $CancellablePromise<$models.WindowState> {
|
||||||
return $Call.ByID(341414414).then(($result: any) => {
|
return $Call.ByID(341414414).then(($result: any) => {
|
||||||
return $$createType0($result);
|
return $$createType1($result);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IsTrusted(peerID: string): $CancellablePromise<boolean> {
|
||||||
|
return $Call.ByID(1255607538, peerID);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoveTrust(peerID: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(732981195, peerID);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save 保存配置到磁盘
|
* Save 保存配置到磁盘
|
||||||
*/
|
*/
|
||||||
@@ -50,10 +84,18 @@ export function SetAutoAccept(autoAccept: boolean): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(3371961138, autoAccept);
|
return $Call.ByID(3371961138, autoAccept);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetCloseToSystray(closeToSystray: boolean): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(2558495467, closeToSystray);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetHostName(hostName: string): $CancellablePromise<void> {
|
export function SetHostName(hostName: string): $CancellablePromise<void> {
|
||||||
return $Call.ByID(1580131496, hostName);
|
return $Call.ByID(1580131496, hostName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetLanguage(language: $models.Language): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(933959199, language);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetSaveHistory(saveHistory: boolean): $CancellablePromise<void> {
|
export function SetSaveHistory(saveHistory: boolean): $CancellablePromise<void> {
|
||||||
return $Call.ByID(3779587628, saveHistory);
|
return $Call.ByID(3779587628, saveHistory);
|
||||||
}
|
}
|
||||||
@@ -70,4 +112,5 @@ export function SetWindowState(state: $models.WindowState): $CancellablePromise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Private type creation functions
|
// Private type creation functions
|
||||||
const $$createType0 = $models.WindowState.createFrom;
|
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
|
||||||
|
const $$createType1 = $models.WindowState.createFrom;
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
Language,
|
||||||
WindowState
|
WindowState
|
||||||
} from "./models.js";
|
} from "./models.js";
|
||||||
|
|||||||
@@ -5,32 +5,30 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import { Create as $Create } from "@wailsio/runtime";
|
import { Create as $Create } from "@wailsio/runtime";
|
||||||
|
|
||||||
|
export enum Language {
|
||||||
|
/**
|
||||||
|
* The Go zero value for the underlying type of the enum.
|
||||||
|
*/
|
||||||
|
$zero = "",
|
||||||
|
|
||||||
|
LanguageEnglish = "en",
|
||||||
|
LanguageChinese = "zh-Hans",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WindowState 定义窗口状态
|
* WindowState 定义窗口状态
|
||||||
*/
|
*/
|
||||||
export class WindowState {
|
export class WindowState {
|
||||||
"Width": number;
|
"width": number;
|
||||||
"Height": number;
|
"height": number;
|
||||||
"X": number;
|
|
||||||
"Y": number;
|
|
||||||
"Maximised": boolean;
|
|
||||||
|
|
||||||
/** Creates a new WindowState instance. */
|
/** Creates a new WindowState instance. */
|
||||||
constructor($$source: Partial<WindowState> = {}) {
|
constructor($$source: Partial<WindowState> = {}) {
|
||||||
if (!("Width" in $$source)) {
|
if (!("width" in $$source)) {
|
||||||
this["Width"] = 0;
|
this["width"] = 0;
|
||||||
}
|
}
|
||||||
if (!("Height" in $$source)) {
|
if (!("height" in $$source)) {
|
||||||
this["Height"] = 0;
|
this["height"] = 0;
|
||||||
}
|
|
||||||
if (!("X" in $$source)) {
|
|
||||||
this["X"] = 0;
|
|
||||||
}
|
|
||||||
if (!("Y" in $$source)) {
|
|
||||||
this["Y"] = 0;
|
|
||||||
}
|
|
||||||
if (!("Maximised" in $$source)) {
|
|
||||||
this["Maximised"] = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(this, $$source);
|
Object.assign(this, $$source);
|
||||||
|
|||||||
@@ -40,13 +40,20 @@ export class Peer {
|
|||||||
* Routes 记录了设备的 IP 地址和状态。
|
* Routes 记录了设备的 IP 地址和状态。
|
||||||
* Key: ip, Value: *RouteState
|
* Key: ip, Value: *RouteState
|
||||||
*/
|
*/
|
||||||
"routes": { [_: string]: RouteState | null };
|
"routes": { [_ in string]?: RouteState | null };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port 是文件传输服务的监听端口。
|
* Port 是文件传输服务的监听端口。
|
||||||
*/
|
*/
|
||||||
"port": number;
|
"port": number;
|
||||||
"os": OS;
|
"os": OS;
|
||||||
|
"pk": string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrustMismatch 指示该节点的公钥与本地信任列表中的公钥不匹配
|
||||||
|
* 如果为 true,说明可能存在 ID 欺骗或密钥轮换
|
||||||
|
*/
|
||||||
|
"trust_mismatch": boolean;
|
||||||
|
|
||||||
/** Creates a new Peer instance. */
|
/** Creates a new Peer instance. */
|
||||||
constructor($$source: Partial<Peer> = {}) {
|
constructor($$source: Partial<Peer> = {}) {
|
||||||
@@ -65,6 +72,12 @@ export class Peer {
|
|||||||
if (!("os" in $$source)) {
|
if (!("os" in $$source)) {
|
||||||
this["os"] = OS.$zero;
|
this["os"] = OS.$zero;
|
||||||
}
|
}
|
||||||
|
if (!("pk" in $$source)) {
|
||||||
|
this["pk"] = "";
|
||||||
|
}
|
||||||
|
if (!("trust_mismatch" in $$source)) {
|
||||||
|
this["trust_mismatch"] = false;
|
||||||
|
}
|
||||||
|
|
||||||
Object.assign(this, $$source);
|
Object.assign(this, $$source);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,22 +17,29 @@ export function GetLocalIPInSameSubnet(receiverIP: string): $CancellablePromise<
|
|||||||
return $Call.ByID(3089425954, receiverIP);
|
return $Call.ByID(3089425954, receiverIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLocalIPs(): $CancellablePromise<[string[], boolean]> {
|
export function GetPeerByID(id: string): $CancellablePromise<[$models.Peer | null, boolean]> {
|
||||||
return $Call.ByID(2403939179).then(($result: any) => {
|
return $Call.ByID(1962377788, id).then(($result: any) => {
|
||||||
$result[0] = $$createType0($result[0]);
|
$result[0] = $$createType1($result[0]);
|
||||||
return $result;
|
return $result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetPeerByIP(ip: string): $CancellablePromise<$models.Peer | null> {
|
export function GetPeerByIP(ip: string): $CancellablePromise<[$models.Peer | null, boolean]> {
|
||||||
return $Call.ByID(1626825408, ip).then(($result: any) => {
|
return $Call.ByID(1626825408, ip).then(($result: any) => {
|
||||||
return $$createType2($result);
|
$result[0] = $$createType1($result[0]);
|
||||||
|
return $result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetPeers(): $CancellablePromise<$models.Peer[]> {
|
export function GetPeers(): $CancellablePromise<$models.Peer[]> {
|
||||||
return $Call.ByID(3041084029).then(($result: any) => {
|
return $Call.ByID(3041084029).then(($result: any) => {
|
||||||
return $$createType3($result);
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSelf(): $CancellablePromise<$models.Peer> {
|
||||||
|
return $Call.ByID(3599633538).then(($result: any) => {
|
||||||
|
return $$createType0($result);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +48,6 @@ export function Start(): $CancellablePromise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Private type creation functions
|
// Private type creation functions
|
||||||
const $$createType0 = $Create.Array($Create.Any);
|
const $$createType0 = $models.Peer.createFrom;
|
||||||
const $$createType1 = $models.Peer.createFrom;
|
const $$createType1 = $Create.Nullable($$createType0);
|
||||||
const $$createType2 = $Create.Nullable($$createType1);
|
const $$createType2 = $Create.Array($$createType0);
|
||||||
const $$createType3 = $Create.Array($$createType1);
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export {
|
|||||||
export {
|
export {
|
||||||
ContentType,
|
ContentType,
|
||||||
Progress,
|
Progress,
|
||||||
Sender,
|
|
||||||
Transfer,
|
Transfer,
|
||||||
TransferStatus,
|
TransferStatus,
|
||||||
TransferType
|
TransferType
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import { Create as $Create } from "@wailsio/runtime";
|
import { Create as $Create } from "@wailsio/runtime";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore: Unused imports
|
||||||
|
import * as discovery$0 from "../discovery/models.js";
|
||||||
|
|
||||||
export enum ContentType {
|
export enum ContentType {
|
||||||
/**
|
/**
|
||||||
* The Go zero value for the underlying type of the enum.
|
* The Go zero value for the underlying type of the enum.
|
||||||
@@ -59,46 +63,6 @@ export class Progress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Sender {
|
|
||||||
/**
|
|
||||||
* 发送者 ID
|
|
||||||
*/
|
|
||||||
"id": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送者名称
|
|
||||||
*/
|
|
||||||
"name": string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送者 IP
|
|
||||||
*/
|
|
||||||
"ip": string;
|
|
||||||
|
|
||||||
/** Creates a new Sender instance. */
|
|
||||||
constructor($$source: Partial<Sender> = {}) {
|
|
||||||
if (!("id" in $$source)) {
|
|
||||||
this["id"] = "";
|
|
||||||
}
|
|
||||||
if (!("name" in $$source)) {
|
|
||||||
this["name"] = "";
|
|
||||||
}
|
|
||||||
if (!("ip" in $$source)) {
|
|
||||||
this["ip"] = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(this, $$source);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new Sender instance from a string or object.
|
|
||||||
*/
|
|
||||||
static createFrom($$source: any = {}): Sender {
|
|
||||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
|
||||||
return new Sender($$parsedSource as Partial<Sender>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transfer
|
* Transfer
|
||||||
*/
|
*/
|
||||||
@@ -116,9 +80,10 @@ export class Transfer {
|
|||||||
/**
|
/**
|
||||||
* 发送者
|
* 发送者
|
||||||
*/
|
*/
|
||||||
"sender": Sender;
|
"sender": discovery$0.Peer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* FileName 如果 ContentType 为 file,文件名;如果 ContentType 为 folder,文件夹名;如果 ContentType 为 text,空
|
||||||
* 文件名
|
* 文件名
|
||||||
*/
|
*/
|
||||||
"file_name": string;
|
"file_name": string;
|
||||||
@@ -177,7 +142,7 @@ export class Transfer {
|
|||||||
this["create_time"] = 0;
|
this["create_time"] = 0;
|
||||||
}
|
}
|
||||||
if (!("sender" in $$source)) {
|
if (!("sender" in $$source)) {
|
||||||
this["sender"] = (new Sender());
|
this["sender"] = (new discovery$0.Peer());
|
||||||
}
|
}
|
||||||
if (!("file_name" in $$source)) {
|
if (!("file_name" in $$source)) {
|
||||||
this["file_name"] = "";
|
this["file_name"] = "";
|
||||||
@@ -256,5 +221,5 @@ export enum TransferType {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Private type creation functions
|
// Private type creation functions
|
||||||
const $$createType0 = Sender.createFrom;
|
const $$createType0 = discovery$0.Peer.createFrom;
|
||||||
const $$createType1 = Progress.createFrom;
|
const $$createType1 = Progress.createFrom;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import * as discovery$0 from "../discovery/models.js";
|
import * as discovery$0 from "../discovery/models.js";
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore: Unused imports
|
||||||
|
import * as sync$0 from "../../../sync/models.js";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
@@ -45,6 +48,12 @@ export function GetTransferList(): $CancellablePromise<($models.Transfer | null)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetTransferSyncMap(): $CancellablePromise<sync$0.Map | null> {
|
||||||
|
return $Call.ByID(2986557111).then(($result: any) => {
|
||||||
|
return $$createType4($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function LoadHistory(): $CancellablePromise<void> {
|
export function LoadHistory(): $CancellablePromise<void> {
|
||||||
return $Call.ByID(2987999795);
|
return $Call.ByID(2987999795);
|
||||||
}
|
}
|
||||||
@@ -97,3 +106,5 @@ export function StoreTransfersToList(transfers: ($models.Transfer | null)[]): $C
|
|||||||
const $$createType0 = $models.Transfer.createFrom;
|
const $$createType0 = $models.Transfer.createFrom;
|
||||||
const $$createType1 = $Create.Nullable($$createType0);
|
const $$createType1 = $Create.Nullable($$createType0);
|
||||||
const $$createType2 = $Create.Array($$createType1);
|
const $$createType2 = $Create.Array($$createType1);
|
||||||
|
const $$createType3 = sync$0.Map.createFrom;
|
||||||
|
const $$createType4 = $Create.Nullable($$createType3);
|
||||||
|
|||||||
@@ -5,8 +5,33 @@
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import { Create as $Create } from "@wailsio/runtime";
|
import { Create as $Create } from "@wailsio/runtime";
|
||||||
|
|
||||||
|
export class File {
|
||||||
|
"name": string;
|
||||||
|
"path": string;
|
||||||
|
|
||||||
|
/** Creates a new File instance. */
|
||||||
|
constructor($$source: Partial<File> = {}) {
|
||||||
|
if (!("name" in $$source)) {
|
||||||
|
this["name"] = "";
|
||||||
|
}
|
||||||
|
if (!("path" in $$source)) {
|
||||||
|
this["path"] = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this, $$source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new File instance from a string or object.
|
||||||
|
*/
|
||||||
|
static createFrom($$source: any = {}): File {
|
||||||
|
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||||
|
return new File($$parsedSource as Partial<File>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class FilesDroppedEvent {
|
export class FilesDroppedEvent {
|
||||||
"files": string[];
|
"files": File[];
|
||||||
"target": string;
|
"target": string;
|
||||||
|
|
||||||
/** Creates a new FilesDroppedEvent instance. */
|
/** Creates a new FilesDroppedEvent instance. */
|
||||||
@@ -25,7 +50,7 @@ export class FilesDroppedEvent {
|
|||||||
* Creates a new FilesDroppedEvent instance from a string or object.
|
* Creates a new FilesDroppedEvent instance from a string or object.
|
||||||
*/
|
*/
|
||||||
static createFrom($$source: any = {}): FilesDroppedEvent {
|
static createFrom($$source: any = {}): FilesDroppedEvent {
|
||||||
const $$createField0_0 = $$createType0;
|
const $$createField0_0 = $$createType1;
|
||||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||||
if ("files" in $$parsedSource) {
|
if ("files" in $$parsedSource) {
|
||||||
$$parsedSource["files"] = $$createField0_0($$parsedSource["files"]);
|
$$parsedSource["files"] = $$createField0_0($$parsedSource["files"]);
|
||||||
@@ -35,4 +60,5 @@ export class FilesDroppedEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Private type creation functions
|
// Private type creation functions
|
||||||
const $$createType0 = $Create.Array($Create.Any);
|
const $$createType0 = File.createFrom;
|
||||||
|
const $$createType1 = $Create.Array($$createType0);
|
||||||
|
|||||||
6
frontend/bindings/sync/index.ts
Normal 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";
|
||||||
52
frontend/bindings/sync/models.ts
Normal 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>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Mesh Drop</title>
|
<title>Mesh Drop</title>
|
||||||
<link rel="stylesheet" href="src/styles/style.css">
|
<link rel="stylesheet" href="src/styles/style.css">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="src/assets/icon.svg">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
71
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@mdi/font": "7.4.47",
|
"@mdi/font": "7.4.47",
|
||||||
"@wailsio/runtime": "^3.0.0-alpha.79",
|
"@wailsio/runtime": "^3.0.0-alpha.79",
|
||||||
"vue": "^3.5.21",
|
"vue": "^3.5.21",
|
||||||
|
"vue-i18n": "^11.2.8",
|
||||||
"vuetify": "^3.10.1"
|
"vuetify": "^3.10.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -509,6 +510,50 @@
|
|||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "11.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz",
|
||||||
|
"integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/message-compiler": "11.2.8",
|
||||||
|
"@intlify/shared": "11.2.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "11.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz",
|
||||||
|
"integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "11.2.8",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "11.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz",
|
||||||
|
"integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -1350,6 +1395,12 @@
|
|||||||
"@vue/shared": "3.5.27"
|
"@vue/shared": "3.5.27"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue/devtools-api": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vue/language-core": {
|
"node_modules/@vue/language-core": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.4.tgz",
|
||||||
@@ -3165,6 +3216,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "11.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
|
||||||
|
"integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "11.2.8",
|
||||||
|
"@intlify/shared": "11.2.8",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-tsc": {
|
"node_modules/vue-tsc": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@mdi/font": "7.4.47",
|
"@mdi/font": "7.4.47",
|
||||||
"@wailsio/runtime": "^3.0.0-alpha.79",
|
"@wailsio/runtime": "^3.0.0-alpha.79",
|
||||||
"vue": "^3.5.21",
|
"vue": "^3.5.21",
|
||||||
|
"vue-i18n": "^11.2.8",
|
||||||
"vuetify": "^3.10.1"
|
"vuetify": "^3.10.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
16
frontend/src/assets/icon.svg
Normal 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 |
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
// --- Vue 核心 ---
|
// --- Vue 核心 ---
|
||||||
import { onMounted, ref, computed } from "vue";
|
import { onMounted, ref, computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
// --- 组件 ---
|
// --- 组件 ---
|
||||||
import PeerCard from "./PeerCard.vue";
|
import PeerCard from "./PeerCard.vue";
|
||||||
@@ -25,6 +26,7 @@ const transferList = ref<Transfer[]>([]);
|
|||||||
const activeKey = ref("discover");
|
const activeKey = ref("discover");
|
||||||
const drawer = ref(true);
|
const drawer = ref(true);
|
||||||
const isMobile = ref(false);
|
const isMobile = ref(false);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
// --- 计算属性 ---
|
// --- 计算属性 ---
|
||||||
const pendingCount = computed(() => {
|
const pendingCount = computed(() => {
|
||||||
@@ -35,18 +37,18 @@ const pendingCount = computed(() => {
|
|||||||
|
|
||||||
const menuItems = computed(() => [
|
const menuItems = computed(() => [
|
||||||
{
|
{
|
||||||
title: "Discover",
|
title: t("menu.discover"),
|
||||||
value: "discover",
|
value: "discover",
|
||||||
icon: "mdi-radar",
|
icon: "mdi-radar",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Transfers",
|
title: t("menu.transfers"),
|
||||||
value: "transfers",
|
value: "transfers",
|
||||||
icon: "mdi-inbox",
|
icon: "mdi-inbox",
|
||||||
badge: pendingCount.value > 0 ? pendingCount.value : null,
|
badge: pendingCount.value > 0 ? pendingCount.value : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: t("menu.settings"),
|
||||||
value: "settings",
|
value: "settings",
|
||||||
icon: "mdi-cog",
|
icon: "mdi-cog",
|
||||||
},
|
},
|
||||||
@@ -66,12 +68,10 @@ onMounted(async () => {
|
|||||||
// --- 后端集成 & 事件监听 ---
|
// --- 后端集成 & 事件监听 ---
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
peers.value = await GetPeers();
|
peers.value = await GetPeers();
|
||||||
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Events.On("peers:update", (event) => {
|
Events.On("peers:update", (event) => {
|
||||||
peers.value = event.data;
|
peers.value = event.data;
|
||||||
peers.value = peers.value.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Events.On("transfer:refreshList", async () => {
|
Events.On("transfer:refreshList", async () => {
|
||||||
@@ -150,7 +150,18 @@ const handleCleanFinished = async () => {
|
|||||||
<v-container fluid class="pa-4">
|
<v-container fluid class="pa-4">
|
||||||
<!-- 发现视图 -->
|
<!-- 发现视图 -->
|
||||||
<div v-show="activeKey === 'discover'">
|
<div v-show="activeKey === 'discover'">
|
||||||
<div v-if="peers.length > 0" class="peer-grid">
|
<div v-if="peers.length > 0">
|
||||||
|
<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">
|
<div v-for="peer in peers" :key="peer.id">
|
||||||
<PeerCard
|
<PeerCard
|
||||||
:peer="peer"
|
:peer="peer"
|
||||||
@@ -158,6 +169,7 @@ const handleCleanFinished = async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -170,7 +182,7 @@ const handleCleanFinished = async () => {
|
|||||||
class="mb-4 radar-icon"
|
class="mb-4 radar-icon"
|
||||||
style="opacity: 0.5"
|
style="opacity: 0.5"
|
||||||
></v-icon>
|
></v-icon>
|
||||||
<div class="text-grey">Scanning for peers...</div>
|
<div class="text-grey">{{ t("discover.scanning") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,7 +196,7 @@ const handleCleanFinished = async () => {
|
|||||||
color="error"
|
color="error"
|
||||||
@click="handleCleanFinished"
|
@click="handleCleanFinished"
|
||||||
>
|
>
|
||||||
Clear Finished
|
{{ t("transfers.clearFinished") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<TransferItem
|
<TransferItem
|
||||||
@@ -198,7 +210,7 @@ const handleCleanFinished = async () => {
|
|||||||
class="empty-state d-flex flex-column justify-center align-center"
|
class="empty-state d-flex flex-column justify-center align-center"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-inbox" size="100" class="mb-4 text-grey"></v-icon>
|
<v-icon icon="mdi-inbox" size="100" class="mb-4 text-grey"></v-icon>
|
||||||
<div class="text-grey">No transfers yet</div>
|
<div class="text-grey">{{ t("transfers.noTransfers") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// --- Vue 核心 ---
|
// --- Vue 核心 ---
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch, onMounted, onUnmounted } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
// --- 组件 ---
|
// --- 组件 ---
|
||||||
import FileSendModal from "./modals/FileSendModal.vue";
|
import FileSendModal from "./modals/FileSendModal.vue";
|
||||||
import TextSendModal from "./modals/TextSendModal.vue";
|
import TextSendModal from "./modals/TextSendModal.vue";
|
||||||
|
|
||||||
// --- Wails & 后端绑定 ---
|
// --- Wails & 后端绑定 ---
|
||||||
import { Dialogs, Clipboard } from "@wailsio/runtime";
|
import { Dialogs, Clipboard, Events } from "@wailsio/runtime";
|
||||||
import {
|
import {
|
||||||
SendFolder,
|
SendFolder,
|
||||||
SendText,
|
SendText,
|
||||||
} from "../../bindings/mesh-drop/internal/transfer/service";
|
} from "../../bindings/mesh-drop/internal/transfer/service";
|
||||||
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
|
import { Peer } from "../../bindings/mesh-drop/internal/discovery/models";
|
||||||
|
import {
|
||||||
|
IsTrusted,
|
||||||
|
AddTrust,
|
||||||
|
RemoveTrust,
|
||||||
|
} from "../../bindings/mesh-drop/internal/config/config";
|
||||||
|
import { File } from "bindings/mesh-drop/models";
|
||||||
|
|
||||||
|
// --- 生命周期 ---
|
||||||
|
const droppedFiles = ref<File[]>([]);
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
isTrusted.value = await IsTrusted(props.peer.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to check trusted peer status:", err);
|
||||||
|
}
|
||||||
|
Events.On("files-dropped", (event) => {
|
||||||
|
droppedFiles.value = event.data.files;
|
||||||
|
showFileModal.value = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
Events.Off("files-dropped");
|
||||||
|
});
|
||||||
|
|
||||||
// --- 属性 & 事件 ---
|
// --- 属性 & 事件 ---
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
peer: Peer;
|
peer: Peer;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "transferStarted"): void;
|
(e: "transferStarted"): void;
|
||||||
}>();
|
}>();
|
||||||
@@ -27,29 +54,30 @@ const emit = defineEmits<{
|
|||||||
const selectedIp = ref<string>("");
|
const selectedIp = ref<string>("");
|
||||||
const showFileModal = ref(false);
|
const showFileModal = ref(false);
|
||||||
const showTextModal = ref(false);
|
const showTextModal = ref(false);
|
||||||
|
const isTrusted = ref(false);
|
||||||
|
|
||||||
const sendOptions = [
|
const sendOptions = computed(() => [
|
||||||
{
|
{
|
||||||
title: "Send Files",
|
title: t("discover.sendFiles"),
|
||||||
value: "files",
|
value: "files",
|
||||||
icon: "mdi-file",
|
icon: "mdi-file",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Send Folder",
|
title: t("discover.sendFolder"),
|
||||||
value: "folder",
|
value: "folder",
|
||||||
icon: "mdi-folder",
|
icon: "mdi-folder",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Send Text",
|
title: t("discover.sendText"),
|
||||||
value: "text",
|
value: "text",
|
||||||
icon: "mdi-format-font",
|
icon: "mdi-format-font",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Send Clipboard",
|
title: t("discover.sendClipboard"),
|
||||||
value: "clipboard",
|
value: "clipboard",
|
||||||
icon: "mdi-clipboard",
|
icon: "mdi-clipboard",
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
// --- 计算属性 ---
|
// --- 计算属性 ---
|
||||||
const ips = computed(() => {
|
const ips = computed(() => {
|
||||||
@@ -70,6 +98,10 @@ const osIcon = computed(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showMismatch = computed(() => {
|
||||||
|
return props.peer.trust_mismatch && isTrusted.value;
|
||||||
|
});
|
||||||
|
|
||||||
// --- 监听 ---
|
// --- 监听 ---
|
||||||
watch(
|
watch(
|
||||||
ips,
|
ips,
|
||||||
@@ -108,7 +140,7 @@ const handleAction = (key: string) => {
|
|||||||
const handleSendFolder = async () => {
|
const handleSendFolder = async () => {
|
||||||
if (!selectedIp.value) return;
|
if (!selectedIp.value) return;
|
||||||
const opts: Dialogs.OpenFileDialogOptions = {
|
const opts: Dialogs.OpenFileDialogOptions = {
|
||||||
Title: "Select folder to send",
|
Title: t("discover.selectFolder"),
|
||||||
CanChooseDirectories: true,
|
CanChooseDirectories: true,
|
||||||
CanChooseFiles: false,
|
CanChooseFiles: false,
|
||||||
AllowsMultipleSelection: false,
|
AllowsMultipleSelection: false,
|
||||||
@@ -118,7 +150,7 @@ const handleSendFolder = async () => {
|
|||||||
|
|
||||||
SendFolder(props.peer, selectedIp.value, folderPath as string).catch((e) => {
|
SendFolder(props.peer, selectedIp.value, folderPath as string).catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
alert("Failed to send folder: " + e);
|
alert(t("discover.sendFolderFailed", { error: e }));
|
||||||
});
|
});
|
||||||
emit("transferStarted");
|
emit("transferStarted");
|
||||||
};
|
};
|
||||||
@@ -127,19 +159,36 @@ const handleSendClipboard = async () => {
|
|||||||
if (!selectedIp.value) return;
|
if (!selectedIp.value) return;
|
||||||
const text = await Clipboard.Text();
|
const text = await Clipboard.Text();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
alert("Clipboard is empty");
|
alert(t("discover.clipboardEmpty"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
SendText(props.peer, selectedIp.value, text).catch((e) => {
|
SendText(props.peer, selectedIp.value, text).catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
alert("Failed to send clipboard: " + e);
|
alert(t("discover.sendClipboardFailed", { error: e }));
|
||||||
});
|
});
|
||||||
emit("transferStarted");
|
emit("transferStarted");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTrust = () => {
|
||||||
|
AddTrust(props.peer.id, props.peer.pk);
|
||||||
|
isTrusted.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUntrust = () => {
|
||||||
|
RemoveTrust(props.peer.id);
|
||||||
|
isTrusted.value = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-card hover link class="peer-card pa-2">
|
<v-card
|
||||||
|
hover
|
||||||
|
link
|
||||||
|
class="peer-card pa-2"
|
||||||
|
:ripple="false"
|
||||||
|
data-file-drop-target
|
||||||
|
:id="`drop-zone-peer-${peer.id}`"
|
||||||
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<v-icon :icon="osIcon" size="24" class="mr-2"></v-icon>
|
<v-icon :icon="osIcon" size="24" class="mr-2"></v-icon>
|
||||||
@@ -183,16 +232,41 @@ const handleSendClipboard = async () => {
|
|||||||
</v-menu>
|
</v-menu>
|
||||||
|
|
||||||
<!-- No Route -->
|
<!-- No Route -->
|
||||||
<v-chip v-else color="warning" size="small" label> No Route </v-chip>
|
<v-chip v-else color="warning" size="small" label>
|
||||||
|
{{ t("discover.noRoute") }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 拖放提示覆盖层 -->
|
||||||
|
<div class="drag-drop-overlay">
|
||||||
|
<v-icon
|
||||||
|
icon="mdi-file-upload-outline"
|
||||||
|
size="48"
|
||||||
|
color="primary"
|
||||||
|
style="opacity: 0.8"
|
||||||
|
></v-icon>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #actions>
|
<v-card-actions>
|
||||||
<v-menu>
|
<!-- Trust Mismatch Warning -->
|
||||||
|
<v-btn
|
||||||
|
v-if="showMismatch"
|
||||||
|
class="flex-grow-1"
|
||||||
|
color="warning"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-alert"
|
||||||
|
:ripple="false"
|
||||||
|
style="pointer-events: none; min-width: 0"
|
||||||
|
>
|
||||||
|
<span class="text-truncate">{{ t("discover.mismatch") }}</span>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-menu v-else>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
block
|
class="flex-grow-1"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
:disabled="ips.length === 0"
|
:disabled="ips.length === 0"
|
||||||
@@ -201,7 +275,7 @@ const handleSendClipboard = async () => {
|
|||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon icon="mdi-send"></v-icon>
|
<v-icon icon="mdi-send"></v-icon>
|
||||||
</template>
|
</template>
|
||||||
Send
|
{{ t("discover.send") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-list>
|
<v-list>
|
||||||
@@ -218,7 +292,38 @@ const handleSendClipboard = async () => {
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</template>
|
|
||||||
|
<!-- Trust Mismatch Reset Override -->
|
||||||
|
<v-btn
|
||||||
|
v-if="showMismatch"
|
||||||
|
variant="tonal"
|
||||||
|
color="error"
|
||||||
|
@click="handleUntrust"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-delete"></v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">{{
|
||||||
|
t("discover.resetTrust")
|
||||||
|
}}</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-else-if="!isTrusted"
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
@click="handleTrust"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-star-outline"></v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">{{
|
||||||
|
t("discover.trustPeer")
|
||||||
|
}}</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn v-else variant="tonal" color="primary" @click="handleUntrust">
|
||||||
|
<v-icon icon="mdi-star"></v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">{{
|
||||||
|
t("discover.untrustPeer")
|
||||||
|
}}</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- Modals -->
|
<!-- Modals -->
|
||||||
@@ -226,6 +331,7 @@ const handleSendClipboard = async () => {
|
|||||||
v-model="showFileModal"
|
v-model="showFileModal"
|
||||||
:peer="peer"
|
:peer="peer"
|
||||||
:selectedIp="selectedIp"
|
:selectedIp="selectedIp"
|
||||||
|
:files="droppedFiles"
|
||||||
@transferStarted="emit('transferStarted')"
|
@transferStarted="emit('transferStarted')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -236,3 +342,70 @@ const handleSendClipboard = async () => {
|
|||||||
@transferStarted="emit('transferStarted')"
|
@transferStarted="emit('transferStarted')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.peer-card {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-card::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgb(var(--v-theme-primary));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-card.file-drop-target-active {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px -4px rgba(var(--v-theme-primary), 0.24) !important;
|
||||||
|
border-color: rgb(var(--v-theme-primary)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-card.file-drop-target-active::after {
|
||||||
|
opacity: 0.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
background: rgba(var(--v-theme-surface), 0.8);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-card.file-drop-target-active .drag-drop-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-drop-content {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-card.file-drop-target-active .drag-drop-content {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
// --- Vue 核心 ---
|
// --- Vue 核心 ---
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
// --- Wails & 后端绑定 ---
|
// --- Wails & 后端绑定 ---
|
||||||
import { Dialogs } from "@wailsio/runtime";
|
import { Dialogs } from "@wailsio/runtime";
|
||||||
@@ -14,7 +15,12 @@ import {
|
|||||||
GetSaveHistory,
|
GetSaveHistory,
|
||||||
SetSaveHistory,
|
SetSaveHistory,
|
||||||
GetVersion,
|
GetVersion,
|
||||||
|
GetLanguage,
|
||||||
|
SetLanguage,
|
||||||
|
SetCloseToSystray,
|
||||||
|
GetCloseToSystray,
|
||||||
} from "../../bindings/mesh-drop/internal/config/config";
|
} from "../../bindings/mesh-drop/internal/config/config";
|
||||||
|
import { Language } from "bindings/mesh-drop/internal/config";
|
||||||
|
|
||||||
// --- 状态 ---
|
// --- 状态 ---
|
||||||
const savePath = ref("");
|
const savePath = ref("");
|
||||||
@@ -22,6 +28,14 @@ const hostName = ref("");
|
|||||||
const autoAccept = ref(false);
|
const autoAccept = ref(false);
|
||||||
const saveHistory = ref(false);
|
const saveHistory = ref(false);
|
||||||
const version = ref("");
|
const version = ref("");
|
||||||
|
const closeToSystray = ref(false);
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ title: "English", value: "en" },
|
||||||
|
{ title: "简体中文", value: "zh-Hans" },
|
||||||
|
];
|
||||||
|
|
||||||
// ---生命周期 ---
|
// ---生命周期 ---
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -30,12 +44,17 @@ onMounted(async () => {
|
|||||||
autoAccept.value = await GetAutoAccept();
|
autoAccept.value = await GetAutoAccept();
|
||||||
saveHistory.value = await GetSaveHistory();
|
saveHistory.value = await GetSaveHistory();
|
||||||
version.value = await GetVersion();
|
version.value = await GetVersion();
|
||||||
|
let l = await GetLanguage();
|
||||||
|
if (l != "") {
|
||||||
|
locale.value = l;
|
||||||
|
}
|
||||||
|
closeToSystray.value = await GetCloseToSystray();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 方法 ---
|
// --- 方法 ---
|
||||||
const changeSavePath = async () => {
|
const changeSavePath = async () => {
|
||||||
const opts: Dialogs.OpenFileDialogOptions = {
|
const opts: Dialogs.OpenFileDialogOptions = {
|
||||||
Title: "Select Save Path",
|
Title: t("settings.selectSavePath"),
|
||||||
CanChooseDirectories: true,
|
CanChooseDirectories: true,
|
||||||
CanChooseFiles: false,
|
CanChooseFiles: false,
|
||||||
AllowsMultipleSelection: false,
|
AllowsMultipleSelection: false,
|
||||||
@@ -46,11 +65,17 @@ const changeSavePath = async () => {
|
|||||||
savePath.value = path;
|
savePath.value = path;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 监听语言变化
|
||||||
|
watch(locale, async (newVal) => {
|
||||||
|
await SetLanguage(newVal as Language);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-list lines="one" bg-color="transparent">
|
<v-list lines="one" bg-color="transparent">
|
||||||
<v-list-item title="Save Path" :subtitle="savePath">
|
<!-- 保存路径 -->
|
||||||
|
<v-list-item :title="t('settings.savePath')" :subtitle="savePath">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon icon="mdi-folder-download"></v-icon>
|
<v-icon icon="mdi-folder-download"></v-icon>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,11 +86,13 @@ const changeSavePath = async () => {
|
|||||||
@click="changeSavePath"
|
@click="changeSavePath"
|
||||||
prepend-icon="mdi-pencil"
|
prepend-icon="mdi-pencil"
|
||||||
>
|
>
|
||||||
Change
|
{{ t("settings.change") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item title="HostName">
|
|
||||||
|
<!-- 主机名 -->
|
||||||
|
<v-list-item :title="t('settings.hostName')">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon icon="mdi-laptop"></v-icon>
|
<v-icon icon="mdi-laptop"></v-icon>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,7 +106,9 @@ const changeSavePath = async () => {
|
|||||||
></v-text-field>
|
></v-text-field>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item title="Save History">
|
|
||||||
|
<!-- 保存历史 -->
|
||||||
|
<v-list-item :title="t('settings.saveHistory')">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon icon="mdi-history"></v-icon>
|
<v-icon icon="mdi-history"></v-icon>
|
||||||
</template>
|
</template>
|
||||||
@@ -93,7 +122,9 @@ const changeSavePath = async () => {
|
|||||||
></v-switch>
|
></v-switch>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item title="Auto Accept">
|
|
||||||
|
<!-- 自动接受 -->
|
||||||
|
<v-list-item :title="t('settings.autoAccept')">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon icon="mdi-content-save"></v-icon>
|
<v-icon icon="mdi-content-save"></v-icon>
|
||||||
</template>
|
</template>
|
||||||
@@ -107,7 +138,42 @@ const changeSavePath = async () => {
|
|||||||
></v-switch>
|
></v-switch>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item title="Version">
|
|
||||||
|
<!-- 关闭窗口时最小化到托盘 -->
|
||||||
|
<v-list-item :title="t('settings.closeToSystray')">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon icon="mdi-tray"></v-icon>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<v-switch
|
||||||
|
v-model="closeToSystray"
|
||||||
|
color="primary"
|
||||||
|
inset
|
||||||
|
hide-details
|
||||||
|
@update:modelValue="SetCloseToSystray(closeToSystray)"
|
||||||
|
></v-switch>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<!-- 语言 -->
|
||||||
|
<v-list-item :title="t('settings.language')">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon icon="mdi-translate"></v-icon>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<v-select
|
||||||
|
v-model="locale"
|
||||||
|
:items="languages"
|
||||||
|
variant="underlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
width="150"
|
||||||
|
></v-select>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<!-- 版本 -->
|
||||||
|
<v-list-item :title="t('settings.version')">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon icon="mdi-information"></v-icon>
|
<v-icon icon="mdi-information"></v-icon>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// --- Vue 核心 ---
|
// --- Vue 核心 ---
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
// --- Wails & 后端绑定 ---
|
// --- Wails & 后端绑定 ---
|
||||||
import { Dialogs, Clipboard } from "@wailsio/runtime";
|
import { Dialogs, Clipboard } from "@wailsio/runtime";
|
||||||
@@ -16,6 +17,8 @@ const props = defineProps<{
|
|||||||
transfer: Transfer;
|
transfer: Transfer;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
// --- 状态 ---
|
// --- 状态 ---
|
||||||
const showContentDialog = ref(false);
|
const showContentDialog = ref(false);
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ const rejectTransfer = () => {
|
|||||||
|
|
||||||
const acceptToFolder = async () => {
|
const acceptToFolder = async () => {
|
||||||
const opts: Dialogs.OpenFileDialogOptions = {
|
const opts: Dialogs.OpenFileDialogOptions = {
|
||||||
Title: "Select Folder to save the file",
|
Title: t("transfers.selectSavePath"),
|
||||||
CanChooseDirectories: true,
|
CanChooseDirectories: true,
|
||||||
CanChooseFiles: false,
|
CanChooseFiles: false,
|
||||||
AllowsMultipleSelection: false,
|
AllowsMultipleSelection: false,
|
||||||
@@ -178,7 +181,9 @@ const handleCopy = async () => {
|
|||||||
></v-icon>
|
></v-icon>
|
||||||
{{
|
{{
|
||||||
props.transfer.file_name ||
|
props.transfer.file_name ||
|
||||||
(props.transfer.content_type === "text" ? "Text" : "Folder")
|
(props.transfer.content_type === "text"
|
||||||
|
? t("transfers.text")
|
||||||
|
: t("transfers.folder"))
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,9 +192,23 @@ const handleCopy = async () => {
|
|||||||
v-if="
|
v-if="
|
||||||
props.transfer.sender.name && props.transfer.type === 'receive'
|
props.transfer.sender.name && props.transfer.type === 'receive'
|
||||||
"
|
"
|
||||||
prepend-icon="mdi-account"
|
:color="
|
||||||
|
props.transfer.sender.trust_mismatch ? 'warning' : undefined
|
||||||
|
"
|
||||||
|
:prepend-icon="
|
||||||
|
props.transfer.sender.trust_mismatch
|
||||||
|
? 'mdi-alert'
|
||||||
|
: 'mdi-account'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ props.transfer.sender.name }}
|
{{ props.transfer.sender.name }}
|
||||||
|
<v-tooltip
|
||||||
|
v-if="props.transfer.sender.trust_mismatch"
|
||||||
|
activator="parent"
|
||||||
|
location="bottom"
|
||||||
|
>
|
||||||
|
{{ t("transfers.securityAlert") }}
|
||||||
|
</v-tooltip>
|
||||||
</v-chip>
|
</v-chip>
|
||||||
|
|
||||||
<v-chip
|
<v-chip
|
||||||
@@ -212,25 +231,25 @@ const handleCopy = async () => {
|
|||||||
v-if="props.transfer.status === 'completed'"
|
v-if="props.transfer.status === 'completed'"
|
||||||
class="text-success"
|
class="text-success"
|
||||||
>
|
>
|
||||||
- Completed
|
- {{ t("transfers.completed") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="props.transfer.status === 'error'" class="text-error">
|
<span v-if="props.transfer.status === 'error'" class="text-error">
|
||||||
- {{ props.transfer.error_msg || "Error" }}
|
- {{ props.transfer.error_msg || t("common.error") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="props.transfer.status === 'canceled'" class="text-info">
|
<span v-if="props.transfer.status === 'canceled'" class="text-info">
|
||||||
- Canceled
|
- {{ t("transfers.cancelled") }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="props.transfer.status === 'rejected'"
|
v-if="props.transfer.status === 'rejected'"
|
||||||
class="text-error"
|
class="text-error"
|
||||||
>
|
>
|
||||||
- Rejected
|
- {{ t("transfers.rejected") }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="props.transfer.status === 'pending'"
|
v-if="props.transfer.status === 'pending'"
|
||||||
class="text-warning"
|
class="text-warning"
|
||||||
>
|
>
|
||||||
- Waiting for accept
|
- {{ t("transfers.waitingForAccept") }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -250,7 +269,9 @@ const handleCopy = async () => {
|
|||||||
<v-btn-group density="compact" variant="tonal" divided rounded="xl">
|
<v-btn-group density="compact" variant="tonal" divided rounded="xl">
|
||||||
<v-btn v-if="canAccept" color="success" @click="acceptTransfer">
|
<v-btn v-if="canAccept" color="success" @click="acceptTransfer">
|
||||||
<v-icon icon="mdi-content-save"></v-icon>
|
<v-icon icon="mdi-content-save"></v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">Accept</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">{{
|
||||||
|
t("common.accept")
|
||||||
|
}}</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -260,13 +281,15 @@ const handleCopy = async () => {
|
|||||||
>
|
>
|
||||||
<v-icon icon="mdi-folder-arrow-right"></v-icon>
|
<v-icon icon="mdi-folder-arrow-right"></v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">
|
<v-tooltip activator="parent" location="bottom">
|
||||||
Save to Folder
|
{{ t("transfers.saveToFolder") }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn v-if="canAccept" color="error" @click="rejectTransfer">
|
<v-btn v-if="canAccept" color="error" @click="rejectTransfer">
|
||||||
<v-icon icon="mdi-close"></v-icon>
|
<v-icon icon="mdi-close"></v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">Reject</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">{{
|
||||||
|
t("common.reject")
|
||||||
|
}}</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -276,13 +299,15 @@ const handleCopy = async () => {
|
|||||||
>
|
>
|
||||||
<v-icon icon="mdi-eye"></v-icon>
|
<v-icon icon="mdi-eye"></v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">
|
<v-tooltip activator="parent" location="bottom">
|
||||||
View Content
|
{{ t("transfers.viewContent") }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn v-if="canCopy" color="success" @click="handleCopy">
|
<v-btn v-if="canCopy" color="success" @click="handleCopy">
|
||||||
<v-icon icon="mdi-content-copy"></v-icon>
|
<v-icon icon="mdi-content-copy"></v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">Copy</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">{{
|
||||||
|
t("common.copy")
|
||||||
|
}}</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -296,7 +321,9 @@ const handleCopy = async () => {
|
|||||||
@click="handleDelete"
|
@click="handleDelete"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-delete"></v-icon>
|
<v-icon icon="mdi-delete"></v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">Delete</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">{{
|
||||||
|
t("common.delete")
|
||||||
|
}}</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -305,7 +332,9 @@ const handleCopy = async () => {
|
|||||||
@click="CancelTransfer(props.transfer.id)"
|
@click="CancelTransfer(props.transfer.id)"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-stop"></v-icon>
|
<v-icon icon="mdi-stop"></v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">Cancel</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">{{
|
||||||
|
t("common.cancel")
|
||||||
|
}}</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-btn-group>
|
</v-btn-group>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +343,7 @@ const handleCopy = async () => {
|
|||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-dialog v-model="showContentDialog" width="600">
|
<v-dialog v-model="showContentDialog" width="600">
|
||||||
<v-card title="Text Content">
|
<v-card :title="t('transfers.textContent')">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-textarea
|
<v-textarea
|
||||||
:model-value="props.transfer.text"
|
:model-value="props.transfer.text"
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// --- Vue 核心 ---
|
// --- Vue 核心 ---
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
// --- Wails & 后端绑定 ---
|
// --- Wails & 后端绑定 ---
|
||||||
import { Events, Dialogs } from "@wailsio/runtime";
|
import { Events, Dialogs, Window } from "@wailsio/runtime";
|
||||||
import { SendFiles } from "../../../bindings/mesh-drop/internal/transfer/service";
|
import { SendFiles } from "../../../bindings/mesh-drop/internal/transfer/service";
|
||||||
import { Peer } from "../../../bindings/mesh-drop/internal/discovery/models";
|
import { Peer } from "../../../bindings/mesh-drop/internal/discovery/models";
|
||||||
|
import { File } from "bindings/mesh-drop/models";
|
||||||
|
|
||||||
|
onMounted(() => {});
|
||||||
|
|
||||||
// --- 属性 & 事件 ---
|
// --- 属性 & 事件 ---
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
peer: Peer;
|
peer: Peer;
|
||||||
selectedIp: string;
|
selectedIp: string;
|
||||||
|
files: File[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -20,7 +25,7 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
// --- 状态 ---
|
// --- 状态 ---
|
||||||
const fileList = ref<{ name: string; path: string }[]>([]);
|
const { t } = useI18n();
|
||||||
|
|
||||||
// --- 计算属性 ---
|
// --- 计算属性 ---
|
||||||
const show = computed({
|
const show = computed({
|
||||||
@@ -32,43 +37,39 @@ const show = computed({
|
|||||||
watch(show, (newVal) => {
|
watch(show, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
Events.On("files-dropped", (event) => {
|
Events.On("files-dropped", (event) => {
|
||||||
const files: string[] = event.data.files || [];
|
const files: File[] = event.data.files || [];
|
||||||
files.forEach((f) => {
|
files.forEach((f) => {
|
||||||
if (!fileList.value.find((existing) => existing.path === f)) {
|
if (!props.files.find((existing) => existing.path === f.path)) {
|
||||||
fileList.value.push({
|
props.files.push(f);
|
||||||
name: f.split(/[\/]/).pop() || f,
|
|
||||||
path: f,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Events.Off("files-dropped");
|
Events.Off("files-dropped");
|
||||||
fileList.value = [];
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 方法 ---
|
// --- 方法 ---
|
||||||
const openFileDialog = async () => {
|
const openFileDialog = async () => {
|
||||||
const files = await Dialogs.OpenFile({
|
const files = await Dialogs.OpenFile({
|
||||||
Title: "Select files to send",
|
Title: t("modal.fileSend.selectTitle"),
|
||||||
AllowsMultipleSelection: true,
|
AllowsMultipleSelection: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (files) {
|
if (files) {
|
||||||
if (Array.isArray(files)) {
|
if (Array.isArray(files)) {
|
||||||
files.forEach((f) => {
|
files.forEach((f) => {
|
||||||
if (!fileList.value.find((existing) => existing.path === f)) {
|
if (!props.files.find((existing) => existing.path === f)) {
|
||||||
fileList.value.push({
|
props.files.push({
|
||||||
name: f.split(/[\\/]/).pop() || f,
|
name: f.split(/[\/]/).pop() || f,
|
||||||
path: f,
|
path: f,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const f = files as string;
|
const f = files as string;
|
||||||
if (!fileList.value.find((existing) => existing.path === f)) {
|
if (!props.files.find((existing) => existing.path === f)) {
|
||||||
fileList.value.push({
|
props.files.push({
|
||||||
name: f.split(/[\\/]/).pop() || f,
|
name: f.split(/[\\/]/).pop() || f,
|
||||||
path: f,
|
path: f,
|
||||||
});
|
});
|
||||||
@@ -78,12 +79,12 @@ const openFileDialog = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFile = (index: number) => {
|
const handleRemoveFile = (index: number) => {
|
||||||
fileList.value.splice(index, 1);
|
props.files.splice(index, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendFiles = async () => {
|
const handleSendFiles = async () => {
|
||||||
if (fileList.value.length === 0 || !props.selectedIp) return;
|
if (props.files.length === 0 || !props.selectedIp) return;
|
||||||
const paths = fileList.value.map((f) => f.path);
|
const paths = props.files.map((f) => f.path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await SendFiles(props.peer, props.selectedIp, paths);
|
await SendFiles(props.peer, props.selectedIp, paths);
|
||||||
@@ -91,20 +92,21 @@ const handleSendFiles = async () => {
|
|||||||
show.value = false;
|
show.value = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
alert("Failed to send files: " + e);
|
alert(t("modal.fileSend.failed", { error: e }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-dialog v-model="show" width="600" persistent eager>
|
<v-dialog v-model="show" width="600" persistent eager>
|
||||||
<v-card title="Send Files">
|
<v-card :title="$t('modal.fileSend.title')">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div
|
<div
|
||||||
v-if="fileList.length === 0"
|
v-if="props.files.length === 0"
|
||||||
class="drop-zone pa-10 text-center rounded-lg border-dashed"
|
class="drop-zone pa-10 text-center rounded-lg"
|
||||||
@click="openFileDialog"
|
@click="openFileDialog"
|
||||||
data-file-drop-target
|
data-file-drop-target
|
||||||
|
id="drop-zone-area"
|
||||||
>
|
>
|
||||||
<v-icon
|
<v-icon
|
||||||
icon="mdi-cloud-upload"
|
icon="mdi-cloud-upload"
|
||||||
@@ -113,7 +115,7 @@ const handleSendFiles = async () => {
|
|||||||
class="mb-2"
|
class="mb-2"
|
||||||
></v-icon>
|
></v-icon>
|
||||||
<div class="text-body-1 text-medium-emphasis">
|
<div class="text-body-1 text-medium-emphasis">
|
||||||
Click to select files
|
{{ $t("modal.fileSend.dragDrop") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,9 +127,10 @@ const handleSendFiles = async () => {
|
|||||||
max-height="400"
|
max-height="400"
|
||||||
style="overflow-y: auto"
|
style="overflow-y: auto"
|
||||||
data-file-drop-target
|
data-file-drop-target
|
||||||
|
id="drop-zone-list"
|
||||||
>
|
>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="(file, index) in fileList"
|
v-for="(file, index) in props.files"
|
||||||
:key="file.path"
|
:key="file.path"
|
||||||
:title="file.name"
|
:title="file.name"
|
||||||
:subtitle="file.path"
|
:subtitle="file.path"
|
||||||
@@ -147,26 +150,28 @@ const handleSendFiles = async () => {
|
|||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
block
|
block
|
||||||
variant="outlined"
|
variant="tonal"
|
||||||
style="border-style: dashed"
|
|
||||||
prepend-icon="mdi-plus"
|
prepend-icon="mdi-plus"
|
||||||
@click="openFileDialog"
|
@click="openFileDialog"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
>
|
>
|
||||||
Add more files
|
{{ $t("modal.fileSend.addMore") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<v-btn variant="text" @click="show = false">Cancel</v-btn>
|
<v-btn variant="text" @click="show = false">{{
|
||||||
|
$t("common.cancel")
|
||||||
|
}}</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="handleSendFiles"
|
@click="handleSendFiles"
|
||||||
:disabled="fileList.length === 0"
|
:disabled="props.files.length === 0"
|
||||||
>
|
>
|
||||||
Send {{ fileList.length > 0 ? `(${fileList.length})` : "" }}
|
{{ $t("modal.fileSend.sendSrc") }}
|
||||||
|
{{ props.files.length > 0 ? `(${props.files.length})` : "" }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -175,17 +180,32 @@ const handleSendFiles = async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.drop-zone {
|
.drop-zone {
|
||||||
border: 2px dashed #666; /* Use a darker color or theme var */
|
border: 2px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone:hover {
|
.drop-zone:hover {
|
||||||
border-color: #38bdf8;
|
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||||
background-color: rgba(56, 189, 248, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone.file-drop-target-active {
|
.drop-zone.file-drop-target-active {
|
||||||
border-color: #38bdf8;
|
border-color: rgb(var(--v-theme-primary));
|
||||||
background-color: rgba(56, 189, 248, 0.1);
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
|
transform: scale(1.01);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#drop-zone-list {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#drop-zone-list.file-drop-target-active {
|
||||||
|
box-shadow: inset 0 0 0 2px rgb(var(--v-theme-primary));
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// --- Vue 核心 ---
|
// --- Vue 核心 ---
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref, watch, nextTick } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
// --- Wails & 后端绑定 ---
|
// --- Wails & 后端绑定 ---
|
||||||
import { SendText } from "../../../bindings/mesh-drop/internal/transfer/service";
|
import { SendText } from "../../../bindings/mesh-drop/internal/transfer/service";
|
||||||
@@ -19,7 +20,9 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
// --- 状态 ---
|
// --- 状态 ---
|
||||||
|
const { t } = useI18n();
|
||||||
const textContent = ref("");
|
const textContent = ref("");
|
||||||
|
const textareaRef = ref();
|
||||||
|
|
||||||
// --- 计算属性 ---
|
// --- 计算属性 ---
|
||||||
const show = computed({
|
const show = computed({
|
||||||
@@ -27,6 +30,14 @@ const show = computed({
|
|||||||
set: (value) => emit("update:modelValue", value),
|
set: (value) => emit("update:modelValue", value),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 监听 ---
|
||||||
|
watch(show, async (val) => {
|
||||||
|
if (val) {
|
||||||
|
await nextTick();
|
||||||
|
textareaRef.value?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- 方法 ---
|
// --- 方法 ---
|
||||||
const executeSendText = async () => {
|
const executeSendText = async () => {
|
||||||
if (!props.selectedIp || !textContent.value) return;
|
if (!props.selectedIp || !textContent.value) return;
|
||||||
@@ -38,32 +49,35 @@ const executeSendText = async () => {
|
|||||||
textContent.value = "";
|
textContent.value = "";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
alert("Failed to send text: " + e);
|
alert(t("modal.textSend.failed", { error: e }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-dialog v-model="show" width="500" persistent eager>
|
<v-dialog v-model="show" width="500" persistent eager>
|
||||||
<v-card title="Send Text">
|
<v-card :title="$t('modal.textSend.title')">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-textarea
|
<v-textarea
|
||||||
|
ref="textareaRef"
|
||||||
v-model="textContent"
|
v-model="textContent"
|
||||||
label="Content"
|
:label="$t('modal.textSend.contentLabel')"
|
||||||
placeholder="Type something to send..."
|
:placeholder="$t('modal.textSend.placeholder')"
|
||||||
rows="4"
|
rows="4"
|
||||||
auto-grow
|
auto-grow
|
||||||
></v-textarea>
|
></v-textarea>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<v-btn variant="text" @click="show = false">Cancel</v-btn>
|
<v-btn variant="text" @click="show = false">{{
|
||||||
|
$t("common.cancel")
|
||||||
|
}}</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="executeSendText"
|
@click="executeSendText"
|
||||||
:disabled="!textContent"
|
:disabled="!textContent"
|
||||||
>
|
>
|
||||||
Send
|
{{ $t("modal.textSend.send") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|||||||
85
frontend/src/locales/en.json
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
frontend/src/locales/zh-Hans.json
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/src/plugins/i18n.ts
Normal 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;
|
||||||
@@ -5,11 +5,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
import vuetify from './vuetify'
|
import vuetify from "./vuetify";
|
||||||
|
import i18n from "./i18n";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { App } from 'vue'
|
import type { App } from "vue";
|
||||||
|
|
||||||
export function registerPlugins (app: App) {
|
export function registerPlugins(app: App) {
|
||||||
app.use(vuetify)
|
app.use(vuetify);
|
||||||
|
app.use(i18n);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ body,
|
|||||||
/* 标准属性 */
|
/* 标准属性 */
|
||||||
cursor: default;
|
cursor: default;
|
||||||
/* 鼠标指针变为默认箭头,而不是文本输入的 I 形 */
|
/* 鼠标指针变为默认箭头,而不是文本输入的 I 形 */
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
|
|||||||
@@ -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
@@ -5,8 +5,7 @@ go 1.25
|
|||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.68
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.67
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -24,7 +23,6 @@ require (
|
|||||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
github.com/ebitengine/purego v0.9.1 // indirect
|
github.com/ebitengine/purego v0.9.1 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
@@ -34,7 +32,6 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
@@ -58,21 +55,14 @@ require (
|
|||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
|
||||||
github.com/samber/lo v1.52.0 // indirect
|
github.com/samber/lo v1.52.0 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
|
|||||||
26
go.sum
@@ -36,10 +36,6 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
|
|||||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
@@ -68,8 +64,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
@@ -140,8 +134,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
|
||||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
@@ -149,16 +141,6 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq
|
|||||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -170,22 +152,18 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.67 h1:cUpNk00Hvu9DMBI6bpF4xxwwzf3yT1n9l7D1WUvMrQ8=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.68 h1:CBSP9rOISKiFv6hmqVj2HsU6f4bSMQmsmuSzPQMUxSE=
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.67/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.68/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
|
||||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|
||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
|||||||
BIN
goreleaser/icon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
goreleaser/icon.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
15
goreleaser/info.json
Normal 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
@@ -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
|
||||||
22
goreleaser/wails.exe.manifest
Normal 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>
|
||||||
@@ -1,44 +1,50 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/spf13/viper"
|
"mesh-drop/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WindowState 定义窗口状态
|
// WindowState 定义窗口状态
|
||||||
type WindowState struct {
|
type WindowState struct {
|
||||||
Width int `mapstructure:"width"`
|
Width int `json:"width"`
|
||||||
Height int `mapstructure:"height"`
|
Height int `json:"height"`
|
||||||
X int `mapstructure:"x"`
|
|
||||||
Y int `mapstructure:"y"`
|
|
||||||
Maximised bool `mapstructure:"maximised"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Version = "0.0.2"
|
var Version = "next"
|
||||||
|
|
||||||
|
type Language string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LanguageEnglish Language = "en"
|
||||||
|
LanguageChinese Language = "zh-Hans"
|
||||||
|
)
|
||||||
|
|
||||||
|
type configData struct {
|
||||||
|
WindowState WindowState `json:"window_state"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
SavePath string `json:"save_path"`
|
||||||
|
HostName string `json:"host_name"`
|
||||||
|
AutoAccept bool `json:"auto_accept"`
|
||||||
|
SaveHistory bool `json:"save_history"`
|
||||||
|
TrustedPeer map[string]string `json:"trusted_peer"` // ID -> PublicKey
|
||||||
|
|
||||||
|
Language Language `json:"language"`
|
||||||
|
CloseToSystray bool `json:"close_to_systray"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
v *viper.Viper
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
data configData
|
||||||
WindowState WindowState `mapstructure:"window_state"`
|
configPath string
|
||||||
ID string `mapstructure:"id"`
|
|
||||||
SavePath string `mapstructure:"save_path"`
|
|
||||||
HostName string `mapstructure:"host_name"`
|
|
||||||
AutoAccept bool `mapstructure:"auto_accept"`
|
|
||||||
SaveHistory bool `mapstructure:"save_history"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认窗口配置
|
|
||||||
var defaultWindowState = WindowState{
|
|
||||||
Width: 1024,
|
|
||||||
Height: 768,
|
|
||||||
X: -1,
|
|
||||||
Y: -1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigDir() string {
|
func GetConfigDir() string {
|
||||||
@@ -58,45 +64,76 @@ func GetUserHomeDir() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New 读取配置
|
// New 读取配置
|
||||||
func Load() *Config {
|
func Load(defaultState WindowState) *Config {
|
||||||
v := viper.New()
|
|
||||||
configDir := GetConfigDir()
|
configDir := GetConfigDir()
|
||||||
err := os.MkdirAll(configDir, 0755)
|
_ = os.MkdirAll(configDir, 0o750)
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to create config directory", "error", err)
|
|
||||||
}
|
|
||||||
configFile := filepath.Join(configDir, "config.json")
|
configFile := filepath.Join(configDir, "config.json")
|
||||||
|
|
||||||
// 设置默认值
|
// 设置默认值
|
||||||
defaultSavePath := filepath.Join(GetUserHomeDir(), "Downloads")
|
defaultSavePath := filepath.Join(GetUserHomeDir(), "Downloads")
|
||||||
v.SetDefault("window_state", defaultWindowState)
|
|
||||||
v.SetDefault("save_path", defaultSavePath)
|
|
||||||
defaultHostName, err := os.Hostname()
|
defaultHostName, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
defaultHostName = "localhost"
|
defaultHostName = "localhost"
|
||||||
}
|
}
|
||||||
v.SetDefault("host_name", defaultHostName)
|
|
||||||
v.SetDefault("id", uuid.New().String())
|
|
||||||
v.SetDefault("save_history", true)
|
|
||||||
|
|
||||||
v.SetConfigFile(configFile)
|
cfgData := configData{
|
||||||
v.SetConfigType("json")
|
WindowState: defaultState,
|
||||||
|
SavePath: defaultSavePath,
|
||||||
|
AutoAccept: false,
|
||||||
|
SaveHistory: true,
|
||||||
|
Language: LanguageEnglish,
|
||||||
|
CloseToSystray: false,
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
HostName: defaultHostName,
|
||||||
|
TrustedPeer: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试读取配置
|
fileBytes, err := os.ReadFile(
|
||||||
if err := v.ReadInConfig(); err != nil {
|
configFile,
|
||||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
)
|
||||||
slog.Info("Config file not found, using defaults")
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
slog.Error("Failed to read config file", "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Warn("Failed to read config file, using defaults", "error", err)
|
slog.Info("Config file not found, creating new one")
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
if err := json.Unmarshal(fileBytes, &cfgData); err != nil {
|
||||||
var config Config
|
|
||||||
if err := v.Unmarshal(&config); err != nil {
|
|
||||||
slog.Error("Failed to unmarshal config", "error", err)
|
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
|
return &config
|
||||||
}
|
}
|
||||||
@@ -105,79 +142,97 @@ func Load() *Config {
|
|||||||
func (c *Config) Save() error {
|
func (c *Config) Save() error {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
return c.save()
|
||||||
|
}
|
||||||
|
|
||||||
configDir := GetConfigDir()
|
func (c *Config) save() error {
|
||||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
dir := filepath.Dir(c.configPath)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.v.WriteConfig(); err != nil {
|
jsonData, err := json.MarshalIndent(c.data, "", " ")
|
||||||
slog.Error("Failed to write config", "error", err)
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置配置文件权限为 0600 (仅所有者读写)
|
||||||
|
if c.configPath != "" {
|
||||||
|
if err := os.WriteFile(c.configPath, jsonData, 0o600); err != nil {
|
||||||
|
slog.Warn("Failed to write config file", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSavePath 修改配置
|
// update 是一个辅助函数,用于在锁保护下更新配置并保存
|
||||||
func (c *Config) SetSavePath(savePath string) {
|
func (c *Config) update(fn func()) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
c.SavePath = savePath
|
fn()
|
||||||
c.v.Set("save_path", savePath)
|
|
||||||
_ = os.MkdirAll(savePath, 0755)
|
if err := c.save(); err != nil {
|
||||||
|
slog.Error("Failed to save config", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSavePath 修改配置
|
||||||
|
func (c *Config) SetSavePath(savePath string) {
|
||||||
|
c.update(func() {
|
||||||
|
c.data.SavePath = savePath
|
||||||
|
_ = os.MkdirAll(savePath, 0o750)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetSavePath() string {
|
func (c *Config) GetSavePath() string {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return c.SavePath
|
return c.data.SavePath
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) SetHostName(hostName string) {
|
func (c *Config) SetHostName(hostName string) {
|
||||||
c.mu.Lock()
|
c.update(func() {
|
||||||
defer c.mu.Unlock()
|
c.data.HostName = hostName
|
||||||
c.HostName = hostName
|
})
|
||||||
c.v.Set("host_name", hostName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetHostName() string {
|
func (c *Config) GetHostName() string {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return c.HostName
|
return c.data.HostName
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetID() string {
|
func (c *Config) GetID() string {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return c.ID
|
return c.data.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) SetAutoAccept(autoAccept bool) {
|
func (c *Config) SetAutoAccept(autoAccept bool) {
|
||||||
c.mu.Lock()
|
c.update(func() {
|
||||||
defer c.mu.Unlock()
|
c.data.AutoAccept = autoAccept
|
||||||
c.AutoAccept = autoAccept
|
})
|
||||||
c.v.Set("auto_accept", autoAccept)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetAutoAccept() bool {
|
func (c *Config) GetAutoAccept() bool {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return c.AutoAccept
|
return c.data.AutoAccept
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) SetSaveHistory(saveHistory bool) {
|
func (c *Config) SetSaveHistory(saveHistory bool) {
|
||||||
c.mu.Lock()
|
c.update(func() {
|
||||||
defer c.mu.Unlock()
|
c.data.SaveHistory = saveHistory
|
||||||
c.SaveHistory = saveHistory
|
})
|
||||||
c.v.Set("save_history", saveHistory)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetSaveHistory() bool {
|
func (c *Config) GetSaveHistory() bool {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return c.SaveHistory
|
return c.data.SaveHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetVersion() string {
|
func (c *Config) GetVersion() string {
|
||||||
@@ -185,14 +240,77 @@ func (c *Config) GetVersion() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) SetWindowState(state WindowState) {
|
func (c *Config) SetWindowState(state WindowState) {
|
||||||
c.mu.Lock()
|
c.update(func() {
|
||||||
defer c.mu.Unlock()
|
c.data.WindowState = state
|
||||||
c.WindowState = state
|
})
|
||||||
c.v.Set("window_state", state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetWindowState() WindowState {
|
func (c *Config) GetWindowState() WindowState {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return c.WindowState
|
return c.data.WindowState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) AddTrust(peerID string, publicKey string) {
|
||||||
|
c.update(func() {
|
||||||
|
if c.data.TrustedPeer == nil {
|
||||||
|
c.data.TrustedPeer = make(map[string]string)
|
||||||
|
}
|
||||||
|
c.data.TrustedPeer[peerID] = publicKey
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetTrusted() map[string]string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.data.TrustedPeer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) RemoveTrust(peerID string) {
|
||||||
|
c.update(func() {
|
||||||
|
delete(c.data.TrustedPeer, peerID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) IsTrusted(peerID string) bool {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
_, exists := c.data.TrustedPeer[peerID]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) SetLanguage(language Language) {
|
||||||
|
c.update(func() {
|
||||||
|
c.data.Language = language
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetLanguage() Language {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.data.Language
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) SetCloseToSystray(closeToSystray bool) {
|
||||||
|
c.update(func() {
|
||||||
|
c.data.CloseToSystray = closeToSystray
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetCloseToSystray() bool {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.data.CloseToSystray
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetPrivateKey() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.data.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetPublicKey() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.data.PublicKey
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package discovery
|
package discovery
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// Peer 代表一个可达的网络端点 (Network Endpoint)。
|
// Peer 代表一个可达的网络端点 (Network Endpoint)。
|
||||||
// 注意:一个物理设备 (Device) 可能通过多个网络接口广播,因此会对应多个 Peer 结构体。
|
// 注意:一个物理设备 (Device) 可能通过多个网络接口广播,因此会对应多个 Peer 结构体。
|
||||||
@@ -20,6 +23,12 @@ type Peer struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
|
|
||||||
OS OS `json:"os"`
|
OS OS `json:"os"`
|
||||||
|
|
||||||
|
PublicKey string `json:"pk"`
|
||||||
|
|
||||||
|
// TrustMismatch 指示该节点的公钥与本地信任列表中的公钥不匹配
|
||||||
|
// 如果为 true,说明可能存在 ID 欺骗或密钥轮换
|
||||||
|
TrustMismatch bool `json:"trust_mismatch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouteState 记录单条路径的状态
|
// RouteState 记录单条路径的状态
|
||||||
@@ -42,4 +51,30 @@ type PresencePacket struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
OS OS `json:"os"`
|
OS OS `json:"os"`
|
||||||
|
PublicKey string `json:"pk"`
|
||||||
|
Signature string `json:"sig"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignPayload 生成用于签名的确定性数据
|
||||||
|
func (p *PresencePacket) SignPayload() []byte {
|
||||||
|
// 使用固定格式拼接字段,避免 JSON 序列化的不确定性
|
||||||
|
// 格式: id|name|port|os|pk
|
||||||
|
return fmt.Appendf(nil, "%s|%s|%d|%s|%s", p.ID, p.Name, p.Port, p.OS, p.PublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy 返回 Peer 的深拷贝
|
||||||
|
func (p Peer) DeepCopy() *Peer {
|
||||||
|
newPeer := p // 结构体浅拷贝 (值类型字段已复制)
|
||||||
|
|
||||||
|
// 手动深拷贝引用类型字段 (Routes)
|
||||||
|
if p.Routes != nil {
|
||||||
|
newPeer.Routes = make(map[string]*RouteState, len(p.Routes))
|
||||||
|
for k, v := range p.Routes {
|
||||||
|
// RouteState 只有值类型字段,但它是指针,所以需要新建对象并解引用赋值
|
||||||
|
stateCopy := *v
|
||||||
|
newPeer.Routes[k] = &stateCopy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &newPeer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mesh-drop/internal/config"
|
|
||||||
"net"
|
"net"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
"mesh-drop/internal/config"
|
||||||
|
"mesh-drop/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DiscoveryPort = 9988
|
DiscoveryPort = 9988
|
||||||
HeartbeatRate = 3 * time.Second
|
HeartbeatRate = 1 * time.Second
|
||||||
PeerTimeout = 10 * time.Second
|
PeerTimeout = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -26,13 +28,11 @@ type Service struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
FileServerPort int
|
FileServerPort int
|
||||||
|
|
||||||
// key 使用 peer.id 和 peer.ip 组合而成的 hash
|
// Key: peer.ID
|
||||||
peers map[string]*Peer
|
peers map[string]*Peer
|
||||||
peersMutex sync.RWMutex
|
peersMutex sync.RWMutex
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
self Peer
|
||||||
application.RegisterEvent[[]Peer]("peers:update")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(config *config.Config, app *application.App, port int) *Service {
|
func NewService(config *config.Config, app *application.App, port int) *Service {
|
||||||
@@ -42,10 +42,17 @@ func NewService(config *config.Config, app *application.App, port int) *Service
|
|||||||
config: config,
|
config: config,
|
||||||
FileServerPort: port,
|
FileServerPort: port,
|
||||||
peers: make(map[string]*Peer),
|
peers: make(map[string]*Peer),
|
||||||
|
self: Peer{
|
||||||
|
ID: config.GetID(),
|
||||||
|
Name: config.GetHostName(),
|
||||||
|
Port: port,
|
||||||
|
OS: OS(runtime.GOOS),
|
||||||
|
PublicKey: config.GetPublicKey(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetLocalIPs() ([]string, bool) {
|
func GetLocalIPs() ([]string, bool) {
|
||||||
interfaces, err := net.Interfaces()
|
interfaces, err := net.Interfaces()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to get network interfaces", "error", err, "component", "discovery")
|
slog.Error("Failed to get network interfaces", "error", err, "component", "discovery")
|
||||||
@@ -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
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +135,18 @@ func (s *Service) startBroadcasting() {
|
|||||||
Name: s.config.GetHostName(),
|
Name: s.config.GetHostName(),
|
||||||
Port: s.FileServerPort,
|
Port: s.FileServerPort,
|
||||||
OS: OS(runtime.GOOS),
|
OS: OS(runtime.GOOS),
|
||||||
|
PublicKey: s.config.GetPublicKey(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 签名
|
||||||
|
sigData := packet.SignPayload()
|
||||||
|
sig, err := security.Sign(s.config.GetPrivateKey(), sigData)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to sign discovery packet", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
packet.Signature = sig
|
||||||
|
|
||||||
data, _ := json.Marshal(packet)
|
data, _ := json.Marshal(packet)
|
||||||
for _, iface := range interfaces {
|
for _, iface := range interfaces {
|
||||||
// 过滤掉 Down 的接口和 Loopback 接口
|
// 过滤掉 Down 的接口和 Loopback 接口
|
||||||
@@ -199,12 +223,54 @@ func (s *Service) startListening() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
s.handleHeartbeat(packet, remoteAddr.IP.String())
|
// 验证签名
|
||||||
|
sig := packet.Signature
|
||||||
|
sigData := packet.SignPayload()
|
||||||
|
valid, err := security.Verify(packet.PublicKey, sigData, sig)
|
||||||
|
if err != nil || !valid {
|
||||||
|
slog.Warn(
|
||||||
|
"Received invalid discovery packet signature",
|
||||||
|
"id",
|
||||||
|
packet.ID,
|
||||||
|
"ip",
|
||||||
|
remoteAddr.IP.String(),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证身份一致性 (防止 ID 欺骗)
|
||||||
|
trustMismatch := false
|
||||||
|
trustedKeys := s.config.GetTrusted()
|
||||||
|
if knownKey, ok := trustedKeys[packet.ID]; ok {
|
||||||
|
if knownKey != packet.PublicKey {
|
||||||
|
slog.Warn(
|
||||||
|
"SECURITY ALERT: Peer ID mismatch with known public key (Spoofing attempt?)",
|
||||||
|
"id",
|
||||||
|
packet.ID,
|
||||||
|
"known_key",
|
||||||
|
knownKey,
|
||||||
|
"received_key",
|
||||||
|
packet.PublicKey,
|
||||||
|
)
|
||||||
|
trustMismatch = true
|
||||||
|
// 当发现 ID 欺骗时,不更新 peer,而是标记为 trustMismatch
|
||||||
|
// 用户可以手动重新添加信任
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 不存在于信任列表
|
||||||
|
// 存在之前在信任列表,但是不匹配被用户手动重置了,此时需要将 peer.TrustMismatch 标记为 false
|
||||||
|
// 否则在 handleHeartbeat 里会一直标记为不匹配
|
||||||
|
if peer, ok := s.peers[packet.ID]; ok {
|
||||||
|
peer.TrustMismatch = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.handleHeartbeat(packet, remoteAddr.IP.String(), trustMismatch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleHeartbeat 处理心跳包
|
// handleHeartbeat 处理心跳包
|
||||||
func (s *Service) handleHeartbeat(pkt PresencePacket, ip string) {
|
func (s *Service) handleHeartbeat(pkt PresencePacket, ip string, trustMismatch bool) {
|
||||||
s.peersMutex.Lock()
|
s.peersMutex.Lock()
|
||||||
|
|
||||||
peer, exists := s.peers[pkt.ID]
|
peer, exists := s.peers[pkt.ID]
|
||||||
@@ -221,17 +287,25 @@ func (s *Service) handleHeartbeat(pkt PresencePacket, ip string) {
|
|||||||
},
|
},
|
||||||
Port: pkt.Port,
|
Port: pkt.Port,
|
||||||
OS: pkt.OS,
|
OS: pkt.OS,
|
||||||
|
PublicKey: pkt.PublicKey,
|
||||||
|
TrustMismatch: trustMismatch,
|
||||||
}
|
}
|
||||||
s.peers[peer.ID] = peer
|
s.peers[peer.ID] = peer
|
||||||
slog.Info("New device found", "name", pkt.Name, "ip", ip, "component", "discovery")
|
slog.Info("New device found", "name", pkt.Name, "ip", ip, "component", "discovery")
|
||||||
} else {
|
} else {
|
||||||
// 更新节点
|
// 更新节点
|
||||||
|
// 只有在没有身份不匹配的情况下才更新元数据,防止欺骗攻击导致 UI 闪烁/篡改
|
||||||
|
if !trustMismatch {
|
||||||
peer.Name = pkt.Name
|
peer.Name = pkt.Name
|
||||||
peer.OS = pkt.OS
|
peer.OS = pkt.OS
|
||||||
|
peer.PublicKey = pkt.PublicKey
|
||||||
|
}
|
||||||
peer.Routes[ip] = &RouteState{
|
peer.Routes[ip] = &RouteState{
|
||||||
IP: ip,
|
IP: ip,
|
||||||
LastSeen: time.Now(),
|
LastSeen: time.Now(),
|
||||||
}
|
}
|
||||||
|
// 如果之前存在不匹配,即使这次匹配了,也不要重置,防止欺骗攻击
|
||||||
|
peer.TrustMismatch = peer.TrustMismatch || trustMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
s.peersMutex.Unlock()
|
s.peersMutex.Unlock()
|
||||||
@@ -250,7 +324,6 @@ func (s *Service) startCleanup() {
|
|||||||
|
|
||||||
for id, peer := range s.peers {
|
for id, peer := range s.peers {
|
||||||
for ip, route := range peer.Routes {
|
for ip, route := range peer.Routes {
|
||||||
// 超过10秒没心跳,认为下线
|
|
||||||
if now.Sub(route.LastSeen) > PeerTimeout {
|
if now.Sub(route.LastSeen) > PeerTimeout {
|
||||||
delete(peer.Routes, ip)
|
delete(peer.Routes, ip)
|
||||||
changed = true
|
changed = true
|
||||||
@@ -278,16 +351,27 @@ func (s *Service) Start() {
|
|||||||
go s.startCleanup()
|
go s.startCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetPeerByIP(ip string) *Peer {
|
func (s *Service) GetPeerByIP(ip string) (*Peer, bool) {
|
||||||
s.peersMutex.RLock()
|
s.peersMutex.RLock()
|
||||||
defer s.peersMutex.RUnlock()
|
defer s.peersMutex.RUnlock()
|
||||||
|
|
||||||
for _, p := range s.peers {
|
for _, p := range s.peers {
|
||||||
if p.Routes[ip] != nil {
|
if p.Routes[ip] != nil {
|
||||||
return p
|
return p.DeepCopy(), true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetPeerByID(id string) (*Peer, bool) {
|
||||||
|
s.peersMutex.RLock()
|
||||||
|
defer s.peersMutex.RUnlock()
|
||||||
|
|
||||||
|
peer, ok := s.peers[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return peer.DeepCopy(), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetPeers() []Peer {
|
func (s *Service) GetPeers() []Peer {
|
||||||
@@ -296,11 +380,18 @@ func (s *Service) GetPeers() []Peer {
|
|||||||
|
|
||||||
list := make([]Peer, 0)
|
list := make([]Peer, 0)
|
||||||
for _, p := range s.peers {
|
for _, p := range s.peers {
|
||||||
list = append(list, *p)
|
list = append(list, *p.DeepCopy())
|
||||||
}
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
return list[i].Name < list[j].Name
|
||||||
|
})
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetID() string {
|
func (s *Service) GetID() string {
|
||||||
return s.ID
|
return s.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetSelf() Peer {
|
||||||
|
return s.self
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,13 @@ func generateSelfSignedCert(certPath, keyPath string) error {
|
|||||||
// 在实际的动态环境中,我们可能希望添加所有当前接口的 IP 地址
|
// 在实际的动态环境中,我们可能希望添加所有当前接口的 IP 地址
|
||||||
// 实际上,在客户端跳过 IP 验证对于本地 P2P 来说是很常见的。
|
// 实际上,在客户端跳过 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -73,7 +79,10 @@ func generateSelfSignedCert(certPath, keyPath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer keyOut.Close()
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
internal/security/identity.go
Normal 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
|
||||||
|
}
|
||||||
@@ -10,13 +10,13 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"mesh-drop/internal/discovery"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"mesh-drop/internal/discovery"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) SendFiles(target *discovery.Peer, targetIP string, filePaths []string) {
|
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)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
stat, err := file.Stat()
|
stat, err := file.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -44,11 +51,7 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
|
|||||||
|
|
||||||
task := NewTransfer(
|
task := NewTransfer(
|
||||||
taskID,
|
taskID,
|
||||||
NewSender(
|
s.discoveryService.GetSelf(),
|
||||||
s.discoveryService.GetID(),
|
|
||||||
s.config.GetHostName(),
|
|
||||||
WithReceiverIP(targetIP, s.discoveryService),
|
|
||||||
),
|
|
||||||
WithFileName(filepath.Base(filePath)),
|
WithFileName(filepath.Base(filePath)),
|
||||||
WithFileSize(stat.Size()),
|
WithFileSize(stat.Size()),
|
||||||
WithType(TransferTypeSend),
|
WithType(TransferTypeSend),
|
||||||
@@ -58,6 +61,7 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
|
|||||||
s.StoreTransferToList(task)
|
s.StoreTransferToList(task)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
defer file.Close()
|
||||||
// 任务结束后清理 ctx
|
// 任务结束后清理 ctx
|
||||||
defer func() {
|
defer func() {
|
||||||
s.cancelMap.Delete(taskID)
|
s.cancelMap.Delete(taskID)
|
||||||
@@ -105,17 +109,21 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
|
|||||||
|
|
||||||
size, err := calculateTarSize(ctx, folderPath)
|
size, err := calculateTarSize(ctx, folderPath)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
task := NewTransfer(
|
task := NewTransfer(
|
||||||
taskID,
|
taskID,
|
||||||
NewSender(
|
s.discoveryService.GetSelf(),
|
||||||
s.discoveryService.GetID(),
|
|
||||||
s.config.GetHostName(),
|
|
||||||
WithReceiverIP(targetIP, s.discoveryService),
|
|
||||||
),
|
|
||||||
WithFileName(filepath.Base(folderPath)),
|
WithFileName(filepath.Base(folderPath)),
|
||||||
WithFileSize(size),
|
WithFileSize(size),
|
||||||
WithType(TransferTypeSend),
|
WithType(TransferTypeSend),
|
||||||
@@ -145,7 +153,13 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
|
|||||||
go func(ctx context.Context) {
|
go func(ctx context.Context) {
|
||||||
defer w.Close()
|
defer w.Close()
|
||||||
if err := streamFolderToTar(ctx, w, folderPath); err != nil {
|
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)
|
w.CloseWithError(err)
|
||||||
}
|
}
|
||||||
}(ctx)
|
}(ctx)
|
||||||
@@ -164,11 +178,7 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
|
|||||||
r := bytes.NewReader([]byte(text))
|
r := bytes.NewReader([]byte(text))
|
||||||
task := NewTransfer(
|
task := NewTransfer(
|
||||||
taskID,
|
taskID,
|
||||||
NewSender(
|
s.discoveryService.GetSelf(),
|
||||||
s.discoveryService.GetID(),
|
|
||||||
s.config.GetHostName(),
|
|
||||||
WithReceiverIP(targetIP, s.discoveryService),
|
|
||||||
),
|
|
||||||
WithFileSize(int64(len(text))),
|
WithFileSize(int64(len(text))),
|
||||||
WithType(TransferTypeSend),
|
WithType(TransferTypeSend),
|
||||||
WithContentType(ContentTypeText),
|
WithContentType(ContentTypeText),
|
||||||
@@ -211,7 +221,12 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ask 向接收端发送传输请求
|
// 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 {
|
if err := ctx.Err(); err != nil {
|
||||||
return TransferAskResponse{}, err
|
return TransferAskResponse{}, err
|
||||||
}
|
}
|
||||||
@@ -244,7 +259,14 @@ func (s *Service) ask(ctx context.Context, target *discovery.Peer, targetIP stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// processTransfer 传输数据
|
// 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() {
|
defer func() {
|
||||||
s.NotifyTransferListUpdate()
|
s.NotifyTransferListUpdate()
|
||||||
}()
|
}()
|
||||||
@@ -252,7 +274,9 @@ func (s *Service) processTransfer(ctx context.Context, askResp TransferAskRespon
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return
|
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 := uploadUrl.Query()
|
||||||
query.Add("token", askResp.Token)
|
query.Add("token", askResp.Token)
|
||||||
uploadUrl.RawQuery = query.Encode()
|
uploadUrl.RawQuery = query.Encode()
|
||||||
@@ -285,7 +309,15 @@ func (s *Service) processTransfer(ctx context.Context, askResp TransferAskRespon
|
|||||||
} else {
|
} else {
|
||||||
task.Status = TransferStatusError
|
task.Status = TransferStatusError
|
||||||
task.ErrorMsg = fmt.Sprintf("Failed to upload file: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -396,7 +428,15 @@ func streamFolderToTar(ctx context.Context, w io.Writer, srcPath string) error {
|
|||||||
if relPath == "." {
|
if relPath == "." {
|
||||||
return nil
|
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, "")
|
header, err := tar.FileInfoHeader(info, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,34 +3,42 @@ package transfer
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mesh-drop/internal/config"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"mesh-drop/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) SaveHistory() {
|
func (s *Service) SaveHistory() {
|
||||||
// 将 pending 状态的任务改为 canceled
|
if !s.config.GetSaveHistory() {
|
||||||
transferList := s.GetTransferList()
|
return
|
||||||
for _, task := range transferList {
|
|
||||||
if task.Status == TransferStatusPending {
|
|
||||||
task.Status = TransferStatusCanceled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
configDir := config.GetConfigDir()
|
configDir := config.GetConfigDir()
|
||||||
historyPath := filepath.Join(configDir, "history.json")
|
historyPath := filepath.Join(configDir, "history.json")
|
||||||
historyJson, err := json.Marshal(transferList)
|
tempPath := historyPath + ".tmp"
|
||||||
|
|
||||||
|
// 序列化传输列表
|
||||||
|
historyJson, err := json.MarshalIndent(s.GetTransferList(), "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("Failed to marshal history", "error", err, "component", "transfer")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, err := os.OpenFile(historyPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
|
||||||
if err != nil {
|
// 写入临时文件
|
||||||
|
if err := os.WriteFile(tempPath, historyJson, 0o600); err != nil {
|
||||||
|
slog.Error("Failed to write temp history file", "error", err, "component", "transfer")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
_, err = file.Write(historyJson)
|
// 原子性重命名
|
||||||
if err != nil {
|
if err := os.Rename(tempPath, historyPath); err != nil {
|
||||||
slog.Error("Failed to write history", "error", err)
|
slog.Error("Failed to rename temp history file", "error", err, "component", "transfer")
|
||||||
|
// 清理临时文件
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slog.Info("History saved successfully", "path", historyPath, "component", "transfer")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) LoadHistory() {
|
func (s *Service) LoadHistory() {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package transfer
|
package transfer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
"mesh-drop/internal/discovery"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mesh-drop/internal/discovery"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TransferStatus string
|
type TransferStatus string
|
||||||
@@ -37,7 +37,8 @@ const (
|
|||||||
type Transfer struct {
|
type Transfer struct {
|
||||||
ID string `json:"id" binding:"required"` // 传输会话 ID
|
ID string `json:"id" binding:"required"` // 传输会话 ID
|
||||||
CreateTime int64 `json:"create_time"` // 创建时间
|
CreateTime int64 `json:"create_time"` // 创建时间
|
||||||
Sender Sender `json:"sender" binding:"required"` // 发送者
|
Sender discovery.Peer `json:"sender" binding:"required"` // 发送者
|
||||||
|
// FileName 如果 ContentType 为 file,文件名;如果 ContentType 为 folder,文件夹名;如果 ContentType 为 text,空
|
||||||
FileName string `json:"file_name"` // 文件名
|
FileName string `json:"file_name"` // 文件名
|
||||||
FileSize int64 `json:"file_size"` // 文件大小 (字节)
|
FileSize int64 `json:"file_size"` // 文件大小 (字节)
|
||||||
SavePath string `json:"savePath"` // 保存路径
|
SavePath string `json:"savePath"` // 保存路径
|
||||||
@@ -53,7 +54,7 @@ type Transfer struct {
|
|||||||
|
|
||||||
type TransferOption func(*Transfer)
|
type TransferOption func(*Transfer)
|
||||||
|
|
||||||
func NewTransfer(id string, sender Sender, opts ...TransferOption) *Transfer {
|
func NewTransfer(id string, sender discovery.Peer, opts ...TransferOption) *Transfer {
|
||||||
t := &Transfer{
|
t := &Transfer{
|
||||||
ID: id,
|
ID: id,
|
||||||
CreateTime: time.Now().UnixMilli(),
|
CreateTime: time.Now().UnixMilli(),
|
||||||
@@ -122,41 +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 用户前端传输进度
|
// Progress 用户前端传输进度
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
Current int64 `json:"current"` // 当前进度
|
Current int64 `json:"current"` // 当前进度
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func (s *Service) handleAsk(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已经存在
|
// 检查是否已经存在
|
||||||
if _, exists := s.transferList.Load(task.ID); exists {
|
if _, exists := s.transfers.Load(task.ID); exists {
|
||||||
// 如果已经存在,说明是网络重试,直接忽略
|
// 如果已经存在,说明是网络重试,直接忽略
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,14 @@ func (s *Service) handleAsk(c *gin.Context) {
|
|||||||
task.DecisionChan = make(chan Decision, 1)
|
task.DecisionChan = make(chan Decision, 1)
|
||||||
s.StoreTransferToList(&task)
|
s.StoreTransferToList(&task)
|
||||||
|
|
||||||
if s.config.GetAutoAccept() {
|
// 从本地获取 peer 检查是否 mismatch
|
||||||
|
peer, ok := s.discoveryService.GetPeerByID(task.Sender.ID)
|
||||||
|
if ok {
|
||||||
|
task.Sender.TrustMismatch = peer.TrustMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.config.GetAutoAccept() ||
|
||||||
|
(s.config.IsTrusted(task.Sender.ID) && !task.Sender.TrustMismatch) {
|
||||||
task.DecisionChan <- Decision{
|
task.DecisionChan <- Decision{
|
||||||
ID: task.ID,
|
ID: task.ID,
|
||||||
Accepted: true,
|
Accepted: true,
|
||||||
@@ -54,7 +61,7 @@ func (s *Service) handleAsk(c *gin.Context) {
|
|||||||
_ = s.notifier.SendNotification(notifications.NotificationOptions{
|
_ = s.notifier.SendNotification(notifications.NotificationOptions{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Title: "File Transfer Request",
|
Title: "File Transfer Request",
|
||||||
Body: fmt.Sprintf("%s(%s) wants to transfer %s", task.Sender.Name, task.Sender.IP, task.FileName),
|
Body: fmt.Sprintf("%s wants to transfer %s", task.Sender.Name, task.FileName),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +81,11 @@ func (s *Service) handleAsk(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
task.Status = TransferStatusRejected
|
task.Status = TransferStatusRejected
|
||||||
|
c.JSON(http.StatusOK, TransferAskResponse{
|
||||||
|
ID: task.ID,
|
||||||
|
Accepted: false,
|
||||||
|
Message: "Transfer rejected",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
case <-c.Request.Context().Done():
|
case <-c.Request.Context().Done():
|
||||||
// 发送端放弃
|
// 发送端放弃
|
||||||
@@ -164,6 +176,22 @@ func (s *Service) handleUpload(c *gin.Context) {
|
|||||||
switch task.ContentType {
|
switch task.ContentType {
|
||||||
case ContentTypeFile:
|
case ContentTypeFile:
|
||||||
destPath := filepath.Join(savePath, task.FileName)
|
destPath := filepath.Join(savePath, task.FileName)
|
||||||
|
// 如果文件已存在则在文件名后追加序号
|
||||||
|
_, err := os.Stat(destPath)
|
||||||
|
counter := 1
|
||||||
|
for err == nil {
|
||||||
|
destPath = filepath.Join(
|
||||||
|
savePath,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"%s (%d)%s",
|
||||||
|
strings.TrimSuffix(task.FileName, filepath.Ext(task.FileName)),
|
||||||
|
counter,
|
||||||
|
filepath.Ext(task.FileName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
counter++
|
||||||
|
_, err = os.Stat(destPath)
|
||||||
|
}
|
||||||
file, err := os.Create(destPath)
|
file, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 接收方无法创建文件,直接报错,任务结束
|
// 接收方无法创建文件,直接报错,任务结束
|
||||||
@@ -178,17 +206,17 @@ func (s *Service) handleUpload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
s.receive(c, task, file, ctxReader)
|
s.receive(c, task, Writer{w: file, filePath: destPath}, ctxReader)
|
||||||
case ContentTypeText:
|
case ContentTypeText:
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
s.receive(c, task, &buf, ctxReader)
|
s.receive(c, task, Writer{w: &buf, filePath: ""}, ctxReader)
|
||||||
task.Text = buf.String()
|
task.Text = buf.String()
|
||||||
case ContentTypeFolder:
|
case ContentTypeFolder:
|
||||||
s.receiveFolder(c, savePath, task, ctxReader)
|
s.receiveFolder(c, savePath, task, ctxReader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxReader io.Reader) {
|
func (s *Service) receive(c *gin.Context, task *Transfer, writer Writer, ctxReader io.Reader) {
|
||||||
// 包装 reader,用于计算进度
|
// 包装 reader,用于计算进度
|
||||||
reader := &PassThroughReader{
|
reader := &PassThroughReader{
|
||||||
Reader: ctxReader,
|
Reader: ctxReader,
|
||||||
@@ -208,7 +236,13 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// 发送端断线,任务取消
|
// 发送端断线,任务取消
|
||||||
if c.Request.Context().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.ErrorMsg = "Sender disconnected"
|
||||||
task.Status = TransferStatusCanceled
|
task.Status = TransferStatusCanceled
|
||||||
return
|
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")
|
slog.Error("Failed to write file", "error", err, "component", "transfer")
|
||||||
task.Status = TransferStatusError
|
task.Status = TransferStatusError
|
||||||
task.ErrorMsg = fmt.Errorf("failed to write file: %v", err).Error()
|
task.ErrorMsg = fmt.Errorf("failed to write file: %v", err).Error()
|
||||||
|
|
||||||
|
// 删除文件
|
||||||
|
if task.ContentType == ContentTypeFile && writer.GetFilePath() != "" {
|
||||||
|
_ = os.Remove(writer.GetFilePath())
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,12 +288,25 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxR
|
|||||||
task.Status = TransferStatusCompleted
|
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()
|
defer s.NotifyTransferListUpdate()
|
||||||
|
|
||||||
// 创建根目录
|
// 创建根目录
|
||||||
destPath := filepath.Join(savePath, task.FileName)
|
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{
|
c.JSON(http.StatusInternalServerError, TransferUploadResponse{
|
||||||
ID: task.ID,
|
ID: task.ID,
|
||||||
Message: "Receiver failed to create folder",
|
Message: "Receiver failed to create folder",
|
||||||
@@ -286,7 +338,13 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if c.Request.Context().Err() != nil {
|
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.Status = TransferStatusCanceled
|
||||||
task.ErrorMsg = "Sender disconnected"
|
task.ErrorMsg = "Sender disconnected"
|
||||||
// 发送端已断开,无需也不应再发送 c.JSON
|
// 发送端已断开,无需也不应再发送 c.JSON
|
||||||
@@ -318,6 +376,14 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取绝对路径以防止 Zip Slip (G305)
|
||||||
|
// 必须先转换成绝对路径再判断
|
||||||
|
absDestPath, err := filepath.Abs(destPath)
|
||||||
|
if err != nil {
|
||||||
|
handleError(err, "resolve_abs_path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
tr := tar.NewReader(reader)
|
tr := tar.NewReader(reader)
|
||||||
for {
|
for {
|
||||||
header, err := tr.Next()
|
header, err := tr.Next()
|
||||||
@@ -328,32 +394,52 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
target := filepath.Join(destPath, header.Name)
|
target := filepath.Join(destPath, filepath.Clean(header.Name))
|
||||||
// 确保路径没有越界
|
absTarget, err := filepath.Abs(target)
|
||||||
if !strings.HasPrefix(target, filepath.Clean(destPath)+string(os.PathSeparator)) {
|
if err != nil {
|
||||||
// 非法路径
|
slog.Error("Failed to resolve absolute path", "path", target, "error", err)
|
||||||
continue
|
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 {
|
switch header.Typeflag {
|
||||||
case tar.TypeDir:
|
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)
|
slog.Error("Failed to create dir", "path", target, "error", err)
|
||||||
}
|
}
|
||||||
case tar.TypeReg:
|
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 {
|
if err != nil {
|
||||||
slog.Error("Failed to create file", "path", target, "error", err)
|
slog.Error("Failed to create file", "path", target, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nolint: gosec
|
||||||
if _, err := io.Copy(f, tr); err != nil {
|
if _, err := io.Copy(f, tr); err != nil {
|
||||||
f.Close()
|
_ = f.Close()
|
||||||
if handleError(err, "write_file_content") {
|
if handleError(err, "write_file_content") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.Close()
|
_ = f.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mesh-drop/internal/config"
|
|
||||||
"mesh-drop/internal/discovery"
|
|
||||||
"mesh-drop/internal/security"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -16,6 +13,9 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||||
|
"mesh-drop/internal/config"
|
||||||
|
"mesh-drop/internal/discovery"
|
||||||
|
"mesh-drop/internal/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -26,7 +26,7 @@ type Service struct {
|
|||||||
|
|
||||||
// pendingRequests 存储等待用户确认的通道
|
// pendingRequests 存储等待用户确认的通道
|
||||||
// Key: TransferID, Value: *Transfer
|
// Key: TransferID, Value: *Transfer
|
||||||
transferList sync.Map
|
transfers sync.Map
|
||||||
|
|
||||||
discoveryService *discovery.Service
|
discoveryService *discovery.Service
|
||||||
|
|
||||||
@@ -37,12 +37,18 @@ type Service struct {
|
|||||||
httpClient *http.Client
|
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)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
|
||||||
// 配置自定义 HTTP 客户端以跳过自签名证书验证
|
// 配置自定义 HTTP 客户端以跳过自签名证书验证
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
||||||
}
|
}
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Transport: tr,
|
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 {
|
func (s *Service) GetPort() int {
|
||||||
return s.port
|
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 {
|
func (s *Service) GetTransferList() []*Transfer {
|
||||||
var requests []*Transfer = make([]*Transfer, 0)
|
requests := make([]*Transfer, 0)
|
||||||
s.transferList.Range(func(key, value any) bool {
|
s.transfers.Range(func(key, value any) bool {
|
||||||
transfer := value.(*Transfer)
|
transfer := value.(*Transfer)
|
||||||
requests = append(requests, transfer)
|
requests = append(requests, transfer)
|
||||||
return true
|
return true
|
||||||
@@ -109,7 +115,7 @@ func (s *Service) GetTransferList() []*Transfer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetTransfer(transferID string) (*Transfer, bool) {
|
func (s *Service) GetTransfer(transferID string) (*Transfer, bool) {
|
||||||
val, ok := s.transferList.Load(transferID)
|
val, ok := s.transfers.Load(transferID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -130,13 +136,13 @@ func (s *Service) CancelTransfer(transferID string) {
|
|||||||
|
|
||||||
func (s *Service) StoreTransfersToList(transfers []*Transfer) {
|
func (s *Service) StoreTransfersToList(transfers []*Transfer) {
|
||||||
for _, transfer := range transfers {
|
for _, transfer := range transfers {
|
||||||
s.transferList.Store(transfer.ID, transfer)
|
s.transfers.Store(transfer.ID, transfer)
|
||||||
}
|
}
|
||||||
s.NotifyTransferListUpdate()
|
s.NotifyTransferListUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) StoreTransferToList(transfer *Transfer) {
|
func (s *Service) StoreTransferToList(transfer *Transfer) {
|
||||||
s.transferList.Store(transfer.ID, transfer)
|
s.transfers.Store(transfer.ID, transfer)
|
||||||
s.NotifyTransferListUpdate()
|
s.NotifyTransferListUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,13 +152,13 @@ func (s *Service) NotifyTransferListUpdate() {
|
|||||||
|
|
||||||
// CleanTransferList 清理完成的 transfer
|
// CleanTransferList 清理完成的 transfer
|
||||||
func (s *Service) CleanFinishedTransferList() {
|
func (s *Service) CleanFinishedTransferList() {
|
||||||
s.transferList.Range(func(key, value any) bool {
|
s.transfers.Range(func(key, value any) bool {
|
||||||
task := value.(*Transfer)
|
task := value.(*Transfer)
|
||||||
if task.Status == TransferStatusCompleted ||
|
if task.Status == TransferStatusCompleted ||
|
||||||
task.Status == TransferStatusError ||
|
task.Status == TransferStatusError ||
|
||||||
task.Status == TransferStatusCanceled ||
|
task.Status == TransferStatusCanceled ||
|
||||||
task.Status == TransferStatusRejected {
|
task.Status == TransferStatusRejected {
|
||||||
s.transferList.Delete(key)
|
s.transfers.Delete(key)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -160,6 +166,6 @@ func (s *Service) CleanFinishedTransferList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) DeleteTransfer(transferID string) {
|
func (s *Service) DeleteTransfer(transferID string) {
|
||||||
s.transferList.Delete(transferID)
|
s.transfers.Delete(transferID)
|
||||||
s.NotifyTransferListUpdate()
|
s.NotifyTransferListUpdate()
|
||||||
}
|
}
|
||||||
|
|||||||
16
internal/transfer/writer.go
Normal 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
@@ -3,44 +3,109 @@ package main
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mesh-drop/internal/config"
|
|
||||||
"mesh-drop/internal/discovery"
|
|
||||||
"mesh-drop/internal/transfer"
|
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
"github.com/wailsapp/wails/v3/pkg/events"
|
"github.com/wailsapp/wails/v3/pkg/events"
|
||||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
"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
|
//go:embed all:frontend/dist
|
||||||
var assets embed.FS
|
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 {
|
type FilesDroppedEvent struct {
|
||||||
Files []string `json:"files"`
|
Files []File `json:"files"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
type App struct {
|
||||||
conf := config.Load()
|
app *application.App
|
||||||
|
mainWindows *application.WebviewWindow
|
||||||
|
conf *config.Config
|
||||||
|
discoveryService *discovery.Service
|
||||||
|
transferService *transfer.Service
|
||||||
|
notifier *notifications.NotificationService
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// 设置日志
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp() *App {
|
||||||
app := application.New(application.Options{
|
app := application.New(application.Options{
|
||||||
Name: "mesh-drop",
|
Name: "MeshDrop",
|
||||||
Assets: application.AssetOptions{
|
Assets: application.AssetOptions{
|
||||||
Handler: application.AssetFileServerFS(assets),
|
Handler: application.AssetFileServerFS(assets),
|
||||||
},
|
},
|
||||||
SingleInstance: &application.SingleInstanceOptions{
|
SingleInstance: &application.SingleInstanceOptions{
|
||||||
UniqueID: "com.nite07.mesh-drop",
|
UniqueID: "com.nite07.mesh-drop",
|
||||||
},
|
},
|
||||||
|
Icon: icon,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建保存路径
|
// 获取默认屏幕大小
|
||||||
err := os.MkdirAll(conf.SavePath, 0755)
|
defaultWidth := 1024
|
||||||
if err != nil {
|
defaultHeight := 768
|
||||||
slog.Error("Failed to create save path", "path", conf.SavePath, "error", err)
|
|
||||||
|
screen := app.Screen.GetPrimary()
|
||||||
|
if screen != nil {
|
||||||
|
defaultWidth = int(float64(screen.Size.Width) * 0.8)
|
||||||
|
defaultHeight = int(float64(screen.Size.Height) * 0.8)
|
||||||
|
slog.Info(
|
||||||
|
"Primary screen found",
|
||||||
|
"width",
|
||||||
|
screen.Size.Width,
|
||||||
|
"height",
|
||||||
|
screen.Size.Height,
|
||||||
|
"defaultWidth",
|
||||||
|
defaultWidth,
|
||||||
|
"defaultHeight",
|
||||||
|
defaultHeight,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
slog.Info("No primary screen found, using defaults")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通知
|
conf := config.Load(config.WindowState{
|
||||||
|
Width: defaultWidth,
|
||||||
|
Height: defaultHeight,
|
||||||
|
})
|
||||||
|
|
||||||
|
win := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
|
Title: "MeshDrop",
|
||||||
|
Width: conf.GetWindowState().Width,
|
||||||
|
Height: conf.GetWindowState().Height,
|
||||||
|
EnableFileDrop: true,
|
||||||
|
Linux: application.LinuxWindow{
|
||||||
|
WebviewGpuPolicy: application.WebviewGpuPolicyAlways,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return &App{
|
||||||
|
app: app,
|
||||||
|
mainWindows: win,
|
||||||
|
conf: conf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) registerServices() {
|
||||||
|
// 初始化通知服务
|
||||||
notifier := notifications.New()
|
notifier := notifications.New()
|
||||||
authorized, err := notifier.RequestNotificationAuthorization()
|
authorized, err := notifier.RequestNotificationAuthorization()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,78 +118,123 @@ func main() {
|
|||||||
port := 9989
|
port := 9989
|
||||||
|
|
||||||
// 初始化发现服务
|
// 初始化发现服务
|
||||||
discoveryService := discovery.NewService(conf, app, port)
|
discoveryService := discovery.NewService(a.conf, a.app, port)
|
||||||
discoveryService.Start()
|
discoveryService.Start()
|
||||||
|
|
||||||
// 初始化传输服务
|
// 初始化传输服务
|
||||||
transferService := transfer.NewService(conf, app, notifier, port, discoveryService)
|
transferService := transfer.NewService(a.conf, a.app, notifier, port, discoveryService)
|
||||||
transferService.Start()
|
transferService.Start()
|
||||||
// 加载传输历史
|
// 加载传输历史
|
||||||
if conf.GetSaveHistory() {
|
if a.conf.GetSaveHistory() {
|
||||||
transferService.LoadHistory()
|
transferService.LoadHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("Backend Service Started", "discovery_port", discovery.DiscoveryPort, "transfer_port", port)
|
a.discoveryService = discoveryService
|
||||||
|
a.transferService = transferService
|
||||||
|
a.notifier = notifier
|
||||||
|
|
||||||
app.RegisterService(application.NewService(discoveryService))
|
a.app.RegisterService(application.NewService(discoveryService))
|
||||||
app.RegisterService(application.NewService(transferService))
|
a.app.RegisterService(application.NewService(transferService))
|
||||||
app.RegisterService(application.NewService(conf))
|
a.app.RegisterService(application.NewService(a.conf))
|
||||||
app.RegisterService(application.NewService(notifier))
|
a.app.RegisterService(application.NewService(notifier))
|
||||||
|
}
|
||||||
|
|
||||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
func (a *App) registerCustomEvents() {
|
||||||
Title: "mesh drop",
|
application.RegisterEvent[FilesDroppedEvent]("files-dropped")
|
||||||
Width: conf.WindowState.Width,
|
application.RegisterEvent[[]discovery.Peer]("peers:update")
|
||||||
Height: conf.WindowState.Height,
|
application.RegisterEvent[application.Void]("transfer:refreshList")
|
||||||
X: conf.WindowState.X,
|
}
|
||||||
Y: conf.WindowState.Y,
|
|
||||||
EnableFileDrop: true,
|
|
||||||
Linux: application.LinuxWindow{
|
|
||||||
WebviewGpuPolicy: application.WebviewGpuPolicyAlways,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
func (a *App) setupEvents() {
|
||||||
// 窗口文件拖拽事件
|
// 窗口文件拖拽事件
|
||||||
window.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
|
a.mainWindows.OnWindowEvent(
|
||||||
files := event.Context().DroppedFiles()
|
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()
|
details := event.Context().DropTargetDetails()
|
||||||
app.Event.Emit("files-dropped", FilesDroppedEvent{
|
a.app.Event.Emit("files-dropped", FilesDroppedEvent{
|
||||||
Files: files,
|
Files: files,
|
||||||
Target: details.ElementID,
|
Target: details.ElementID,
|
||||||
})
|
})
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 窗口关闭事件
|
// 窗口关闭事件
|
||||||
window.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) {
|
a.mainWindows.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) {
|
||||||
x, y := window.Position()
|
if a.conf.GetCloseToSystray() {
|
||||||
width, height := window.Size()
|
event.Cancel()
|
||||||
conf.SetWindowState(config.WindowState{
|
a.mainWindows.Hide()
|
||||||
X: x,
|
return
|
||||||
Y: y,
|
}
|
||||||
Width: width,
|
|
||||||
Height: height,
|
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() {
|
if a.conf.GetSaveHistory() {
|
||||||
transferService.SaveHistory()
|
// 将 pending 状态的任务改为 canceled
|
||||||
|
a.transferService.GetTransferSyncMap().Range(func(key, value any) bool {
|
||||||
|
t := value.(*transfer.Transfer)
|
||||||
|
if t.Status == transfer.TransferStatusPending {
|
||||||
|
t.Status = transfer.TransferStatusCanceled
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
// 保存传输历史
|
||||||
|
a.transferService.SaveHistory()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 保存配置
|
func (a *App) setupSystray() {
|
||||||
err := conf.Save()
|
systray := a.app.SystemTray.New()
|
||||||
if err != nil {
|
systray.SetIcon(icon)
|
||||||
slog.Error("Failed to save config", "error", err)
|
systray.SetLabel("Mesh Drop")
|
||||||
|
|
||||||
|
menu := a.app.NewMenu()
|
||||||
|
menu.Add("Quit").OnClick(func(ctx *application.Context) {
|
||||||
|
a.app.Quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
systray.OnClick(func() {
|
||||||
|
if a.mainWindows.IsVisible() {
|
||||||
|
a.mainWindows.Hide()
|
||||||
|
} else {
|
||||||
|
a.mainWindows.Show()
|
||||||
|
a.mainWindows.Focus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
application.RegisterEvent[FilesDroppedEvent]("files-dropped")
|
systray.SetMenu(menu)
|
||||||
|
}
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
func (a *App) Run() {
|
||||||
Level: slog.LevelDebug,
|
a.registerServices()
|
||||||
}))
|
a.setupSystray()
|
||||||
slog.SetDefault(logger)
|
a.registerCustomEvents()
|
||||||
|
a.setupEvents()
|
||||||
err = app.Run()
|
err := a.app.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := NewApp()
|
||||||
|
app.Run()
|
||||||
|
}
|
||||||
|
|||||||