@yufengtadian/freedom-cli 1.12.17 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/build.js +74 -9
- package/lib/cli.js +61 -3
- package/lib/init.js +9 -1
- package/lib/security.js +2 -2
- package/lib/utils.js +5 -2
- package/lib/verify.js +236 -0
- package/package.json +43 -39
- package/shell/darwin-arm64/freedom-shell +0 -0
- package/shell/linux-x64/freedom-shell +0 -0
- package/shell/win-x64/freedom-shell.exe +0 -0
- package/templates/go/go.mod +1 -7
- package/templates/go/go.sum +2 -2
- package/templates/go/main.go +22 -8
- package/templates/go/pkg/freedom/anti_debug_other.go +2 -3
- package/templates/go/pkg/freedom/anti_debug_windows.go +10 -12
- package/templates/go/pkg/freedom/assets/{index.html → default.html} +1 -1
- package/templates/go/pkg/freedom/assets/freedom.js +219 -21
- package/templates/go/pkg/freedom/assets_embed.go +11 -13
- package/templates/go/pkg/freedom/authenticode_other.go +11 -0
- package/templates/go/pkg/freedom/authenticode_windows.go +87 -0
- package/templates/go/pkg/freedom/backend_proc.go +295 -51
- package/templates/go/pkg/freedom/bridge.go +13 -2
- package/templates/go/pkg/freedom/capability.go +77 -0
- package/templates/go/pkg/freedom/center_other.go +3 -0
- package/templates/go/pkg/freedom/center_windows.go +64 -36
- package/templates/go/pkg/freedom/dispatch.go +60 -0
- package/templates/go/pkg/freedom/events_windows.go +319 -0
- package/templates/go/pkg/freedom/freedom.go +242 -92
- package/templates/go/pkg/freedom/msgwindow_windows.go +257 -0
- package/templates/go/pkg/freedom/osver_other.go +15 -0
- package/templates/go/pkg/freedom/osver_windows.go +121 -0
- package/templates/go/pkg/freedom/proc_hide_windows.go +2 -2
- package/templates/go/pkg/freedom/{configfile.go → resources.go} +56 -32
- package/templates/go/pkg/freedom/security.go +19 -12
- package/templates/go/pkg/freedom/singleinstance_other.go +10 -0
- package/templates/go/pkg/freedom/singleinstance_windows.go +145 -0
- package/templates/go/pkg/freedom/spawn_other.go +11 -0
- package/templates/go/pkg/freedom/spawn_windows.go +23 -0
- package/templates/go/pkg/freedom/store.go +458 -0
- package/templates/go/pkg/freedom/syscap_linux.go +278 -0
- package/templates/go/pkg/freedom/syscap_other.go +22 -0
- package/templates/go/pkg/freedom/syscap_windows.go +845 -0
- package/templates/go/pkg/freedom/sysint_common.go +70 -0
- package/templates/go/pkg/freedom/sysint_windows.go +426 -0
- package/templates/go/pkg/freedom/tray_common.go +12 -0
- package/templates/go/pkg/freedom/tray_linux.go +302 -0
- package/templates/go/pkg/freedom/tray_other.go +10 -0
- package/templates/go/pkg/freedom/tray_windows.go +536 -0
- package/templates/go/pkg/freedom/updater.go +372 -0
- package/templates/go/pkg/freedom/window_mgr.go +385 -0
- package/templates/go/pkg/freedom/window_other.go +32 -66
- package/templates/go/pkg/freedom/window_windows.go +406 -251
- package/templates/project-minimal/freedom.config.js +58 -0
- package/templates/project-minimal/index.html +41 -0
- package/templates/project-minimal/package.json +14 -0
- package/templates/project-minimal/src/main.js +19 -0
- package/templates/project-minimal/vite.config.js +18 -0
- package/templates/go/gen_config.go +0 -22
- package/templates/go/pkg/freedom/security_test.go +0 -153
- package/templates/go/webview_go/.github/workflows/ci.yaml +0 -62
- package/templates/go/webview_go/CHANGELOG.md +0 -15
- package/templates/go/webview_go/LICENSE +0 -22
- package/templates/go/webview_go/README.md +0 -50
- package/templates/go/webview_go/examples/basic/main.go +0 -12
- package/templates/go/webview_go/examples/bind/main.go +0 -38
- package/templates/go/webview_go/glue.c +0 -36
- package/templates/go/webview_go/go.mod +0 -3
- package/templates/go/webview_go/go.sum +0 -0
- package/templates/go/webview_go/libs/mswebview2/LICENSE +0 -27
- package/templates/go/webview_go/libs/mswebview2/include/WebView2.h +0 -23568
- package/templates/go/webview_go/libs/mswebview2/include/vendor.go +0 -2
- package/templates/go/webview_go/libs/mswebview2/vendor.go +0 -2
- package/templates/go/webview_go/libs/mswebview2/version.txt +0 -1
- package/templates/go/webview_go/libs/webview/LICENSE +0 -22
- package/templates/go/webview_go/libs/webview/include/vendor.go +0 -2
- package/templates/go/webview_go/libs/webview/include/webview.h +0 -3871
- package/templates/go/webview_go/libs/webview/vendor.go +0 -2
- package/templates/go/webview_go/libs/webview/version.txt +0 -1
- package/templates/go/webview_go/webview.cc +0 -1
- package/templates/go/webview_go/webview.go +0 -481
- package/templates/go/webview_go/webview_test.go +0 -50
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
//go:build windows
|
|
2
|
+
|
|
3
|
+
package freedom
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"os"
|
|
9
|
+
"runtime"
|
|
10
|
+
"sync"
|
|
11
|
+
"syscall"
|
|
12
|
+
"time"
|
|
13
|
+
"unsafe"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// 单实例(对标 Tauri single-instance 插件):
|
|
17
|
+
// 主实例以 appID 为标题持有辅助消息窗口;二次启动经 FindWindowW 找到主实例,
|
|
18
|
+
// 用 WM_COPYDATA 把命令行参数(JSON 数组)转发过去后自行退出。
|
|
19
|
+
// deep link 场景同理:协议拉起的新进程参数转发给已运行实例。
|
|
20
|
+
|
|
21
|
+
// siState 是单实例回调登记处。
|
|
22
|
+
var siState struct {
|
|
23
|
+
mu sync.Mutex
|
|
24
|
+
active bool
|
|
25
|
+
appID string
|
|
26
|
+
onSecond func(args []string)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// siMutexHandle 主实例持有至进程退出(不显式 CloseHandle,OS 随进程回收)。
|
|
30
|
+
var siMutexHandle uintptr
|
|
31
|
+
|
|
32
|
+
// smtoAbortIfHung = 0x2;SendMessageTimeout 超时毫秒。
|
|
33
|
+
const smtoAbortIfHung = 0x00000002
|
|
34
|
+
|
|
35
|
+
// siWndClass 是主实例消息窗口类名(与 msgwindow_windows.go 保持一致)。
|
|
36
|
+
const siWndClass = "FreedomMsgWnd"
|
|
37
|
+
|
|
38
|
+
// forwardWindowMagic 是 WM_COPYDATA 的 DwData 魔数,接收侧校验后才会解析负载,
|
|
39
|
+
// 避免任意窗口向本进程投递伪造 COPYDATA 结构。
|
|
40
|
+
const forwardWindowMagic = 0x46524531 // "FREE1"
|
|
41
|
+
|
|
42
|
+
// RequestSingleInstance 申请单实例锁,必须在 New/Run 之前调用。
|
|
43
|
+
// 返回 true 表示本进程是主实例;返回 false 表示已有实例在运行——
|
|
44
|
+
// 本进程命令行参数已转发给它,调用方应立即退出(如 os.Exit(0))。
|
|
45
|
+
//
|
|
46
|
+
// 主实例收到二次启动时:触发 OnSecondInstance 注册的回调,
|
|
47
|
+
// 并向所有前端推送 "app.secondInstance" 事件({args: [...]})。
|
|
48
|
+
func RequestSingleInstance(appID string) bool {
|
|
49
|
+
if appID == "" {
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
// 权威判定用 CreateMutexW:FindWindow 两步组合存在 TOCTOU——
|
|
53
|
+
// 两个进程同时查询都查不到对方,会各自认定为主实例。
|
|
54
|
+
mp, err := syscall.UTF16PtrFromString(`Local\Freedom-SingleInstance-` + appID)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return true // appID 不可编码:降级为多实例运行
|
|
57
|
+
}
|
|
58
|
+
h, _, e := procCreateMutexW.Call(0, 0, uintptr(unsafe.Pointer(mp)))
|
|
59
|
+
if h == 0 {
|
|
60
|
+
fmt.Fprintf(os.Stderr, "freedom: CreateMutexW 失败(继续以多实例运行): %v\n", e)
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
if e == syscall.ERROR_ALREADY_EXISTS {
|
|
64
|
+
// 主实例可能仍在启动中(窗口尚未创建):有限轮询等待
|
|
65
|
+
if hm := waitForMainWindow(appID, 3*time.Second); hm != 0 {
|
|
66
|
+
forwardArgsTo(hm, os.Args[1:])
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
// 互斥体存活但窗口久不出现(主实例窗口创建失败后按多实例继续):
|
|
70
|
+
// 静默退出会丢用户请求,降级为继续运行。
|
|
71
|
+
fmt.Fprintf(os.Stderr, "freedom: 已有实例互斥体存在但窗口未找到,继续以本实例运行\n")
|
|
72
|
+
}
|
|
73
|
+
siMutexHandle = h
|
|
74
|
+
mw, err := ensureMsgWindow(appID)
|
|
75
|
+
if err != nil {
|
|
76
|
+
fmt.Fprintf(os.Stderr, "freedom: 单实例窗口不可用(继续以多实例运行): %v\n", err)
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
mw.setTitle(appID) // 快捷键等能力可能已先以默认标题建窗,纠正为 appID 供 FindWindow 定位
|
|
80
|
+
siState.mu.Lock()
|
|
81
|
+
siState.active = true
|
|
82
|
+
siState.appID = appID
|
|
83
|
+
siState.mu.Unlock()
|
|
84
|
+
mw.setOnData(func(b []byte) {
|
|
85
|
+
var args []string
|
|
86
|
+
if json.Unmarshal(b, &args) != nil {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
siState.mu.Lock()
|
|
90
|
+
fn := siState.onSecond
|
|
91
|
+
siState.mu.Unlock()
|
|
92
|
+
if fn != nil {
|
|
93
|
+
fn(args)
|
|
94
|
+
}
|
|
95
|
+
if a := currentApp(); a != nil {
|
|
96
|
+
a.Emit("app.secondInstance", map[string]interface{}{"args": args})
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// waitForMainWindow 轮询查找主实例消息窗口,超时返回 0。
|
|
103
|
+
func waitForMainWindow(appID string, timeout time.Duration) uintptr {
|
|
104
|
+
class, _ := syscall.UTF16PtrFromString(siWndClass)
|
|
105
|
+
title, err := syscall.UTF16PtrFromString(appID)
|
|
106
|
+
if err != nil {
|
|
107
|
+
return 0
|
|
108
|
+
}
|
|
109
|
+
deadline := time.Now().Add(timeout)
|
|
110
|
+
for {
|
|
111
|
+
if hm, _, _ := procFindWindowW.Call(uintptr(unsafe.Pointer(class)),
|
|
112
|
+
uintptr(unsafe.Pointer(title))); hm != 0 {
|
|
113
|
+
return hm
|
|
114
|
+
}
|
|
115
|
+
if time.Now().After(deadline) {
|
|
116
|
+
return 0
|
|
117
|
+
}
|
|
118
|
+
time.Sleep(50 * time.Millisecond)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// forwardArgsTo 用 WM_COPYDATA 把命令行参数(JSON 数组)转发给主实例窗口。
|
|
123
|
+
// 用 SendMessageTimeout 而非 SendMessage:主实例挂死时不至于拖死二次进程。
|
|
124
|
+
func forwardArgsTo(hwnd uintptr, args []string) {
|
|
125
|
+
payload, err := json.Marshal(args)
|
|
126
|
+
if err != nil || len(payload) == 0 {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
cds := copyDataStruct{
|
|
130
|
+
DwData: forwardWindowMagic,
|
|
131
|
+
CbData: uint32(len(payload)),
|
|
132
|
+
LpData: uintptr(unsafe.Pointer(&payload[0])),
|
|
133
|
+
}
|
|
134
|
+
var res uintptr
|
|
135
|
+
procSendMessageTimeoutW.Call(hwnd, wmCopyData, 0, uintptr(unsafe.Pointer(&cds)),
|
|
136
|
+
smtoAbortIfHung, 3000, uintptr(unsafe.Pointer(&res)))
|
|
137
|
+
runtime.KeepAlive(payload) // 系统调用期间 payload 必须存活(cds.LpData 指向其底层数组)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// OnSecondInstance 注册主实例回调:二次启动的参数经此送达(覆盖式注册)。
|
|
141
|
+
func OnSecondInstance(fn func(args []string)) {
|
|
142
|
+
siState.mu.Lock()
|
|
143
|
+
siState.onSecond = fn
|
|
144
|
+
siState.mu.Unlock()
|
|
145
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//go:build windows
|
|
2
|
+
|
|
3
|
+
package freedom
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"os/exec"
|
|
7
|
+
"syscall"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
const (
|
|
11
|
+
detachedProcess = 0x00000010 // DETACHED_PROCESS:不继承控制台
|
|
12
|
+
createNewProcessGroup = 0x00000200 // 独立进程组,父进程退出后不受影响
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// newDetachedCmd 构造一个与当前控制台脱离的自身副本命令(process.restart 用)。
|
|
16
|
+
// 若不 DETACH,重启后的新实例随旧实例控制台一起收到 CTRL_CLOSE 而被杀。
|
|
17
|
+
func newDetachedCmd(exe string, args ...string) *exec.Cmd {
|
|
18
|
+
cmd := exec.Command(exe, args...)
|
|
19
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{
|
|
20
|
+
CreationFlags: detachedProcess | createNewProcessGroup,
|
|
21
|
+
}
|
|
22
|
+
return cmd
|
|
23
|
+
}
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
package freedom
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"runtime"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
"sync/atomic"
|
|
12
|
+
"time"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// 数据层(对标 Tauri path / store / window-state / os / process 插件):
|
|
16
|
+
// 平台无关部分。经 sysGeneric 分发,两个平台的 sysCapCall 都会先走到这里。
|
|
17
|
+
|
|
18
|
+
// appDir 返回应用数据目录。kind ∈ config|data|cache|temp|home|exe;
|
|
19
|
+
// name 非空时追加应用子目录(默认取 Config.AppID / exe 名)。
|
|
20
|
+
func appDir(kind, name, appID string) (string, error) {
|
|
21
|
+
var base string
|
|
22
|
+
switch kind {
|
|
23
|
+
case "config", "data":
|
|
24
|
+
b, err := os.UserConfigDir()
|
|
25
|
+
if err != nil {
|
|
26
|
+
return "", err
|
|
27
|
+
}
|
|
28
|
+
base = b
|
|
29
|
+
case "cache":
|
|
30
|
+
b, err := os.UserCacheDir()
|
|
31
|
+
if err != nil {
|
|
32
|
+
return "", err
|
|
33
|
+
}
|
|
34
|
+
base = b
|
|
35
|
+
case "temp":
|
|
36
|
+
base = os.TempDir()
|
|
37
|
+
case "home":
|
|
38
|
+
b, err := os.UserHomeDir()
|
|
39
|
+
if err != nil {
|
|
40
|
+
return "", err
|
|
41
|
+
}
|
|
42
|
+
base = b
|
|
43
|
+
case "exe":
|
|
44
|
+
exe, err := os.Executable()
|
|
45
|
+
if err != nil {
|
|
46
|
+
return "", err
|
|
47
|
+
}
|
|
48
|
+
return filepath.Dir(exe), nil
|
|
49
|
+
default:
|
|
50
|
+
return "", fmt.Errorf("freedom: 未知 path kind %q", kind)
|
|
51
|
+
}
|
|
52
|
+
if name == "" {
|
|
53
|
+
name = appID
|
|
54
|
+
}
|
|
55
|
+
if name != "" {
|
|
56
|
+
base = filepath.Join(base, sanitizeName(name))
|
|
57
|
+
}
|
|
58
|
+
return base, nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// sanitizeName 只保留目录/文件名安全字符,并消除 ".." 穿越与隐藏目录前缀,
|
|
62
|
+
// 防前端传入的 name 逃逸出应用数据根目录。
|
|
63
|
+
func sanitizeName(s string) string {
|
|
64
|
+
out := make([]rune, 0, len(s))
|
|
65
|
+
for _, r := range s {
|
|
66
|
+
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
|
|
67
|
+
r == '.' || r == '_' || r == '-' {
|
|
68
|
+
out = append(out, r)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
cleaned := string(out)
|
|
72
|
+
for strings.Contains(cleaned, "..") { // "a..b" → "a.b",杜绝 .. 段
|
|
73
|
+
cleaned = strings.ReplaceAll(cleaned, "..", ".")
|
|
74
|
+
}
|
|
75
|
+
cleaned = strings.TrimLeft(cleaned, ".") // 不以点开头的隐藏段
|
|
76
|
+
return cleaned
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- 原子落盘与损坏备份 ----
|
|
80
|
+
|
|
81
|
+
var tmpSeq atomic.Uint64
|
|
82
|
+
|
|
83
|
+
// writeAtomic 先写唯一命名的临时文件再 rename 替换。
|
|
84
|
+
// 固定 ".tmp" 名会被并发落盘互相覆盖(且崩溃残留会污染下次写入);
|
|
85
|
+
// 并发替换同一目标时 Windows 的 MoveFileEx 会间歇 ACCESS_DENIED,重试等到位。
|
|
86
|
+
func writeAtomic(path string, b []byte) error {
|
|
87
|
+
tmp := fmt.Sprintf("%s.tmp-%d-%d", path, os.Getpid(), tmpSeq.Add(1))
|
|
88
|
+
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
|
89
|
+
return err
|
|
90
|
+
}
|
|
91
|
+
var err error
|
|
92
|
+
for i := 0; i < 5; i++ {
|
|
93
|
+
if err = os.Rename(tmp, path); err == nil {
|
|
94
|
+
return nil
|
|
95
|
+
}
|
|
96
|
+
time.Sleep(time.Duration(10*(i+1)) * time.Millisecond)
|
|
97
|
+
}
|
|
98
|
+
os.Remove(tmp)
|
|
99
|
+
return err
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// backupCorrupt 把无法解析的 JSON 文件改名留证,避免随后的正常保存静默覆盖用户数据。
|
|
103
|
+
func backupCorrupt(path string) {
|
|
104
|
+
_ = os.Rename(path, fmt.Sprintf("%s.corrupt-%d", path, time.Now().UnixNano()))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- store:命名 JSON KV(落盘即写,进程内串行) ----
|
|
108
|
+
|
|
109
|
+
type storeFile struct {
|
|
110
|
+
mu sync.Mutex
|
|
111
|
+
path string
|
|
112
|
+
data map[string]json.RawMessage
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
var storeCache sync.Map // path → *storeFile
|
|
116
|
+
|
|
117
|
+
func storeFor(dir, name string) (*storeFile, error) {
|
|
118
|
+
if name == "" {
|
|
119
|
+
name = "default"
|
|
120
|
+
}
|
|
121
|
+
path := filepath.Join(dir, sanitizeName(name)+".store.json")
|
|
122
|
+
if v, ok := storeCache.Load(path); ok {
|
|
123
|
+
return v.(*storeFile), nil
|
|
124
|
+
}
|
|
125
|
+
sf := &storeFile{path: path, data: map[string]json.RawMessage{}}
|
|
126
|
+
if b, err := os.ReadFile(path); err == nil {
|
|
127
|
+
var m map[string]json.RawMessage
|
|
128
|
+
if json.Unmarshal(b, &m) == nil {
|
|
129
|
+
sf.data = m
|
|
130
|
+
} else {
|
|
131
|
+
backupCorrupt(path) // 坏文件留证,随后 set/delete 的保存不再静默覆盖
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
actual, _ := storeCache.LoadOrStore(path, sf)
|
|
135
|
+
return actual.(*storeFile), nil
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func (sf *storeFile) get(key string) (json.RawMessage, error) {
|
|
139
|
+
sf.mu.Lock()
|
|
140
|
+
defer sf.mu.Unlock()
|
|
141
|
+
v, ok := sf.data[key]
|
|
142
|
+
if !ok {
|
|
143
|
+
return json.RawMessage("null"), nil
|
|
144
|
+
}
|
|
145
|
+
return v, nil
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func (sf *storeFile) set(key string, value json.RawMessage) error {
|
|
149
|
+
sf.mu.Lock()
|
|
150
|
+
defer sf.mu.Unlock()
|
|
151
|
+
sf.data[key] = value
|
|
152
|
+
return sf.flushLocked()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func (sf *storeFile) delete(key string) error {
|
|
156
|
+
sf.mu.Lock()
|
|
157
|
+
defer sf.mu.Unlock()
|
|
158
|
+
delete(sf.data, key)
|
|
159
|
+
return sf.flushLocked()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func (sf *storeFile) keys() []string {
|
|
163
|
+
sf.mu.Lock()
|
|
164
|
+
defer sf.mu.Unlock()
|
|
165
|
+
out := make([]string, 0, len(sf.data))
|
|
166
|
+
for k := range sf.data {
|
|
167
|
+
out = append(out, k)
|
|
168
|
+
}
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func (sf *storeFile) all() map[string]json.RawMessage {
|
|
173
|
+
sf.mu.Lock()
|
|
174
|
+
defer sf.mu.Unlock()
|
|
175
|
+
out := make(map[string]json.RawMessage, len(sf.data))
|
|
176
|
+
for k, v := range sf.data {
|
|
177
|
+
out[k] = v
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func (sf *storeFile) flushLocked() error {
|
|
183
|
+
if err := os.MkdirAll(filepath.Dir(sf.path), 0o755); err != nil {
|
|
184
|
+
return err
|
|
185
|
+
}
|
|
186
|
+
b, err := json.MarshalIndent(sf.data, "", " ")
|
|
187
|
+
if err != nil {
|
|
188
|
+
return err
|
|
189
|
+
}
|
|
190
|
+
return writeAtomic(sf.path, b) // 原子替换,避免写一半损坏
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- window state(位置/尺寸记忆)----
|
|
194
|
+
|
|
195
|
+
// WindowState 是窗口几何快照(物理像素)。
|
|
196
|
+
type WindowState struct {
|
|
197
|
+
X int `json:"x"`
|
|
198
|
+
Y int `json:"y"`
|
|
199
|
+
Width int `json:"width"`
|
|
200
|
+
Height int `json:"height"`
|
|
201
|
+
Maximized bool `json:"maximized"`
|
|
202
|
+
Fullscreen bool `json:"fullscreen"`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func windowStatePath(dir string) string {
|
|
206
|
+
return filepath.Join(dir, "window-state.json")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
func loadWindowState(dir string) (WindowState, bool) {
|
|
210
|
+
var st WindowState
|
|
211
|
+
b, err := os.ReadFile(windowStatePath(dir))
|
|
212
|
+
if err != nil {
|
|
213
|
+
return st, false
|
|
214
|
+
}
|
|
215
|
+
if json.Unmarshal(b, &st) != nil {
|
|
216
|
+
backupCorrupt(windowStatePath(dir))
|
|
217
|
+
return WindowState{}, false
|
|
218
|
+
}
|
|
219
|
+
if st.Width <= 0 || st.Height <= 0 {
|
|
220
|
+
return WindowState{}, false
|
|
221
|
+
}
|
|
222
|
+
return st, true
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func saveWindowState(dir string, st WindowState) error {
|
|
226
|
+
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
227
|
+
return err
|
|
228
|
+
}
|
|
229
|
+
b, _ := json.Marshal(st)
|
|
230
|
+
return writeAtomic(windowStatePath(dir), b)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- os / process 信息 ----
|
|
234
|
+
|
|
235
|
+
func osInfo() map[string]interface{} {
|
|
236
|
+
host, _ := os.Hostname()
|
|
237
|
+
info := map[string]interface{}{
|
|
238
|
+
"platform": runtime.GOOS,
|
|
239
|
+
"arch": runtime.GOARCH,
|
|
240
|
+
"hostname": host,
|
|
241
|
+
"osVersion": osVersionString(), // 平台分文件:osver_windows.go / osver_other.go
|
|
242
|
+
"goVersion": runtime.Version(),
|
|
243
|
+
"appVersion": Version, // 构建期 ldflags 注入,见 freedom.go
|
|
244
|
+
"numCPU": runtime.NumCPU(),
|
|
245
|
+
}
|
|
246
|
+
// M6:WebView2 Runtime 探测回显(仅 Windows 且检出时给键,其他平台省略)
|
|
247
|
+
if v := webview2RuntimeVersion(); v != "" {
|
|
248
|
+
info["webview2Runtime"] = v
|
|
249
|
+
}
|
|
250
|
+
return info
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- 平台无关 sys 方法统一分发(两平台 sysCapCall 都会先经过这里)----
|
|
254
|
+
|
|
255
|
+
// sysGeneric 处理路径/store/os/process 等平台无关方法;ok=false 表示不属于本分发器。
|
|
256
|
+
// args 为已解析的 JSON 参数字典(可为 nil)。
|
|
257
|
+
func (a *App) sysGeneric(method string, args map[string]json.RawMessage) (result interface{}, ok bool, err error) {
|
|
258
|
+
argStr := func(k string) string {
|
|
259
|
+
if v, ok := args[k]; ok {
|
|
260
|
+
var s string
|
|
261
|
+
if json.Unmarshal(v, &s) == nil {
|
|
262
|
+
return s
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return ""
|
|
266
|
+
}
|
|
267
|
+
argNum := func(k string) float64 {
|
|
268
|
+
if v, ok := args[k]; ok {
|
|
269
|
+
var f float64
|
|
270
|
+
json.Unmarshal(v, &f)
|
|
271
|
+
}
|
|
272
|
+
return 0
|
|
273
|
+
}
|
|
274
|
+
switch method {
|
|
275
|
+
case "path.get":
|
|
276
|
+
dir, e := a.appPath(argStr("kind"), argStr("name"))
|
|
277
|
+
return dir, true, e
|
|
278
|
+
case "store.load": // 整包返回
|
|
279
|
+
sf, e := a.storeFor(argStr("store"))
|
|
280
|
+
if e != nil {
|
|
281
|
+
return nil, true, e
|
|
282
|
+
}
|
|
283
|
+
return sf.all(), true, nil
|
|
284
|
+
case "store.get":
|
|
285
|
+
sf, e := a.storeFor(argStr("store"))
|
|
286
|
+
if e != nil {
|
|
287
|
+
return nil, true, e
|
|
288
|
+
}
|
|
289
|
+
v, e := sf.get(argStr("key"))
|
|
290
|
+
return v, true, e
|
|
291
|
+
case "store.set":
|
|
292
|
+
sf, e := a.storeFor(argStr("store"))
|
|
293
|
+
if e != nil {
|
|
294
|
+
return nil, true, e
|
|
295
|
+
}
|
|
296
|
+
value := args["value"]
|
|
297
|
+
if value == nil {
|
|
298
|
+
value = json.RawMessage("null")
|
|
299
|
+
}
|
|
300
|
+
return nil, true, sf.set(argStr("key"), value)
|
|
301
|
+
case "store.delete":
|
|
302
|
+
sf, e := a.storeFor(argStr("store"))
|
|
303
|
+
if e != nil {
|
|
304
|
+
return nil, true, e
|
|
305
|
+
}
|
|
306
|
+
return nil, true, sf.delete(argStr("key"))
|
|
307
|
+
case "store.keys":
|
|
308
|
+
sf, e := a.storeFor(argStr("store"))
|
|
309
|
+
if e != nil {
|
|
310
|
+
return nil, true, e
|
|
311
|
+
}
|
|
312
|
+
return sf.keys(), true, nil
|
|
313
|
+
case "os.info":
|
|
314
|
+
info := osInfo()
|
|
315
|
+
// M3:回显生效的能力收口,前端可据此隐藏入口(nil=全开时省略该键)。
|
|
316
|
+
if a.cfg.Capabilities != nil {
|
|
317
|
+
info["capabilities"] = map[string][]string{
|
|
318
|
+
"allow": a.cfg.Capabilities.Allow,
|
|
319
|
+
"deny": a.cfg.Capabilities.Deny,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return info, true, nil
|
|
323
|
+
case "process.id":
|
|
324
|
+
return os.Getpid(), true, nil
|
|
325
|
+
case "process.exit":
|
|
326
|
+
code := int(argNum("code"))
|
|
327
|
+
go a.exitProcess(code)
|
|
328
|
+
return nil, true, nil
|
|
329
|
+
case "process.restart":
|
|
330
|
+
go a.restartProcess()
|
|
331
|
+
return nil, true, nil
|
|
332
|
+
case "update.check": // G4:异步检查,结果走 update.available/upToDate/error 事件
|
|
333
|
+
return a.updateCheckAsync(), true, nil
|
|
334
|
+
case "update.install": // 用 check 验签缓存的条目,成功后 update.installed
|
|
335
|
+
res, e := a.updateInstallAsync()
|
|
336
|
+
return res, true, e
|
|
337
|
+
case "update.pending":
|
|
338
|
+
return a.pendingUpdate(), true, nil
|
|
339
|
+
}
|
|
340
|
+
return nil, false, nil
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// storeFor 定位/创建命名 store(数据目录:<app data dir>/<AppID>/<name>.store.json)。
|
|
344
|
+
func (a *App) storeFor(name string) (*storeFile, error) {
|
|
345
|
+
dir, err := a.dataDir()
|
|
346
|
+
if err != nil {
|
|
347
|
+
return nil, err
|
|
348
|
+
}
|
|
349
|
+
return storeFor(dir, name)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// appPath 解析 path.get:kind 目录 + 可选子目录名(默认 AppID,回退 exe 名)。
|
|
353
|
+
func (a *App) appPath(kind, name string) (string, error) {
|
|
354
|
+
if name == "" {
|
|
355
|
+
name = a.cfg.AppID
|
|
356
|
+
if name == "" {
|
|
357
|
+
name = defaultAppID()
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return appDir(kind, name, "")
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// exitProcess 关闭后端子进程后退出壳进程(前端 freedom.app/process.exit 触发)。
|
|
364
|
+
func (a *App) exitProcess(code int) {
|
|
365
|
+
if err := a.backend.Close(); err != nil {
|
|
366
|
+
fmt.Fprintf(os.Stderr, "freedom: backend close on exit: %v\n", err)
|
|
367
|
+
}
|
|
368
|
+
os.Exit(code)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// restartProcess 拉起自身新实例后退出当前实例。
|
|
372
|
+
func (a *App) restartProcess() {
|
|
373
|
+
exe, err := os.Executable()
|
|
374
|
+
if err == nil {
|
|
375
|
+
cmd := newDetachedCmd(exe, os.Args[1:]...)
|
|
376
|
+
err = cmd.Start()
|
|
377
|
+
}
|
|
378
|
+
if err != nil {
|
|
379
|
+
fmt.Fprintf(os.Stderr, "freedom: restart spawn failed: %v\n", err)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
a.exitProcess(0)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---- 窗口状态记忆(Config.RememberWindowState)----
|
|
386
|
+
|
|
387
|
+
// defaultAppID 以可执行文件名(去扩展名)作为应用标识的默认回退值。
|
|
388
|
+
func defaultAppID() string {
|
|
389
|
+
exe, err := os.Executable()
|
|
390
|
+
if err != nil {
|
|
391
|
+
return "FreedomApp"
|
|
392
|
+
}
|
|
393
|
+
base := filepath.Base(exe)
|
|
394
|
+
if ext := filepath.Ext(base); ext != "" {
|
|
395
|
+
base = base[:len(base)-len(ext)]
|
|
396
|
+
}
|
|
397
|
+
if base == "" {
|
|
398
|
+
return "FreedomApp"
|
|
399
|
+
}
|
|
400
|
+
return base
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// dataDir 返回本应用的数据目录(AppID 为空时回退 exe 名)。
|
|
404
|
+
func (a *App) dataDir() (string, error) {
|
|
405
|
+
id := a.cfg.AppID
|
|
406
|
+
if id == "" {
|
|
407
|
+
id = defaultAppID()
|
|
408
|
+
}
|
|
409
|
+
return appDir("data", id, "")
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// restoreWindowState 在启动时应用上次保存的几何;无效则保持配置值。
|
|
413
|
+
func (a *App) restoreWindowState() {
|
|
414
|
+
if !a.cfg.RememberWindowState {
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
dir, err := a.dataDir()
|
|
418
|
+
if err != nil {
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
st, ok := loadWindowState(dir)
|
|
422
|
+
if !ok {
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
// 显示器布局可能已变(拔掉副屏):坐标落在所有屏幕之外时只恢复尺寸,
|
|
426
|
+
// 位置交由 applyCenter/窗口管理器决定,避免窗口"消失"在虚空里。
|
|
427
|
+
posOK := stateOnScreen(st)
|
|
428
|
+
hwnd := a.WindowHandle()
|
|
429
|
+
if hwnd == 0 {
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if st.Maximized {
|
|
433
|
+
_, _ = windowControl(hwnd, "maximize", a.cfg.TitleBar, "{}")
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
_, _ = windowControl(hwnd, "setSize", a.cfg.TitleBar,
|
|
437
|
+
fmt.Sprintf(`{"width":%d,"height":%d}`, st.Width, st.Height))
|
|
438
|
+
if posOK {
|
|
439
|
+
_, _ = windowControl(hwnd, "setPosition", a.cfg.TitleBar,
|
|
440
|
+
fmt.Sprintf(`{"x":%d,"y":%d}`, st.X, st.Y))
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// saveWindowStateNow 立即快照当前几何到状态文件(退出/停止拖拽时调用)。
|
|
445
|
+
func (a *App) saveWindowStateNow() {
|
|
446
|
+
if !a.cfg.RememberWindowState {
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
dir, err := a.dataDir()
|
|
450
|
+
if err != nil {
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
hwnd := a.WindowHandle()
|
|
454
|
+
if hwnd == 0 {
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
a.persistWindowState(hwnd, dir)
|
|
458
|
+
}
|