This commit is contained in:
2026-02-04 03:55:38 +08:00
parent 208786aa90
commit 62bc88477a
6 changed files with 302 additions and 72 deletions

View File

@@ -1,59 +1,8 @@
# Welcome to Your New Wails3 Project! TODO
Congratulations on generating your Wails3 application! This README will guide you through the next steps to get your project up and running. - [ ] 断点续传
- [x] 加密传输
## Getting Started - [x] 剪辑板传输
- [ ] 文件夹传输
1. Navigate to your project directory in the terminal. - [x] 多样化图标
- [ ] 取消传输
2. To run your application in development mode, use the following command:
```
wails3 dev
```
This will start your application and enable hot-reloading for both frontend and backend changes.
3. To build your application for production, use:
```
wails3 build
```
This will create a production-ready executable in the `build` directory.
## Exploring Wails3 Features
Now that you have your project set up, it's time to explore the features that Wails3 offers:
1. **Check out the examples**: The best way to learn is by example. Visit the `examples` directory in the `v3/examples` directory to see various sample applications.
2. **Run an example**: To run any of the examples, navigate to the example's directory and use:
```
go run .
```
Note: Some examples may be under development during the alpha phase.
3. **Explore the documentation**: Visit the [Wails3 documentation](https://v3.wails.io/) for in-depth guides and API references.
4. **Join the community**: Have questions or want to share your progress? Join the [Wails Discord](https://discord.gg/JDdSxwjhGf) or visit the [Wails discussions on GitHub](https://github.com/wailsapp/wails/discussions).
## Project Structure
Take a moment to familiarize yourself with your project structure:
- `frontend/`: Contains your frontend code (HTML, CSS, JavaScript/TypeScript)
- `main.go`: The entry point of your Go backend
- `app.go`: Define your application structure and methods here
- `wails.json`: Configuration file for your Wails project
## Next Steps
1. Modify the frontend in the `frontend/` directory to create your desired UI.
2. Add backend functionality in `main.go`.
3. Use `wails3 dev` to see your changes in real-time.
4. When ready, build your application with `wails3 build`.
Happy coding with Wails3! If you encounter any issues or have questions, don't hesitate to consult the documentation or reach out to the Wails community.

View File

@@ -35,6 +35,10 @@ export function SendFile(target: discovery$0.Peer | null, targetIP: string, file
return $Call.ByID(2954589433, target, targetIP, filePath); return $Call.ByID(2954589433, target, targetIP, filePath);
} }
export function SendFolder(target: discovery$0.Peer | null, targetIP: string, folderPath: string): $CancellablePromise<void> {
return $Call.ByID(3258308403, target, targetIP, folderPath);
}
export function SendText(target: discovery$0.Peer | null, targetIP: string, text: string): $CancellablePromise<void> { export function SendText(target: discovery$0.Peer | null, targetIP: string, text: string): $CancellablePromise<void> {
return $Call.ByID(1497421440, target, targetIP, text); return $Call.ByID(1497421440, target, targetIP, text);
} }

View File

@@ -40,6 +40,7 @@ import {
GetTransferList, GetTransferList,
SendFile, SendFile,
SendText, SendText,
SendFolder,
} from "../../bindings/mesh-drop/internal/transfer/service"; } from "../../bindings/mesh-drop/internal/transfer/service";
import { Dialogs, Clipboard } from "@wailsio/runtime"; import { Dialogs, Clipboard } from "@wailsio/runtime";
@@ -115,8 +116,6 @@ const pendingCount = computed(() => {
// --- 操作 --- // --- 操作 ---
const dialog = useDialog();
const handleSendFile = async (ip: string) => { const handleSendFile = async (ip: string) => {
try { try {
const filePath = await Dialogs.OpenFile({ const filePath = await Dialogs.OpenFile({
@@ -134,9 +133,21 @@ const handleSendFile = async (ip: string) => {
}; };
const handleSendFolder = async (ip: string) => { const handleSendFolder = async (ip: string) => {
// TODO const opts: Dialogs.OpenFileDialogOptions = {
Title: "Select folder to send",
CanChooseDirectories: true,
CanChooseFiles: false,
AllowsMultipleSelection: false,
};
const folderPath = await Dialogs.OpenFile(opts);
if (!folderPath) return;
const peer = await GetPeerByIP(ip);
if (!peer) return;
await SendFolder(peer, ip, folderPath as string);
activeKey.value = "transfers";
}; };
const dialog = useDialog();
const handleSendText = (ip: string) => { const handleSendText = (ip: string) => {
const textContent = ref(""); const textContent = ref("");
const d = dialog.create({ const d = dialog.create({

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed, h } from "vue";
import { import {
NCard, NCard,
NButton, NButton,
@@ -9,6 +9,7 @@ import {
NText, NText,
NTag, NTag,
useMessage, useMessage,
NInput,
} from "naive-ui"; } from "naive-ui";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { import {
@@ -16,12 +17,17 @@ import {
faArrowDown, faArrowDown,
faCircleExclamation, faCircleExclamation,
faUser, faUser,
faFile,
faFileLines,
faFolder,
} 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 } from "../../bindings/mesh-drop/internal/transfer/service";
import { Dialogs, Clipboard } from "@wailsio/runtime"; import { Dialogs, Clipboard } from "@wailsio/runtime";
import { useDialog } from "naive-ui";
const props = defineProps<{ const props = defineProps<{
transfer: Transfer; transfer: Transfer;
}>(); }>();
@@ -86,6 +92,20 @@ const handleCopy = async () => {
message.error("Failed to copy to clipboard"); message.error("Failed to copy to clipboard");
}); });
}; };
const dialog = useDialog();
const handleOpen = async () => {
const d = dialog.create({
title: "Text Content",
content: () =>
h(NInput, {
value: props.transfer.text,
readonly: true,
type: "textarea",
rows: 10,
}),
});
};
</script> </script>
<template> <template>
@@ -114,15 +134,27 @@ const handleCopy = async () => {
v-if="props.transfer.content_type === 'file'" v-if="props.transfer.content_type === 'file'"
strong strong
class="filename" class="filename"
:title="props.transfer.file_name" :title="props.transfer.file_name">
>{{ props.transfer.file_name }}</n-text <n-icon>
> <FontAwesomeIcon :icon="faFile" />
</n-icon>
{{ props.transfer.file_name }}
</n-text>
<n-text <n-text
v-else-if="props.transfer.content_type === 'text'" v-else-if="props.transfer.content_type === 'text'"
strong strong
class="filename" class="filename"
title="Text" title="Text">
>Text</n-text <n-icon> <FontAwesomeIcon :icon="faFileLines" /> </n-icon>
Text</n-text
>
<n-text
v-else-if="props.transfer.content_type === 'folder'"
strong
class="filename"
title="Folder">
<n-icon> <FontAwesomeIcon :icon="faFolder" /> </n-icon>
{{ props.transfer.file_name || "Folder" }}</n-text
> >
<n-tag <n-tag
size="small" size="small"
@@ -208,7 +240,7 @@ const handleCopy = async () => {
</n-space> </n-space>
</div> </div>
<!-- 复制按钮 --> <!-- 文本传输按钮 -->
<div <div
class="actions-wrapper" class="actions-wrapper"
v-if=" v-if="
@@ -217,6 +249,9 @@ const handleCopy = async () => {
props.transfer.content_type === 'text' props.transfer.content_type === 'text'
"> ">
<n-space> <n-space>
<n-button size="small" type="success" @click="handleOpen"
>Open</n-button
>
<n-button size="small" type="success" @click="handleCopy" <n-button size="small" type="success" @click="handleCopy"
>Copy</n-button >Copy</n-button
> >

View File

@@ -1,11 +1,13 @@
package transfer package transfer
import ( import (
"archive/tar"
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"math"
"mesh-drop/internal/discovery" "mesh-drop/internal/discovery"
"net/http" "net/http"
"net/url" "net/url"
@@ -44,6 +46,39 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
s.processTransfer(target, targetIP, task, file) s.processTransfer(target, targetIP, task, file)
} }
func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath string) {
size, err := calculateTarSize(folderPath)
if err != nil {
slog.Error("Failed to calculate folder size", "path", folderPath, "error", err, "component", "transfer-client")
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{
ID: uuid.New().String(),
FileName: filepath.Base(folderPath),
FileSize: size,
Sender: Sender{
ID: s.discoveryService.GetID(),
Name: s.discoveryService.GetName(),
},
Type: TransferTypeSend,
Status: TransferStatusPending,
ContentType: ContentTypeFolder,
}
s.processTransfer(target, targetIP, task, r)
}
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)) reader := bytes.NewReader([]byte(text))
task := Transfer{ task := Transfer{
@@ -62,6 +97,116 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
s.processTransfer(target, targetIP, task, reader) s.processTransfer(target, targetIP, task, reader)
} }
type countWriter struct {
n int64
}
func (w *countWriter) Write(p []byte) (int, error) {
w.n += int64(len(p))
return len(p), nil
}
func calculateTarSize(srcPath string) (int64, error) {
var size int64
err := filepath.Walk(srcPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 计算相对路径
relPath, err := filepath.Rel(srcPath, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
// 使用 tar.FileInfoHeader 计算 header
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
// 保持与 streamFolderToTar 一致
header.Name = filepath.ToSlash(relPath)
if info.IsDir() {
header.Name += "/"
}
cw := &countWriter{}
tw := tar.NewWriter(cw)
if err := tw.WriteHeader(header); err != nil {
return err
}
// tw.WriteHeader 写入 header blocks包括扩展头
size += cw.n
if !info.IsDir() {
// 文件内容大小 + 填充
fileSize := info.Size()
blocks := math.Ceil(float64(fileSize) / 512)
size += int64(blocks) * 512
}
return nil
})
// 两个 512 字节的空块作为结束标记
size += 1024
return size, err
}
func streamFolderToTar(w io.Writer, srcPath string) error {
tw := tar.NewWriter(w)
defer tw.Close()
return filepath.Walk(srcPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(srcPath, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
slog.Debug("Processing file", "path", path, "relPath", relPath, "component", "transfer-client")
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
// tar 文件名使用正斜杠
header.Name = filepath.ToSlash(relPath)
if info.IsDir() {
header.Name += "/"
}
if err := tw.WriteHeader(header); err != nil {
return err
}
if !info.IsDir() {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(tw, file); err != nil {
return err
}
}
return nil
})
}
func (s *Service) processTransfer(target *discovery.Peer, targetIP string, task Transfer, payload io.Reader) { func (s *Service) processTransfer(target *discovery.Peer, targetIP string, task Transfer, payload io.Reader) {
s.transferList.Store(task.ID, task) s.transferList.Store(task.ID, task)
s.app.Event.Emit("transfer:refreshList") s.app.Event.Emit("transfer:refreshList")

View File

@@ -1,6 +1,7 @@
package transfer package transfer
import ( import (
"archive/tar"
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
@@ -9,6 +10,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -215,7 +217,7 @@ func (s *Service) handleUpload(c *gin.Context) {
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)
} }
} }
@@ -230,7 +232,7 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer) {
Total: total, Total: total,
Speed: speed, Speed: speed,
} }
s.transferList.Store(task.ID, task) s.transferList.Store(task.ID, *task)
s.app.Event.Emit("transfer:refreshList") s.app.Event.Emit("transfer:refreshList")
}, },
} }
@@ -245,7 +247,7 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer) {
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
@@ -257,7 +259,91 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer io.Writer) {
}) })
// 传输成功,任务结束 // 传输成功,任务结束
task.Status = TransferStatusCompleted task.Status = TransferStatusCompleted
s.transferList.Store(task.ID, task) s.transferList.Store(task.ID, *task)
s.app.Event.Emit("transfer:refreshList")
}
func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer) {
// 创建根目录
destPath := filepath.Join(savePath, task.FileName)
if err := os.MkdirAll(destPath, 0755); err != nil {
c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID,
Message: "Receiver failed to create folder",
})
return
}
// 包装 reader用于计算进度
reader := &PassThroughReader{
Reader: c.Request.Body,
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")
},
}
tr := tar.NewReader(reader)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID,
Message: "Stream error",
})
slog.Error("Tar stream error", "error", err)
return
}
target := filepath.Join(destPath, header.Name)
// 确保路径没有越界
if !strings.HasPrefix(target, filepath.Clean(destPath)+string(os.PathSeparator)) {
// 非法路径
continue
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0755); err != nil {
slog.Error("Failed to create dir", "path", target, "error", err)
}
case tar.TypeReg:
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
if err != nil {
slog.Error("Failed to create file", "path", target, "error", err)
continue
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
slog.Error("Failed to write file", "path", target, "error", err)
c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID,
Message: "Write error",
})
return
}
f.Close()
}
}
c.JSON(http.StatusOK, TransferUploadResponse{
ID: task.ID,
Message: "Folder received successfully",
})
task.Progress.Total = task.FileSize
task.Progress.Current = task.FileSize
task.Status = TransferStatusCompleted
s.transferList.Store(task.ID, *task)
s.app.Event.Emit("transfer:refreshList") s.app.Event.Emit("transfer:refreshList")
} }