Files
Commilitia-Drop/desktop/app.go
T
admin ab57afdec2 桌面原生数据面:pion 数据通道取代 WebView WebRTC,逃离 256KB rwnd 与渲染器节流
把桌面 P2P 数据面从 WebView 内 JS WebRTC 移到 Go 进程的 pion,逃离 WebKit/WebView2 写死的 256KB SCTP 接收窗与隐藏窗口的渲染器节流。后端零改动、与现有客户端互通(线协议逐字节对齐)。真机实测原生↔原生 / 原生↔iOS(经有线)稳定 7-10MB/s,对比旧 JS 路径的 1.5MB/s 还塌缩是质变。设计与阶段见 desktop/NATIVE-TRANSFER.md。

- engine 包(纯 Go,仅 pion+stdlib、零 Wails 依赖,供将来 gomobile 复用于 iOS):pion 收发,逐字节对齐 web 引擎线协议(DataChannel cdrop-file ordered、文本帧 meta/done/ack、二进制分片、信令 {type,sdp?,candidate?});SetSCTPMaxReceiveBufferSize 顶到 4MB;mDNS QueryOnly 解析对端 .local 候选实现同内网 host↔host 直连;接收端直接写盘(去 base64 桥 / OPFS);进程内端到端测试 + 诊断日志(候选/ICE 态/选中对/发送停滞)。
- app.go:Bind P2PStartOutgoing / StartIncoming / HandleSignal / Cancel + 原生取文件 PickFileForSend + 中继回退读盘片 ReadFileSlice;引擎回调经 EventsEmit p2p:* 反向通知(进度/状态/信令/落盘路径/候选对)。
- JS p2pBackend 抽象(p2pNative.ts):桌面按对端把会话委派给 Go 引擎,transfer.ts / hub.ts 零改动;HTTP 全留 JS(出站信令 POST、状态机 /p2p·接收端 /done·/fail);监听 p2p:* 事件驱动 store、phase 与 iceStats。
- 发送取文件改原生路径:selectedFile 模型 File→FileSource(桌面带绝对 path → Go 直接读盘走原生;浏览器拖放无 path → 回退 JS)。Composer dropzone 在桌面打开原生文件对话框。
- 完成语义修复:接收端完成不立即拆连接(续发最终 ack、留 SSE 终态 Cancel 拆、60s 兜底、终态不可被后续 close 翻转)——修「iOS→mac 实际成功却被误标失败」。
- 剪贴板暂存路径修复:富文本经 Universal Clipboard 暂存为 .rtfd,pasteboard 纯文本表示有时取到该暂存路径——uploadClipboard 共享守卫拦截 + mac 原生读改从 RTF / RTFD 派生纯文本。
2026-06-27 23:49:54 +08:00

