-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.go
More file actions
341 lines (319 loc) · 9.64 KB
/
Copy pathapp.go
File metadata and controls
341 lines (319 loc) · 9.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"unsafe"
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/sys/windows"
)
// FileEntry 表示一个文件或文件夹条目
type FileEntry struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"` // 文件夹为 -1
ModTime string `json:"modTime"`
ModTimeUnix int64 `json:"modTimeUnix"`
IconType string `json:"iconType"`
Icon string `json:"icon"` // 系统图标(PNG data URI),空=用 emoji
}
// ListResult 目录列表结果
type ListResult struct {
Path string `json:"path"`
Parent string `json:"parent"`
Entries []FileEntry `json:"entries"`
}
// App struct
type App struct {
ctx context.Context
}
// startupArg 启动参数:外部程序通过 %V 传入的文件夹路径或 CLSID
var startupArg string
const openPathFile = "filemanager_open_path.txt"
// GetStartupPath 返回本次启动要打开的路径;空表示打开"此电脑"。
// 仅处理外部打开请求(单实例唤醒时新实例把参数写入临时文件)。
// 该请求是一次性的:返回后清空 startupArg,避免残留导致后续
// Win+E 唤醒等场景反复跳转到旧路径。
func (a *App) GetStartupPath() string {
p := filepath.Join(os.TempDir(), openPathFile)
if data, err := os.ReadFile(p); err == nil {
s := strings.TrimSpace(string(data))
_ = os.Remove(p)
if s != "" {
startupArg = s
}
}
if strings.HasPrefix(startupArg, "::{") {
startupArg = "" // CLSID 虚拟对象(此电脑等)→ 打开此电脑页,并消费请求
return ""
}
s := startupArg
startupArg = "" // 消费请求:只生效一次
return s
}
// GetDefaultStartupPath 返回按设置决定的默认启动路径:
// thispc=此电脑、last=上次浏览的标签页路径、custom=用户自定义路径。
// 路径失效时回退到"此电脑"(空字符串)。
func (a *App) GetDefaultStartupPath() string {
s, err := LoadSettings()
if err != nil {
return ""
}
var path string
switch s.StartupMode {
case "last":
path = s.LastTabPath
case "custom":
path = s.StartupPath
default:
return ""
}
if path == "" {
return ""
}
if info, err := os.Stat(path); err == nil && info.IsDir() {
return path
}
return ""
}
// SaveLastTabPath 记录最近浏览的标签页路径(用于"last"启动模式)
func (a *App) SaveLastTabPath(path string) {
if path == "" || strings.HasPrefix(path, "::") {
return
}
s, err := LoadSettings()
if err != nil {
return
}
if s.LastTabPath == path {
return
}
s.LastTabPath = path
_ = s.Save()
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
wailsReady = true
// 应用已保存设置:Win+E 接管 + 系统图标开关
if s, err := LoadSettings(); err == nil {
if s.WinEEnabled {
applyWinEHook(true)
}
sysIconsEnabled = s.UseSystemIcons
}
// 启动系统托盘
startTray(ctx)
// 轮询外部打开请求(单实例模式下新实例把路径写入临时文件后退出)
go watchOpenPathFile(ctx)
}
// watchOpenPathFile 轮询外部打开请求文件:
// 单实例模式下,新实例把路径写入 %TEMP%\filemanager_open_path.txt 后退出,
// 本线程发现文件后发出 open-path 事件通知前端跳转(旧实例收不到新实例的
// 事件,必须由主实例自己轮询)。
func watchOpenPathFile(ctx context.Context) {
p := filepath.Join(os.TempDir(), openPathFile)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
data, err := os.ReadFile(p)
if err != nil {
continue
}
s := strings.TrimSpace(string(data))
if s == "" {
continue // 可能是写入中间态,保留文件等下次轮询
}
_ = os.Remove(p)
// 仅真实路径走 open-path 事件;CLSID 虚拟对象(此电脑等)由
// 窗口恢复逻辑兜底,不在此重复新建标签
if strings.HasPrefix(s, "::{") {
continue
}
runtime.EventsEmit(ctx, "open-path", s)
}
}
}
// ListDirectory 列出目录内容:文件夹在前,按名称不区分大小写排序
func (a *App) ListDirectory(path string) (*ListResult, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("无法访问 %s:%s", path, osErrText(err))
}
if !info.IsDir() {
return nil, fmt.Errorf("不是文件夹:%s", path)
}
// OneDrive 等"云占位目录"(reparse point)用 shell API 枚举,
// 避免 FindFirstFile 触发 OneDrive 下载目录全部内容;失败回退 os.ReadDir。
entries, err := listDirEntries(path)
if err != nil {
return nil, fmt.Errorf("读取目录失败:%s", osErrText(err))
}
result := &ListResult{
Path: filepath.Clean(path),
Parent: filepath.Dir(path),
Entries: []FileEntry{}, // 显式空切片:空目录序列化为 [] 而非 null
}
sort.Slice(entries, func(i, j int) bool {
ei, ej := entries[i], entries[j]
if ei.IsDir != ej.IsDir {
return ei.IsDir
}
return strings.ToLower(ei.Name) < strings.ToLower(ej.Name)
})
result.Entries = entries
return result, nil
}
// listDirEntries 枚举目录条目:reparse 目录走 shell API(不触发云盘下载),否则 os.ReadDir
func listDirEntries(path string) ([]FileEntry, error) {
if isReparseDir(path) {
if entries, err := listDirShell(path); err == nil {
return entries, nil
}
// shell 枚举失败(特殊 shell 对象等)回退 Win32 枚举
}
return listDirReadDir(path)
}
// listDirReadDir 用 os.ReadDir 枚举目录(Win32 FindFirstFile)。
// 注意:枚举"云占位目录"会触发 OneDrive 下载,调用方应先用 listDirEntries 分流。
func listDirReadDir(path string) ([]FileEntry, error) {
entries, err := os.ReadDir(path)
if err != nil {
return nil, err
}
result := []FileEntry{}
for _, de := range entries {
full := filepath.Join(path, de.Name())
e := FileEntry{
Name: de.Name(),
Path: full,
IsDir: de.IsDir(),
Size: -1,
ModTime: "未知",
ModTimeUnix: 0,
}
if fi, err := de.Info(); err == nil {
e.ModTime = fi.ModTime().Format("2006-01-02 15:04")
e.ModTimeUnix = fi.ModTime().Unix()
if !de.IsDir() {
e.Size = fi.Size()
}
}
if de.IsDir() {
e.IconType = "folder"
} else {
e.IconType = iconType(full)
}
e.Icon = getSystemIcon(full, de.IsDir())
result = append(result, e)
}
return result, nil
}
// GetDrives 返回可用盘符列表(如 "C:\\"、"D:\\")
func (a *App) GetDrives() ([]string, error) {
mask, err := windows.GetLogicalDrives()
if err != nil {
return nil, fmt.Errorf("获取盘符失败:%s", osErrText(err))
}
var drives []string
for i := 0; i < 26; i++ {
if mask&(1<<uint(i)) != 0 {
drives = append(drives, string(rune('A'+i))+":\\")
}
}
return drives, nil
}
// DriveUsage 磁盘使用情况
type DriveUsage struct {
Drive string `json:"drive"`
Total int64 `json:"total"`
Free int64 `json:"free"`
}
// GetDriveUsage 查询磁盘总容量与可用空间
func (a *App) GetDriveUsage(drive string) (*DriveUsage, error) {
root := drive
if !strings.HasSuffix(root, "\\") {
root += "\\"
}
rootPtr, _ := syscall.UTF16PtrFromString(root)
var free, total, totalFree uint64
r, _, _ := procGetDiskFreeSpaceExW.Call(
uintptr(unsafe.Pointer(rootPtr)),
uintptr(unsafe.Pointer(&free)),
uintptr(unsafe.Pointer(&total)),
uintptr(unsafe.Pointer(&totalFree)))
if r == 0 {
return nil, fmt.Errorf("读取磁盘信息失败:%s", root)
}
return &DriveUsage{Drive: drive, Total: int64(total), Free: int64(free)}, nil
}
// GetHomeDir 返回当前用户主目录(作为初始页)
func (a *App) GetHomeDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("获取用户目录失败:%s", osErrText(err))
}
return home, nil
}
// iconType 根据扩展名返回图标类型
func iconType(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".ico", ".tiff", ".heic":
return "image"
case ".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm", ".m4v":
return "video"
case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a", ".wma":
return "audio"
case ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz":
return "archive"
case ".doc", ".docx", ".pdf", ".txt", ".md", ".xls", ".xlsx", ".ppt", ".pptx", ".rtf", ".csv":
return "doc"
case ".go", ".js", ".ts", ".py", ".java", ".c", ".cpp", ".h", ".html", ".css", ".json", ".xml", ".yml", ".yaml", ".sql", ".sh", ".bat", ".ps1":
return "code"
case ".exe", ".msi", ".com":
return "exe"
default:
return "file"
}
}
// osErrText 把常见的系统错误翻译成中文提示
func osErrText(err error) string {
if err == nil {
return ""
}
if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ERROR_FILE_NOT_FOUND) || errors.Is(err, syscall.ERROR_PATH_NOT_FOUND) {
return "路径不存在"
}
if errors.Is(err, os.ErrPermission) || errors.Is(err, syscall.ERROR_ACCESS_DENIED) {
return "没有访问权限"
}
msg := err.Error()
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "access is denied"):
return "没有访问权限"
case strings.Contains(lower, "cannot find"):
return "路径不存在"
case strings.Contains(lower, "being used by another process"), strings.Contains(lower, "file in use"):
return "文件被占用,无法操作"
case strings.Contains(lower, "directory not empty"):
return "目录不为空,无法删除"
}
return msg
}