add: cancel transfer

This commit is contained in:
2026-02-04 15:06:41 +08:00
parent c2f3c2c3df
commit 68533dad31
9 changed files with 529 additions and 221 deletions

View File

@@ -4,4 +4,5 @@ TODO
- [x] 文件夹传输 - [x] 文件夹传输
- [x] 多样化图标 - [x] 多样化图标
- [ ] 加密传输 - [ ] 加密传输
- [ ] 取消传输 - [x] 取消传输
- [ ] 多文件发送

View File

@@ -13,10 +13,21 @@ import * as discovery$0 from "../discovery/models.js";
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import * as $models from "./models.js"; import * as $models from "./models.js";
export function CancelTransfer(transferID: string): $CancellablePromise<void> {
return $Call.ByID(900002248, transferID);
}
export function GetPort(): $CancellablePromise<number> { export function GetPort(): $CancellablePromise<number> {
return $Call.ByID(4195335736); return $Call.ByID(4195335736);
} }
export function GetTransfer(transferID: string): $CancellablePromise<[$models.Transfer, boolean]> {
return $Call.ByID(1198637268, transferID).then(($result: any) => {
$result[0] = $$createType0($result[0]);
return $result;
});
}
export function GetTransferList(): $CancellablePromise<$models.Transfer[]> { export function GetTransferList(): $CancellablePromise<$models.Transfer[]> {
return $Call.ByID(584162076).then(($result: any) => { return $Call.ByID(584162076).then(($result: any) => {
return $$createType1($result); return $$createType1($result);

View File

@@ -51,9 +51,10 @@ const showMobileMenu = ref(false);
const isMobile = ref(false); const isMobile = ref(false);
// 监听窗口大小变化更新 isMobile // 监听窗口大小变化更新 isMobile
onMounted(() => { onMounted(async () => {
checkMobile(); checkMobile();
window.addEventListener("resize", checkMobile); window.addEventListener("resize", checkMobile);
transferList.value = await GetTransferList();
}); });
const checkMobile = () => { const checkMobile = () => {
@@ -83,7 +84,12 @@ const menuOptions = computed<MenuOption[]>(() => [
[ [
"Transfers", "Transfers",
pendingCount.value > 0 ? pendingCount.value > 0 ?
h(NBadge, { value: pendingCount.value, max: 99, type: "error" }) h(NBadge, {
style: "display: inline-flex; align-items: center",
value: pendingCount.value,
max: 99,
type: "error",
})
: null, : null,
], ],
), ),
@@ -124,8 +130,8 @@ const handleSendFile = async (ip: string) => {
if (!filePath) return; if (!filePath) return;
const peer = await GetPeerByIP(ip); const peer = await GetPeerByIP(ip);
if (!peer) return; if (!peer) return;
await SendFile(peer, ip, filePath);
activeKey.value = "transfers"; activeKey.value = "transfers";
await SendFile(peer, ip, filePath);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
alert("Failed to send file: " + e); alert("Failed to send file: " + e);
@@ -143,8 +149,8 @@ const handleSendFolder = async (ip: string) => {
if (!folderPath) return; if (!folderPath) return;
const peer = await GetPeerByIP(ip); const peer = await GetPeerByIP(ip);
if (!peer) return; if (!peer) return;
await SendFolder(peer, ip, folderPath as string);
activeKey.value = "transfers"; activeKey.value = "transfers";
await SendFolder(peer, ip, folderPath as string);
}; };
const dialog = useDialog(); const dialog = useDialog();
@@ -167,8 +173,8 @@ const handleSendText = (ip: string) => {
try { try {
const peer = await GetPeerByIP(ip); const peer = await GetPeerByIP(ip);
if (!peer) return; if (!peer) return;
await SendText(peer, ip, textContent.value);
activeKey.value = "transfers"; activeKey.value = "transfers";
await SendText(peer, ip, textContent.value);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
alert("Failed to send text: " + e); alert("Failed to send text: " + e);
@@ -185,8 +191,8 @@ const handleSendClipboard = async (ip: string) => {
} }
const peer = await GetPeerByIP(ip); const peer = await GetPeerByIP(ip);
if (!peer) return; if (!peer) return;
await SendText(peer, ip, text);
activeKey.value = "transfers"; activeKey.value = "transfers";
await SendText(peer, ip, text);
}; };
const removeTransfer = (id: string) => { const removeTransfer = (id: string) => {

View File

@@ -23,7 +23,10 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { Transfer } from "../../bindings/mesh-drop/internal/transfer"; import { Transfer } from "../../bindings/mesh-drop/internal/transfer";
import { ResolvePendingRequest } from "../../bindings/mesh-drop/internal/transfer/service"; import {
ResolvePendingRequest,
CancelTransfer,
} from "../../bindings/mesh-drop/internal/transfer/service";
import { Dialogs, Clipboard } from "@wailsio/runtime"; import { Dialogs, Clipboard } from "@wailsio/runtime";
import { useDialog } from "naive-ui"; import { useDialog } from "naive-ui";
@@ -106,6 +109,27 @@ const handleOpen = async () => {
}), }),
}); });
}; };
const canCancel = computed(() => {
if (
props.transfer.status === "completed" ||
props.transfer.status === "error" ||
props.transfer.status === "canceled" ||
props.transfer.status === "rejected"
) {
return false;
}
if (props.transfer.type === "send") {
return true;
} else if (props.transfer.type === "receive") {
// 接收端在 pending 状态只能拒绝不能取消
if (props.transfer.status === "pending") {
return false;
}
return true;
}
return false;
});
</script> </script>
<template> <template>
@@ -177,19 +201,31 @@ const handleOpen = async () => {
<!-- 状态文本进行中/已完成 --> <!-- 状态文本进行中/已完成 -->
<span> <span>
<n-text depth="3" v-if="props.transfer.status === 'active'"> <n-text depth="3" v-if="props.transfer.status === 'active'">
- {{ formatSpeed(props.transfer.progress.speed) }}</n-text &nbsp;- {{ formatSpeed(props.transfer.progress.speed) }}</n-text
> >
<n-text <n-text
depth="3" depth="3"
v-if="props.transfer.status === 'completed'" v-if="props.transfer.status === 'completed'"
type="success"> type="success">
- Completed</n-text &nbsp;- Completed</n-text
> >
<n-text <n-text
depth="3" depth="3"
v-if="props.transfer.status === 'error'" v-if="props.transfer.status === 'error'"
type="error"> type="error">
- {{ props.transfer.error_msg || "Error" }}</n-text &nbsp;- {{ props.transfer.error_msg || "Error" }}</n-text
>
<n-text
depth="3"
v-if="props.transfer.status === 'canceled'"
type="error">
&nbsp;- Canceled</n-text
>
<n-text
depth="3"
v-if="props.transfer.status === 'rejected'"
type="error">
&nbsp;- Rejected</n-text
> >
</span> </span>
</div> </div>
@@ -258,17 +294,16 @@ const handleOpen = async () => {
</n-space> </n-space>
</div> </div>
<!-- 发送方取消按钮 --> <!-- 取消按钮 -->
<div <div class="actions-wrapper" v-if="canCancel">
class="actions-wrapper"
v-if="
props.transfer.type === 'send' &&
props.transfer.status !== 'completed'
">
<n-space> <n-space>
<n-button size="small" type="error" ghost @click=""> <n-button
Cancel size="small"
</n-button> type="error"
ghost
@click="CancelTransfer(props.transfer.id)"
>Cancel</n-button
>
</n-space> </n-space>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,9 @@ package transfer
import ( import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@@ -18,6 +20,16 @@ import (
) )
func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath string) { func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath string) {
taskID := uuid.New().String()
ctx, cancel := context.WithCancel(context.Background())
s.cancelMap.Store(taskID, cancel)
// 任务结束后清理 ctx
defer func() {
s.cancelMap.Delete(taskID)
cancel()
}()
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")
@@ -31,7 +43,7 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
} }
task := Transfer{ task := Transfer{
ID: uuid.New().String(), ID: taskID,
FileName: filepath.Base(filePath), FileName: filepath.Base(filePath),
FileSize: stat.Size(), FileSize: stat.Size(),
Sender: Sender{ Sender: Sender{
@@ -43,28 +55,52 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
ContentType: ContentTypeFile, ContentType: ContentTypeFile,
} }
s.processTransfer(target, targetIP, task, file) s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
askResp, err := s.ask(ctx, target, targetIP, task)
if err != nil {
if errors.Is(err, context.Canceled) {
task.Status = TransferStatusCanceled
} else {
// 如果请求发送失败,更新状态为 Error
task.Status = TransferStatusError
task.ErrorMsg = fmt.Sprintf("Failed to connect to receiver: %v", err)
}
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
if askResp.Accepted {
s.processTransfer(ctx, askResp, target, targetIP, task, file)
} else {
// 接收方拒绝
task.Status = TransferStatusRejected
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
} }
func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath string) { func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath string) {
size, err := calculateTarSize(folderPath) taskID := uuid.New().String()
ctx, cancel := context.WithCancel(context.Background())
s.cancelMap.Store(taskID, cancel)
// 任务结束后清理 ctx
defer func() {
s.cancelMap.Delete(taskID)
cancel()
}()
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
} }
r, w := io.Pipe()
go func() {
defer w.Close()
if err := streamFolderToTar(w, folderPath); err != nil {
slog.Error("Failed to stream folder to tar", "error", err, "component", "transfer-client")
w.CloseWithError(err)
}
}()
task := Transfer{ task := Transfer{
ID: uuid.New().String(), ID: taskID,
FileName: filepath.Base(folderPath), FileName: filepath.Base(folderPath),
FileSize: size, FileSize: size,
Sender: Sender{ Sender: Sender{
@@ -76,13 +112,51 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
ContentType: ContentTypeFolder, ContentType: ContentTypeFolder,
} }
s.processTransfer(target, targetIP, task, r) s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
askResp, err := s.ask(ctx, target, targetIP, task)
if err != nil {
// 如果请求发送失败,更新状态为 Error
task.Status = TransferStatusError
task.ErrorMsg = fmt.Sprintf("Failed to connect to receiver: %v", err)
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
if askResp.Accepted {
r, w := io.Pipe()
go func(ctx context.Context) {
defer w.Close()
if err := streamFolderToTar(ctx, w, folderPath); err != nil {
slog.Error("Failed to stream folder to tar", "error", err, "component", "transfer-client")
w.CloseWithError(err)
}
}(ctx)
s.processTransfer(ctx, askResp, target, targetIP, task, r)
} else {
// 接收方拒绝
task.Status = TransferStatusRejected
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
} }
func (s *Service) SendText(target *discovery.Peer, targetIP string, text string) { func (s *Service) SendText(target *discovery.Peer, targetIP string, text string) {
reader := bytes.NewReader([]byte(text)) taskID := uuid.New().String()
ctx, cancel := context.WithCancel(context.Background())
s.cancelMap.Store(taskID, cancel)
// 任务结束后清理 ctx
defer func() {
s.cancelMap.Delete(taskID)
cancel()
}()
r := bytes.NewReader([]byte(text))
task := Transfer{ task := Transfer{
ID: uuid.New().String(), ID: taskID,
FileName: "", FileName: "",
FileSize: int64(len(text)), FileSize: int64(len(text)),
Sender: Sender{ Sender: Sender{
@@ -94,7 +168,135 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
ContentType: ContentTypeText, ContentType: ContentTypeText,
} }
s.processTransfer(target, targetIP, task, reader) s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
askResp, err := s.ask(ctx, target, targetIP, task)
if err != nil {
// 如果请求发送失败,更新状态为 Error
task.Status = TransferStatusError
task.ErrorMsg = fmt.Sprintf("Failed to connect to receiver: %v", err)
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
if askResp.Accepted {
s.processTransfer(ctx, askResp, target, targetIP, task, r)
} else {
// 接收方拒绝
task.Status = TransferStatusRejected
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
}
// ask 向接收端发送传输请求
func (s *Service) ask(ctx context.Context, target *discovery.Peer, targetIP string, task Transfer) (TransferAskResponse, error) {
if err := ctx.Err(); err != nil {
return TransferAskResponse{}, err
}
// 发送请求
askBody, _ := json.Marshal(task)
askUrl := fmt.Sprintf("http://%s:%d/transfer/ask", targetIP, target.Port)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, askUrl, bytes.NewReader(askBody))
req.Header.Set("Content-Type", "application/json")
if err != nil {
return TransferAskResponse{}, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return TransferAskResponse{}, err
}
defer resp.Body.Close()
var askResp TransferAskResponse
if err := json.NewDecoder(resp.Body).Decode(&askResp); err != nil {
return TransferAskResponse{}, err
}
if resp.StatusCode != http.StatusOK {
return TransferAskResponse{}, errors.New(askResp.Message)
}
return askResp, nil
}
// processTransfer 传输数据
func (s *Service) processTransfer(ctx context.Context, askResp TransferAskResponse, target *discovery.Peer, targetIP string, task Transfer, payload io.Reader) {
if err := ctx.Err(); err != nil {
return
}
uploadUrl, _ := url.Parse(fmt.Sprintf("http://%s:%d/transfer/upload/%s", targetIP, target.Port, task.ID))
query := uploadUrl.Query()
query.Add("token", askResp.Token)
uploadUrl.RawQuery = query.Encode()
reader := &PassThroughReader{
Reader: payload,
total: task.FileSize,
callback: func(current, total int64, speed float64) {
task.Progress = Progress{
Current: current,
Total: total,
Speed: speed,
}
task.Status = TransferStatusActive
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadUrl.String(), reader)
if err != nil {
return
}
req.ContentLength = task.FileSize
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
if errors.Is(err, context.Canceled) {
task.Status = TransferStatusCanceled
} else {
task.Status = TransferStatusError
task.ErrorMsg = fmt.Sprintf("Failed to upload file: %v", err)
slog.Error("Failed to upload file", "url", uploadUrl.String(), "error", err, "component", "transfer-client")
}
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
defer resp.Body.Close()
var uploadResp TransferUploadResponse
if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil {
return
}
if resp.StatusCode != http.StatusOK {
task.Status = TransferStatusError
task.ErrorMsg = uploadResp.Message
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
// 判断任务完成还是被接收端取消
if uploadResp.Status == TransferStatusCanceled {
task.Status = TransferStatusCanceled
task.ErrorMsg = uploadResp.Message
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
// 传输成功,任务结束
task.Status = TransferStatusCompleted
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
} }
type countWriter struct { type countWriter struct {
@@ -106,12 +308,15 @@ func (w *countWriter) Write(p []byte) (int, error) {
return len(p), nil return len(p), nil
} }
func calculateTarSize(srcPath string) (int64, error) { func calculateTarSize(ctx context.Context, srcPath string) (int64, error) {
var size int64 var size int64
err := filepath.Walk(srcPath, func(path string, info os.FileInfo, err error) error { err := filepath.Walk(srcPath, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
if ctx.Err() != nil {
return ctx.Err()
}
// 计算相对路径 // 计算相对路径
relPath, err := filepath.Rel(srcPath, path) relPath, err := filepath.Rel(srcPath, path)
@@ -158,7 +363,7 @@ func calculateTarSize(srcPath string) (int64, error) {
return size, err return size, err
} }
func streamFolderToTar(w io.Writer, srcPath string) error { func streamFolderToTar(ctx context.Context, w io.Writer, srcPath string) error {
tw := tar.NewWriter(w) tw := tar.NewWriter(w)
defer tw.Close() defer tw.Close()
@@ -166,6 +371,9 @@ func streamFolderToTar(w io.Writer, srcPath string) error {
if err != nil { if err != nil {
return err return err
} }
if ctx.Err() != nil {
return ctx.Err()
}
relPath, err := filepath.Rel(srcPath, path) relPath, err := filepath.Rel(srcPath, path)
if err != nil { if err != nil {
@@ -206,98 +414,3 @@ func streamFolderToTar(w io.Writer, srcPath string) error {
return nil return nil
}) })
} }
func (s *Service) processTransfer(target *discovery.Peer, targetIP string, task Transfer, payload io.Reader) {
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
// 发送请求
askBody, _ := json.Marshal(task)
askUrl := fmt.Sprintf("http://%s:%d/transfer/ask", targetIP, target.Port)
resp, err := http.Post(askUrl, "application/json", bytes.NewReader(askBody))
if err != nil {
slog.Error("Failed to send ask request", "url", askUrl, "error", err, "component", "transfer-client")
// 如果请求发送失败,更新状态为 Error
task.Status = TransferStatusError
task.ErrorMsg = fmt.Sprintf("Failed to connect to receiver: %v", err)
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
defer resp.Body.Close()
var askResp TransferAskResponse
if err := json.NewDecoder(resp.Body).Decode(&askResp); err != nil {
return
}
if resp.StatusCode != http.StatusOK {
task.Status = TransferStatusError
task.ErrorMsg = askResp.Message
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
if !askResp.Accepted {
// 接收方拒绝
task.Status = TransferStatusRejected
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
// 上传
uploadUrl, _ := url.Parse(fmt.Sprintf("http://%s:%d/transfer/upload/%s", targetIP, target.Port, task.ID))
query := uploadUrl.Query()
query.Add("token", askResp.Token)
uploadUrl.RawQuery = query.Encode()
reader := &PassThroughReader{
Reader: payload,
total: task.FileSize,
callback: func(current, total int64, speed float64) {
task.Progress = Progress{
Current: current,
Total: total,
Speed: speed,
}
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
},
}
req, err := http.NewRequest(http.MethodPut, uploadUrl.String(), reader)
if err != nil {
return
}
req.ContentLength = task.FileSize
req.Header.Set("Content-Type", "application/octet-stream")
resp, err = http.DefaultClient.Do(req)
if err != nil {
slog.Error("Failed to upload file", "url", uploadUrl.String(), "error", err, "component", "transfer-client")
return
}
defer resp.Body.Close()
var uploadResp TransferUploadResponse
if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil {
return
}
if resp.StatusCode != http.StatusOK {
task.Status = TransferStatusError
task.ErrorMsg = uploadResp.Message
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
return
}
// 传输成功,任务结束
task.Status = TransferStatusCompleted
s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList")
}

View File

@@ -73,6 +73,7 @@ type TransferAskResponse struct {
// TransferUploadResponse 上传回应 // TransferUploadResponse 上传回应
type TransferUploadResponse struct { type TransferUploadResponse struct {
ID string `json:"id"` // 传输会话 ID ID string `json:"id"` // 传输会话 ID
Message string `json:"message"` Message string `json:"message"`
Status TransferStatus `json:"status"`
} }

View File

@@ -0,0 +1,21 @@
package transfer
import (
"context"
"io"
)
// ContextReader 带有 Context 的 Reader
type ContextReader struct {
ctx context.Context
r io.Reader
}
func (cr *ContextReader) Read(p []byte) (n int, err error) {
select {
case <-cr.ctx.Done():
return 0, cr.ctx.Err() // 返回 context.Canceled 错误
default:
return cr.r.Read(p)
}
}

View File

@@ -3,74 +3,24 @@ package transfer
import ( import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"context"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"mesh-drop/internal/discovery"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/wailsapp/wails/v3/pkg/application"
) )
type Service struct {
app *application.App
port int
savePath string // 默认下载目录
// pendingRequests 存储等待用户确认的通道
// Key: TransferID, Value: Transfer
transferList sync.Map
discoveryService *discovery.Service
}
func NewService(app *application.App, port int, defaultSavePath string, discoveryService *discovery.Service) *Service {
gin.SetMode(gin.ReleaseMode)
return &Service{
app: app,
port: port,
savePath: defaultSavePath,
discoveryService: discoveryService,
}
}
func init() {
application.RegisterEvent[application.Void]("transfer:refreshList")
}
func (s *Service) GetPort() int {
return s.port
}
func (s *Service) Start() {
r := gin.Default()
transfer := r.Group("/transfer")
{
transfer.POST("/ask", s.handleAsk)
transfer.PUT("/upload/:id", s.handleUpload)
}
go func() {
addr := fmt.Sprintf(":%d", s.port)
slog.Info("Transfer service listening", "address", addr, "component", "transfer")
if err := r.Run(addr); err != nil {
slog.Error("Transfer service error", "error", err, "component", "transfer")
}
}()
}
// handleAsk 处理接收文件请求 // handleAsk 处理接收文件请求
func (s *Service) handleAsk(c *gin.Context) { func (s *Service) handleAsk(c *gin.Context) {
var task Transfer var task Transfer
// Gin 的 BindJSON 自动处理 JSON 解析
if err := c.ShouldBindJSON(&task); err != nil { if err := c.ShouldBindJSON(&task); err != nil {
c.JSON(http.StatusBadRequest, TransferAskResponse{ c.JSON(http.StatusBadRequest, TransferAskResponse{
ID: task.ID, ID: task.ID,
@@ -113,7 +63,8 @@ func (s *Service) handleAsk(c *gin.Context) {
Accepted: decision.Accepted, Accepted: decision.Accepted,
Token: task.Token, Token: task.Token,
}) })
case <-c.Done(): s.app.Event.Emit("transfer:refreshList")
case <-c.Request.Context().Done():
// 发送端放弃 // 发送端放弃
task.Status = TransferStatusCanceled task.Status = TransferStatusCanceled
s.transferList.Store(task.ID, task) s.transferList.Store(task.ID, task)
@@ -147,6 +98,7 @@ func (s *Service) handleUpload(c *gin.Context) {
c.JSON(http.StatusBadRequest, TransferUploadResponse{ c.JSON(http.StatusBadRequest, TransferUploadResponse{
ID: id, ID: id,
Message: "Invalid request: missing id or token", Message: "Invalid request: missing id or token",
Status: TransferStatusError,
}) })
return return
} }
@@ -157,16 +109,24 @@ func (s *Service) handleUpload(c *gin.Context) {
c.JSON(http.StatusUnauthorized, TransferUploadResponse{ c.JSON(http.StatusUnauthorized, TransferUploadResponse{
ID: id, ID: id,
Message: "Invalid request: task not found", Message: "Invalid request: task not found",
Status: TransferStatusError,
}) })
return return
} }
task := val.(Transfer) task := val.(Transfer)
ctx, cancel := context.WithCancel(c.Request.Context())
s.cancelMap.Store(task.ID, cancel)
defer func() {
s.cancelMap.Delete(task.ID)
cancel()
}()
// 校验 token // 校验 token
if task.Token != token { if task.Token != token {
c.JSON(http.StatusUnauthorized, TransferUploadResponse{ c.JSON(http.StatusUnauthorized, TransferUploadResponse{
ID: id, ID: id,
Message: "Token mismatch", Message: "Token mismatch",
Status: TransferStatusError,
}) })
return return
} }
@@ -176,6 +136,7 @@ func (s *Service) handleUpload(c *gin.Context) {
c.JSON(http.StatusForbidden, TransferUploadResponse{ c.JSON(http.StatusForbidden, TransferUploadResponse{
ID: id, ID: id,
Message: "Invalid task status", Message: "Invalid task status",
Status: TransferStatusError,
}) })
return return
} }
@@ -190,6 +151,11 @@ func (s *Service) handleUpload(c *gin.Context) {
savePath = s.savePath savePath = s.savePath
} }
ctxReader := &ContextReader{
ctx: ctx,
r: c.Request.Body,
}
switch task.ContentType { switch task.ContentType {
case ContentTypeFile: case ContentTypeFile:
destPath := filepath.Join(savePath, task.FileName) destPath := filepath.Join(savePath, task.FileName)
@@ -199,6 +165,7 @@ func (s *Service) handleUpload(c *gin.Context) {
c.JSON(http.StatusInternalServerError, TransferUploadResponse{ c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID, ID: task.ID,
Message: "Receiver failed to create file", Message: "Receiver failed to create file",
Status: TransferStatusError,
}) })
slog.Error("Failed to create file", "error", err, "component", "transfer") slog.Error("Failed to create file", "error", err, "component", "transfer")
task.Status = TransferStatusError task.Status = TransferStatusError
@@ -209,22 +176,22 @@ func (s *Service) handleUpload(c *gin.Context) {
return return
} }
defer file.Close() defer file.Close()
s.receive(c, &task, file) s.receive(c, &task, file, ctxReader)
case ContentTypeText: case ContentTypeText:
var buf bytes.Buffer var buf bytes.Buffer
s.receive(c, &task, &buf) s.receive(c, &task, &buf, ctxReader)
task.Text = buf.String() task.Text = buf.String()
s.transferList.Store(task.ID, task) s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList") s.app.Event.Emit("transfer:refreshList")
case ContentTypeFolder: case ContentTypeFolder:
s.receiveFolder(c, savePath, &task) s.receiveFolder(c, savePath, &task, ctxReader)
} }
} }
func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer) { func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer, ctxReader io.Reader) {
// 包装 reader用于计算进度 // 包装 reader用于计算进度
reader := &PassThroughReader{ reader := &PassThroughReader{
Reader: c.Request.Body, Reader: ctxReader,
total: task.FileSize, total: task.FileSize,
callback: func(current, total int64, speed float64) { callback: func(current, total int64, speed float64) {
task.Progress = Progress{ task.Progress = Progress{
@@ -239,16 +206,42 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer) {
_, err := io.Copy(writer, reader) _, err := io.Copy(writer, reader)
if err != nil { if err != nil {
// 文件写入失败,直接报错,任务结束 // 发送端断线,任务取消
if c.Request.Context().Err() != nil {
slog.Info("Sender canceled transfer (Network/Context disconnected)", "id", task.ID, "raw_err", err)
task.ErrorMsg = "Sender disconnected"
task.Status = TransferStatusCanceled
s.transferList.Store(task.ID, *task)
s.app.Event.Emit("transfer:refreshList")
return
}
// 用户取消传输
if errors.Is(err, context.Canceled) {
slog.Info("User canceled transfer", "component", "transfer")
task.ErrorMsg = "User canceled transfer"
task.Status = TransferStatusCanceled
// 通知发送端
c.JSON(http.StatusOK, TransferUploadResponse{
ID: task.ID,
Message: "File transfer canceled",
Status: TransferStatusCanceled,
})
s.transferList.Store(task.ID, *task)
s.app.Event.Emit("transfer:refreshList")
return
}
// 接收端写文件失败
c.JSON(http.StatusInternalServerError, TransferUploadResponse{ c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID, ID: task.ID,
Message: "Failed to write file", Message: "Failed to write file",
Status: TransferStatusError,
}) })
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()
s.transferList.Store(task.ID, *task) s.transferList.Store(task.ID, *task)
// 通知前端传输失败
s.app.Event.Emit("transfer:refreshList") s.app.Event.Emit("transfer:refreshList")
return return
} }
@@ -256,6 +249,7 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer) {
c.JSON(http.StatusOK, TransferUploadResponse{ c.JSON(http.StatusOK, TransferUploadResponse{
ID: task.ID, ID: task.ID,
Message: "File received successfully", Message: "File received successfully",
Status: TransferStatusCompleted,
}) })
// 传输成功,任务结束 // 传输成功,任务结束
task.Status = TransferStatusCompleted task.Status = TransferStatusCompleted
@@ -263,20 +257,26 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer) {
s.app.Event.Emit("transfer:refreshList") s.app.Event.Emit("transfer:refreshList")
} }
func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer) { func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer, ctxReader io.Reader) {
// 创建根目录 // 创建根目录
destPath := filepath.Join(savePath, task.FileName) destPath := filepath.Join(savePath, task.FileName)
if err := os.MkdirAll(destPath, 0755); err != nil { if err := os.MkdirAll(destPath, 0755); err != nil {
c.JSON(http.StatusInternalServerError, TransferUploadResponse{ c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID, ID: task.ID,
Message: "Receiver failed to create folder", Message: "Receiver failed to create folder",
Status: TransferStatusError,
}) })
slog.Error("Failed to create folder", "error", err, "component", "transfer")
task.Status = TransferStatusError
task.ErrorMsg = fmt.Errorf("receiver failed to create folder: %v", err).Error()
s.transferList.Store(task.ID, *task)
s.app.Event.Emit("transfer:refreshList")
return return
} }
// 包装 reader用于计算进度 // 包装 reader用于计算进度
reader := &PassThroughReader{ reader := &PassThroughReader{
Reader: c.Request.Body, Reader: ctxReader,
total: task.FileSize, total: task.FileSize,
callback: func(current, total int64, speed float64) { callback: func(current, total int64, speed float64) {
task.Progress = Progress{ task.Progress = Progress{
@@ -289,18 +289,56 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer)
}, },
} }
handleError := func(err error, stage string) bool {
if err == nil {
return false
}
if c.Request.Context().Err() != nil {
slog.Info("Transfer canceled by sender (Network disconnect)", "id", task.ID, "stage", stage)
task.Status = TransferStatusCanceled
task.ErrorMsg = "Sender disconnected"
// 发送端已断开,无需也不应再发送 c.JSON
s.transferList.Store(task.ID, *task)
s.app.Event.Emit("transfer:refreshList")
return true
}
if errors.Is(err, context.Canceled) {
slog.Info("Transfer canceled by user", "id", task.ID, "stage", stage)
task.Status = TransferStatusCanceled
task.ErrorMsg = "User canceled transfer"
// 通知发送端(虽然此时连接可能即将关闭,但尽力通知)
c.JSON(http.StatusOK, TransferUploadResponse{
ID: task.ID,
Message: "File transfer canceled",
Status: TransferStatusCanceled,
})
s.transferList.Store(task.ID, *task)
s.app.Event.Emit("transfer:refreshList")
return true
}
slog.Error("Transfer failed", "error", err, "stage", stage)
task.Status = TransferStatusError
task.ErrorMsg = fmt.Sprintf("Failed at %s: %v", stage, err)
c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID,
Message: fmt.Sprintf("Transfer failed: %v", err),
Status: TransferStatusError,
})
s.transferList.Store(task.ID, *task)
s.app.Event.Emit("transfer:refreshList")
return true
}
tr := tar.NewReader(reader) tr := tar.NewReader(reader)
for { for {
header, err := tr.Next() header, err := tr.Next()
if err == io.EOF { if err == io.EOF {
break break
} }
if err != nil { if handleError(err, "read_tar_header") {
c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID,
Message: "Stream error",
})
slog.Error("Tar stream error", "error", err)
return return
} }
@@ -325,12 +363,9 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer)
if _, err := io.Copy(f, tr); err != nil { if _, err := io.Copy(f, tr); err != nil {
f.Close() f.Close()
slog.Error("Failed to write file", "path", target, "error", err) if handleError(err, "write_file_content") {
c.JSON(http.StatusInternalServerError, TransferUploadResponse{ return
ID: task.ID, }
Message: "Write error",
})
return
} }
f.Close() f.Close()
} }
@@ -346,12 +381,3 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer)
s.transferList.Store(task.ID, *task) s.transferList.Store(task.ID, *task)
s.app.Event.Emit("transfer:refreshList") s.app.Event.Emit("transfer:refreshList")
} }
func (s *Service) GetTransferList() []Transfer {
var requests []Transfer
s.transferList.Range(func(key, value any) bool {
requests = append(requests, value.(Transfer))
return true
})
return requests
}

View File

@@ -0,0 +1,94 @@
package transfer
import (
"context"
"fmt"
"log/slog"
"mesh-drop/internal/discovery"
"sync"
"github.com/gin-gonic/gin"
"github.com/wailsapp/wails/v3/pkg/application"
)
type Service struct {
app *application.App
port int
savePath string // 默认下载目录
// pendingRequests 存储等待用户确认的通道
// Key: TransferID, Value: Transfer
transferList sync.Map
discoveryService *discovery.Service
// cancelMap 存储取消操作的通道
// Key: TransferID, Value: context.CancelFunc
cancelMap sync.Map
}
func NewService(app *application.App, port int, defaultSavePath string, discoveryService *discovery.Service) *Service {
gin.SetMode(gin.ReleaseMode)
return &Service{
app: app,
port: port,
savePath: defaultSavePath,
discoveryService: discoveryService,
}
}
func init() {
application.RegisterEvent[application.Void]("transfer:refreshList")
}
func (s *Service) GetPort() int {
return s.port
}
func (s *Service) Start() {
r := gin.Default()
transfer := r.Group("/transfer")
{
transfer.POST("/ask", s.handleAsk)
transfer.PUT("/upload/:id", s.handleUpload)
}
go func() {
addr := fmt.Sprintf(":%d", s.port)
slog.Info("Transfer service listening", "address", addr, "component", "transfer")
if err := r.Run(addr); err != nil {
slog.Error("Transfer service error", "error", err, "component", "transfer")
}
}()
}
func (s *Service) GetTransferList() []Transfer {
var requests []Transfer
s.transferList.Range(func(key, value any) bool {
requests = append(requests, value.(Transfer))
return true
})
return requests
}
func (s *Service) GetTransfer(transferID string) (Transfer, bool) {
val, ok := s.transferList.Load(transferID)
if !ok {
return Transfer{}, false
}
return val.(Transfer), true
}
func (s *Service) CancelTransfer(transferID string) {
if cancel, ok := s.cancelMap.Load(transferID); ok {
cancel.(context.CancelFunc)()
s.cancelMap.Delete(transferID)
t, ok := s.GetTransfer(transferID)
if ok {
t.Status = TransferStatusCanceled
s.transferList.Store(transferID, t)
s.app.Event.Emit("transfer:refreshList")
}
}
}