540 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/menu/keys"
"github.com/wailsapp/wails/v2/pkg/runtime"
"cdrop-desktop/engine"
"cdrop-desktop/platform"
)
// App is the Wails application context bound to the WebView.
type App struct {
ctx context.Context
apiBase string
token *platform.TokenResult // in-memory copy; refresh_token persists in the OS secret store (see session.go)
user platform.UserInfo // display identity from 代铸; preserved across refresh (machine tokens are nameless)
quitting bool // set by the menu bar "Quit" so beforeClose allows the real exit
startHidden bool // set by main when launched via autostart: come up to the menu bar, no window
clipSource platform.Source
clipMonitor *platform.Monitor
// transfer 是原生 P2P 数据面(pion):取代 WebView 内 JS WebRTC,逃离 WebKit/WebView2
// 的 256KB rwnd 与渲染器节流。HTTP(信令 / 状态机)仍由 WebView 侧 JS 经事件桥发起,
// 引擎只做纯 WebRTC + 文件 I/O。见 desktop/NATIVE-TRANSFER.md。
transfer *engine.Engine
}
// NewApp creates a new App application struct.
func NewApp() *App {
return &App{apiBase: envOr("CDROP_API_BASE", "https://drop.commilitia.net")}
}
// startup is called when the app starts. The context is saved so we can call
// the runtime methods, and the menu bar item is installed (macOS; no-op
// elsewhere). The app launches as a regular Dock app with the window visible.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// Load the persisted refresh_token into the Go process so refresh works after
// a restart. The refresh_token stays here — it's never handed to the WebView.
if s, err := platform.LoadSession(); err == nil && s != nil {
a.token = &platform.TokenResult{
AccessToken: s.AccessToken,
RefreshToken: s.RefreshToken,
ExpiresIn: s.ExpiresIn,
}
a.user = s.User
}
// Keep the autostart entry in sync with this binary: when it's enabled,
// re-apply so an entry written by an older build (no --hidden flag) or one
// pointing at a since-moved bundle is refreshed to the current path + flags.
// Idempotent and cheap — a single plist / registry write.
if platform.IsLaunchAtLoginEnabled() {
_ = platform.SetLaunchAtLogin(true)
}
// 原生传输引擎:落地目录取当前配置(设置页改目录时经 SaveSettings 同步到引擎)。
a.transfer = engine.New(engine.Config{DownloadDir: platform.ResolveDownloadDir()}, &transferEvents{app: a})
a.startClipboardSync(ctx)
// 触发 macOS 本地网络权限(macOS 15+ 隐私门):使本进程 WKWebView 的 WebRTC 能收集 host /
// mDNS 候选、同内网走直连而非 prflx↔prflx 慢路径(见 platform.TriggerLocalNetwork)。其他平台空实现。
platform.TriggerLocalNetwork()
platform.InstallStatusBar(
platform.StatusBarMenu{
Title: "cdrop",
Show: "显示主窗口",
Settings: "设置…",
Quit: "退出 cdrop",
},
// The native menu-action callbacks fire on the AppKit main thread; calling
// Wails runtime methods synchronously there can re-enter the main run loop
// and crash. Hand off to a goroutine — Wails then dispatches to the main
// thread safely.
platform.StatusBarHandler{
OnShowWindow: func() { go a.revealWindow() },
OnOpenSettings: func() { go a.openSettings() },
OnQuit: func() { go a.requestQuit() },
},
)
// Autostart launch: the window already came up hidden (options.StartHidden).
// On macOS also drop the Dock icon to accessory so only the menu bar item
// remains — the same resting state as close-to-menu-bar. (Windows: this is a
// no-op; the tray is installed above and the hidden window keeps the taskbar
// clear. The menu bar item / tray is the way back in.)
if a.startHidden {
platform.SetDockVisible(false)
}
}
// buildMenu returns the macOS application menu. Without it the standard
// shortcuts (⌘C/⌘V/⌘X/⌘A/⌘Z and ⌘W/⌘M) don't work.
//
// Quit is a custom ⌘Q item rather than the role AppMenu's, because Wails routes
// both the window close button and ⌘Q through the same OnBeforeClose("Q")
// channel — indistinguishable there. The custom item sets the quitting flag
// first, so OnBeforeClose lets ⌘Q quit while the close button hides. ⌘W likewise
// hides (close-to-menu-bar), matching the lifecycle.
func (a *App) buildMenu() *menu.Menu {
m := menu.NewMenu()
appSub := menu.NewMenu()
appSub.Append(menu.Text("退出 cdrop", keys.CmdOrCtrl("q"), func(*menu.CallbackData) {
a.requestQuit()
}))
m.Append(menu.SubMenu("cdrop", appSub)) // macOS renders the first menu as the app menu
m.Append(menu.EditMenu()) // Undo/Redo/Cut/Copy/Paste/SelectAll
window := menu.NewMenu()
window.Append(menu.Text("最小化", keys.CmdOrCtrl("m"), func(*menu.CallbackData) {
runtime.WindowMinimise(a.ctx)
}))
window.Append(menu.Text("关闭窗口", keys.CmdOrCtrl("w"), func(*menu.CallbackData) {
a.hideWindow()
}))
m.Append(menu.SubMenu("窗口", window))
return m
}
// hideWindow tucks the window away to the menu bar (window hidden, Dock icon
// gone). Shared by ⌘W and the window's close button (via beforeClose).
func (a *App) hideWindow() {
runtime.WindowHide(a.ctx)
platform.SetDockVisible(false)
}
// beforeClose intercepts the window close. Instead of quitting, it hides the
// window and drops to accessory (Dock icon gone, menu bar item remains the way
// back in). The real exit only happens after the menu bar "Quit" sets quitting.
func (a *App) beforeClose(_ context.Context) bool {
if a.quitting {
return false // allow the quit to proceed
}
a.hideWindow()
return true // prevent the close
}
// revealWindow brings the main window back and restores the Dock icon. Bound to
// the menu bar "Show" item.
func (a *App) revealWindow() {
if a.ctx == nil {
return
}
platform.SetDockVisible(true)
runtime.WindowShow(a.ctx)
runtime.WindowUnminimise(a.ctx)
}
// openSettings reveals the window and asks the WebView to route to settings.
func (a *App) openSettings() {
a.revealWindow()
runtime.EventsEmit(a.ctx, "menu:settings")
}
// requestQuit flags an intentional quit so beforeClose lets it through, then
// asks Wails to terminate.
func (a *App) requestQuit() {
a.quitting = true
runtime.Quit(a.ctx)
}
// startClipboardSync starts the native pasteboard watcher. Local copies are
// emitted to the WebView as "clipboard:native"; the JS layer owns the upload
// decision (auth + the user's sync toggle) and reuses the web app's existing
// upload + dedup path. The download direction is driven by the WebView calling
// ApplyRemoteClipboard. The shared Monitor enforces the loop guard across both
// directions.
func (a *App) startClipboardSync(ctx context.Context) {
a.clipSource = platform.NewClipboardSource()
a.clipMonitor = platform.NewMonitor(func(text string) {
runtime.EventsEmit(a.ctx, "clipboard:native", text)
})
go platform.Run(ctx, a.clipSource, a.clipMonitor)
}
// ApplyRemoteClipboard is bound to the WebView. It writes a remote clipboard
// update to the local pasteboard (download direction). The Monitor arms its loop
// guard before the write so the resulting change isn't re-uploaded, and skips
// this device's own echoes. content is plain text at this stage.
func (a *App) ApplyRemoteClipboard(content, sourceDevice string) {
if a.clipMonitor == nil || a.clipSource == nil {
return
}
a.clipMonitor.ApplyRemote(a.clipSource, platform.ClipboardContent{
Content: content,
ContentType: "text/plain",
SourceDevice: sourceDevice,
}, a.DeviceName())
}
// ShowNotification is bound to the WebView. The JS hub calls it when a
// notify-worthy event (incoming file / message / transfer done|failed) arrives
// while the window is NOT in the foreground — so the user gets a native system
// notification instead of an in-app cue they'd miss. Foreground events stay
// in-app: the JS gates on document focus/visibility, so this is only ever
// invoked for the background case. Title/body arrive already localized by the
// WebView (client-side i18n). macOS display additionally needs a signed bundle
// (D6) — until then platform.Notify no-ops safely there; Windows shows now.
func (a *App) ShowNotification(title, body string) {
platform.Notify(title, body)
}
// StartLogin is bound to the WebView. It runs the loopback PKCE flow in the
// background and reports the outcome via Wails events ("oauth:success" /
// "oauth:error"). On success it persists the full session (with refresh_token)
// Go-side and emits only the refresh-free SessionView to JS — the refresh_token
// never enters the WebView (see the credential policy in session.go).
func (a *App) StartLogin() {
go func() {
cfg, err := a.resolveOAuthConfig()
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
flow := platform.NewFlow(cfg, func(u string) {
runtime.BrowserOpenURL(a.ctx, u)
})
// Step 1: device-authorize → a bootstrap machine token (proves identity, no meta).
boot, err := flow.Login(a.ctx)
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
// Step 2: 代铸 the bootstrap token into a cdrop-managed device session bound to this
// device's stable device_id, so the desktop is managed exactly like a browser. The
// device session's tokens supersede the bootstrap (which is discarded, left to expire).
deviceID, err := platform.ResolveDeviceID()
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
ds, err := platform.MintDeviceSession(a.ctx, a.apiBase, boot.AccessToken, deviceID, platform.ResolveDeviceName(), platform.DeviceType())
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
// Identity from the verified 代铸 response (X-Auth-Subject / X-Auth-Name / X-Auth-Avatar),
// so the real display name and picture show rather than the subject UUID a nameless
// machine token yields.
user := platform.UserInfo{ID: ds.UserID, Name: ds.Name, Avatar: ds.Avatar}
if user.Name == "" {
user.Name = user.ID
}
res := &platform.LoginResult{
AccessToken: ds.AccessToken,
RefreshToken: ds.RefreshToken,
ExpiresIn: ds.ExpiresIn,
User: user,
}
a.token = &platform.TokenResult{AccessToken: ds.AccessToken, RefreshToken: ds.RefreshToken, ExpiresIn: ds.ExpiresIn}
a.user = user
if err := platform.SaveSession(*res); err != nil {
runtime.LogWarningf(a.ctx, "persist session failed: %v", err)
}
view := res.View()
runtime.EventsEmit(a.ctx, "oauth:success", &view)
}()
}
// Refresh is bound to the WebView and takes NO argument: the refresh_token never
// crosses into JS, so Go uses its own in-memory copy. It mints a fresh
// access_token (public-client loopback flow, no client_secret), persists the
// rotated session, and returns only the refresh-free SessionView. The WebView
// calls this when an API request comes back 401.
func (a *App) Refresh() (*platform.SessionView, error) {
if a.token == nil || a.token.RefreshToken == "" {
return nil, errors.New("no refresh token")
}
cfg, err := a.resolveOAuthConfig()
if err != nil {
return nil, err
}
flow := platform.NewFlow(cfg, func(string) {})
tok, err := flow.Refresh(a.ctx, a.token.RefreshToken)
if err != nil {
return nil, err
}
if tok.RefreshToken == "" {
tok.RefreshToken = a.token.RefreshToken // providers that don't rotate: keep the old one
}
// Preserve the identity established at login: the refreshed access token is a nameless
// machine token (it carries meta, not the display name), so re-deriving identity from it
// would regress the name to the subject UUID. The device session keeps its meta across
// rotation, so the device stays the same managed device.
res := &platform.LoginResult{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresIn: tok.ExpiresIn,
User: a.user,
}
a.token = tok
if err := platform.SaveSession(*res); err != nil {
runtime.LogWarningf(a.ctx, "persist refreshed session failed: %v", err)
}
view := res.View()
return &view, nil
}
// ClearSession is bound to the WebView; called on logout to drop the persisted
// session file (and the Go-side token copy) so the next launch starts logged out.
func (a *App) ClearSession() error {
a.token = nil
return platform.ClearSession()
}
// GetSettings is bound to the WebView. It returns the persisted desktop config,
// with LaunchAtLogin reflecting the actual LaunchAgent state (the plist is the
// source of truth — the user may have toggled it elsewhere).
func (a *App) GetSettings() platform.DesktopConfig {
cfg, _ := platform.LoadConfig()
cfg.LaunchAtLogin = platform.IsLaunchAtLoginEnabled()
return cfg
}
// SaveSettings is bound to the WebView. It persists the settings the page owns
// and applies the launch-at-login side effect (install / remove the LaunchAgent).
//
// The page's DesktopConfig has no device_name field, so the incoming cfg always
// carries DeviceName="". We merge onto the current config (preserving DeviceName,
// which is owned by SetDeviceName) instead of overwriting — otherwise saving any
// setting would wipe the persisted device name. Config is written before the
// launch-agent side effect so its failure doesn't lose the other preferences.
func (a *App) SaveSettings(cfg platform.DesktopConfig) error {
cur, _ := platform.LoadConfig()
cur.ClipboardSyncEnabled = cfg.ClipboardSyncEnabled
cur.LaunchAtLogin = cfg.LaunchAtLogin
cur.DownloadDir = cfg.DownloadDir
if err := platform.SaveConfig(cur); err != nil {
return err
}
// 让引擎的落地目录跟随设置变化(接收端直接写盘)。
if a.transfer != nil {
a.transfer.SetDownloadDir(platform.ResolveDownloadDir())
}
return platform.SetLaunchAtLogin(cur.LaunchAtLogin)
}
// DeviceName is bound to the WebView. Returns the persisted device name, or the
// hostname when the user hasn't renamed it — so the desktop skips the per-device
// naming step the browser shows.
func (a *App) DeviceName() string {
return platform.ResolveDeviceName()
}
// SetDeviceName is bound to the WebView; called when the user renames this device
// so the name survives restart (the WebView's localStorage doesn't, on wails://).
func (a *App) SetDeviceName(name string) error {
cfg, _ := platform.LoadConfig()
cfg.DeviceName = name
return platform.SaveConfig(cfg)
}
// SaveDownload is bound to the WebView. The transfer pipeline hands a received
// file's bytes (base64 over the bridge) to Go, which writes them into the user's
// configured download directory (default ~/Downloads) without overwriting. The
// absolute path is returned so the page can tell the user where it landed.
func (a *App) SaveDownload(name string, data []byte) (string, error) {
return platform.SaveDownload(platform.ResolveDownloadDir(), name, data)
}
// BeginDownload / AppendDownload / FinalizeDownload / AbortDownload are the
// streaming counterpart of SaveDownload, bound to the WebView. The receiver's
// hybrid sink keeps a file in memory until it crosses a cap, then spills the
// overflow here so large files don't sit in the WebView heap (the wails:// no-OPFS
// deadlock). Begin opens a temp file in the download dir, Append streams batches
// (base64 over the bridge), Finalize renames it into place and returns the path,
// Abort discards it on cancel / failure.
func (a *App) BeginDownload(sessionId string) error {
return platform.BeginStreamingDownload(platform.ResolveDownloadDir(), sessionId)
}
func (a *App) AppendDownload(sessionId string, data []byte) error {
return platform.AppendStreamingDownload(sessionId, data)
}
func (a *App) FinalizeDownload(sessionId, name string) (string, error) {
return platform.FinalizeStreamingDownload(sessionId, name)
}
func (a *App) AbortDownload(sessionId string) {
platform.AbortStreamingDownload(sessionId)
}
// ChooseDownloadDir opens a native directory picker (title localised by the
// caller) and returns the chosen path, or "" when the user cancels. The page
// persists the result via SaveSettings.
func (a *App) ChooseDownloadDir(title string) (string, error) {
return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: title})
}
// EffectiveDownloadDir returns the directory downloads currently land in — the
// configured override or the system default — for display in the settings page.
func (a *App) EffectiveDownloadDir() string {
return platform.ResolveDownloadDir()
}
// resolveOAuthConfig prefers explicit env overrides (dev / a pinned broker);
// otherwise it fetches /api/auth/config from the backend for the broker's public URL,
// then runs the broker device-authorization flow against it.
func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) {
if env := oauthConfigFromEnv(); env.BrokerURL != "" {
return env, nil
}
return platform.FetchOAuthConfig(a.ctx, a.apiBase)
}
func oauthConfigFromEnv() platform.OAuthConfig {
return platform.OAuthConfig{
BrokerURL: os.Getenv("CDROP_BROKER_URL"),
App: envOr("CDROP_BROKER_APP", "cdrop"),
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// LoggedIn reports whether a token is currently held; bound so the WebView can
// query auth state on startup.
func (a *App) LoggedIn() bool {
return a.token != nil
}
// Greet returns a greeting for the given name (scaffold demo, kept until the
// shared web UI replaces the default frontend).
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hello %s, It's show time!", name)
}
// --- 原生 P2P 数据面桥(绑定到 WebView ---
//
// WebView 侧的 p2pBackendGo-bridge 后端)调用这四个方法发起 / 接收 / 喂信令 / 取消;
// 引擎经 transferEvents 反向 EventsEmit 进度 / 状态 / 出站信令 / 落盘路径。HTTP 全留 JS:
// 出站信令由 JS POST /api/hub/signal,入站信令由 JS hub 转发进 P2PHandleSignal,状态机
// POST/p2p、/done、/fail)由 JS 据 p2p:state 事件发起。iceServersJSON 由 JS 透传
// (与 web 同源,来自 /api/calls/credentials)。
// P2PStartOutgoing 作为发送端发起:filePath 为本机绝对路径(经 PickFileForSend 获得)。
func (a *App) P2PStartOutgoing(sessionID, peerName, filePath, iceServersJSON string) error {
return a.transfer.StartOutgoing(sessionID, peerName, filePath, iceServersJSON)
}
// P2PStartIncoming 预备接收端,等对端 offer。
func (a *App) P2PStartIncoming(sessionID, peerName, iceServersJSON string) error {
return a.transfer.StartIncoming(sessionID, peerName, iceServersJSON)
}
// P2PHandleSignal 把 JS hub 从 SSE 收到的入站信令喂给引擎(按对端设备名路由)。
func (a *App) P2PHandleSignal(fromPeer, payloadJSON string) error {
return a.transfer.HandleSignal(fromPeer, payloadJSON)
}
// P2PCancel 拆除会话(取消 / 中继回退)。
func (a *App) P2PCancel(sessionID string) {
a.transfer.Cancel(sessionID)
}
// PickedFile 是原生文件选择的结果,供发送端走原生数据面(Go 直接按路径读盘,不经浏览器
// File 的沙箱无路径 + 内存约束)。
type PickedFile struct {
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
}
// ReadFileSlice 读 path 的 [start,end) 字节并 base64 返回。仅供中继回退时 WebView 侧的
// FileSource.slice 用(原生 P2P 由引擎直接按路径读盘、不走此桥);故 base64 开销只在罕见
// 的 relay 路径上,不影响 P2P 主路径。镜像 iOS 的 readOutgoingSlice。
func (a *App) ReadFileSlice(path string, start, end int64) (string, error) {
if start < 0 || end < start {
return "", fmt.Errorf("bad range [%d,%d)", start, end)
}
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
buf := make([]byte, end-start)
n, err := f.ReadAt(buf, start)
if err != nil && err != io.EOF {
return "", err
}
return base64.StdEncoding.EncodeToString(buf[:n]), nil
}
// PickFileForSend 打开原生文件对话框,返回选中文件的绝对路径 + 名 + 大小;用户取消返回 nil。
func (a *App) PickFileForSend(title string) (*PickedFile, error) {
path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{Title: title})
if err != nil {
return nil, err
}
if path == "" {
return nil, nil // 用户取消
}
fi, err := os.Stat(path)
if err != nil {
return nil, err
}
return &PickedFile{Path: path, Name: filepath.Base(path), Size: fi.Size()}, nil
}
// transferEvents 把引擎回调转成 Wails 事件,供 WebView 侧 p2pBackend 监听。回调可能在 pion
// 任意 goroutine 触发;Wails 的 EventsEmit / Log* 可跨线程安全调用。
type transferEvents struct{ app *App }
func (e *transferEvents) OnProgress(sessionID string, bytes int64) {
runtime.EventsEmit(e.app.ctx, "p2p:progress", map[string]any{"sessionId": sessionID, "bytes": bytes})
}
func (e *transferEvents) OnState(sessionID, state string) {
runtime.EventsEmit(e.app.ctx, "p2p:state", map[string]any{"sessionId": sessionID, "state": state})
}
func (e *transferEvents) OnSignal(sessionID, toPeer, payloadJSON string) {
runtime.EventsEmit(e.app.ctx, "p2p:signal", map[string]any{"sessionId": sessionID, "to": toPeer, "payload": payloadJSON})
}
func (e *transferEvents) OnSaved(sessionID, path string) {
runtime.EventsEmit(e.app.ctx, "p2p:saved", map[string]any{"sessionId": sessionID, "path": path})
}
func (e *transferEvents) OnIcePair(sessionID, local, remote string) {
runtime.EventsEmit(e.app.ctx, "p2p:icepair", map[string]any{"sessionId": sessionID, "local": local, "remote": remote})
}
func (e *transferEvents) OnLog(line string) {
runtime.LogInfof(e.app.ctx, "p2p: %s", line)
}