diff --git a/_client_cgo.go b/_client_cgo.go new file mode 100644 index 0000000..dfa085e --- /dev/null +++ b/_client_cgo.go @@ -0,0 +1,336 @@ +//go:build !cgo + +package client + +import ( + _ "embed" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "apigo.cc/gojs" + "apigo.cc/gojs/goja" + "github.com/ssgo/tool/watcher" + webview "github.com/webview/webview_go" +) + +//go:embed client.ts +var clientTS string + +//go:embed README.md +var clientMD string + +//go:embed app.js +var appCode string + +var binds = map[string]any{} +var bindsLock = sync.RWMutex{} + +var windowsId uint64 +var windows = map[uint64]*Webview{} +var windowsLock = sync.RWMutex{} +var waitChan chan bool + +func Bind(name string, fn any) { + bindsLock.Lock() + binds[name] = fn + bindsLock.Unlock() +} + +func Unbind(name string) { + bindsLock.Lock() + delete(binds, name) + bindsLock.Unlock() +} + +type Webview struct { + id uint64 + w webview.WebView + isRunning bool + // isAsync bool + isDebug bool + webRoot string + watcher *watcher.Watcher + html string + url string + closeChan chan bool +} + +func Wait() { + // 如果有窗口在运行,等待窗口关闭 + windowsLock.RLock() + if len(windows) > 0 { + waitChan = make(chan bool, 1) + } + windowsLock.RUnlock() + + // fmt.Println("====== wait start") + if waitChan != nil { + <-waitChan + waitChan = nil + } + // fmt.Println("====== wait end") +} + +func CloseAll() { + windows1 := map[uint64]*Webview{} + windowsLock.Lock() + for id, w := range windows { + windows1[id] = w + } + windowsLock.Unlock() + for _, w := range windows1 { + w.close() + } +} + +func (w *Webview) close() { + // fmt.Println("====== w.Close()") + if w.isRunning { + w.isRunning = false + // w.closeChan = make(chan bool, 1) + // fmt.Println("====== w.w.Terminate()") + w.w.Terminate() + // fmt.Println("====== w.w.Terminate() ok") + // <-w.closeChan + // w.closeChan = nil + } +} + +func (w *Webview) doClose() { + // fmt.Println("====== w.doClose()") + if w.watcher != nil { + w.watcher.Stop() + w.watcher = nil + } + windowsLock.Lock() + delete(windows, w.id) + n := len(windows) + windowsLock.Unlock() + + // fmt.Println("====== w.w.Destroy()") + w.w.Destroy() + // fmt.Println("====== w.w.Destroy() ok") + + // 解除 w.Close 的等待 + if w.closeChan != nil { + w.closeChan <- true + } + + // 解除 gojs.WaitAll 的等待 + if waitChan != nil && n == 0 { + waitChan <- true + } +} + +func (w *Webview) loadHtml(html string) { + w.w.SetHtml(html) +} + +func (w *Webview) loadURL(url string) { + if url != "" { + if strings.Contains(url, "://") { + w.w.Navigate(url) + } else { + if !filepath.IsAbs(url) { + curPath, _ := os.Getwd() + url = filepath.Join(curPath, url) + } + w.webRoot = filepath.Dir(url) + url = "file://" + url + w.w.Navigate(url) + } + } + w.w.Navigate(url) +} + +func (w *Webview) reload() { + if w.html != "" { + w.loadHtml(w.html) + } else { + w.loadURL(w.url) + } +} + +func (w *Webview) setTitle(title string) { + w.w.SetTitle(title) +} + +func (w *Webview) setSize(width int, height int, sizeMode string) { + w.w.SetSize(width, height, getSizeMode(sizeMode)) +} + +func (w *Webview) eval(code string) { + w.w.Eval(code) +} + +func (w *Webview) Close(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + w.close() + return nil +} + +func (w *Webview) LoadHtml(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(1) + w.loadHtml(args.Str(0)) + return nil +} + +func (w *Webview) LoadURL(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(1) + w.loadURL(args.Str(0)) + return nil +} + +func (w *Webview) Dispatch(f func()) { + w.w.Dispatch(f) +} + +func (w *Webview) SetTitle(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(1) + w.setTitle(args.Str(0)) + return nil +} + +func (w *Webview) SetSize(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(2) + w.setSize(args.Int(0), args.Int(1), args.Str(2)) + return nil +} + +func (w *Webview) Eval(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(1) + w.eval(args.Str(0)) + return nil +} + +func (w *Webview) binds() { + tmpBinds := map[string]any{} + bindsLock.RLock() + for k, v := range binds { + tmpBinds[k] = v + } + bindsLock.RUnlock() + for k, v := range tmpBinds { + _ = w.w.Bind(k, v) + } + _ = w.w.Bind("__closeWindow", w.close) + _ = w.w.Bind("__setTitle", w.setTitle) + _ = w.w.Bind("__setSize", w.setSize) + _ = w.w.Bind("__eval", w.eval) + w.w.Init(appCode) +} + +func getSizeMode(sizeMode string) webview.Hint { + switch sizeMode { + case "none": + return webview.HintNone + case "fixed": + return webview.HintFixed + case "min": + return webview.HintMin + case "max": + return webview.HintMax + default: + return webview.HintNone + } +} + +func init() { + obj := map[string]any{ + "open": func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm) + title := "" + width := 800 + height := 600 + sizeMode := "none" + isDebug := false + html := "" + url := "" + if opt := args.Obj(0); opt != nil { + if titleV := opt.Str("title"); titleV != "" { + title = titleV + } + if widthV := opt.Int("width"); widthV != 0 { + width = widthV + } + if heightV := opt.Int("height"); heightV != 0 { + height = heightV + } + if sizeModeV := opt.Str("sizeMode"); sizeModeV != "" { + sizeMode = sizeModeV + } + if isDebugV := opt.Bool("isDebug"); isDebugV { + isDebug = isDebugV + } + if htmlV := opt.Str("html"); htmlV != "" { + html = htmlV + } + if urlV := opt.Str("url"); urlV != "" { + url = urlV + if strings.HasPrefix(url, "file://") { + url = url[7:] + } + } + } + + w := &Webview{isDebug: isDebug, html: html, url: url} + // startChan := make(chan bool, 1) + gojs.RunInMain(func() { + windowsLock.Lock() + w.id = windowsId + windowsId++ + windows[w.id] = w + windowsLock.Unlock() + w.w = webview.New(isDebug) + w.w.SetTitle(title) + w.w.SetSize(width, height, getSizeMode(sizeMode)) + w.binds() + + if w.isDebug { + var isWaitingRun = false + if w.webRoot == "" && w.html != "" { + w.webRoot = "." + } + if w.webRoot != "" { + w.watcher, _ = watcher.Start([]string{w.webRoot}, []string{"html", "js", "css"}, func(filename string, event string) { + if !isWaitingRun { + isWaitingRun = true + go func() { + time.Sleep(time.Millisecond * 10) + isWaitingRun = false + w.w.Eval("location.reload()") + }() + } + }) + } + } + + w.isRunning = true + + w.reload() + // startChan <- true + w.w.Run() + // fmt.Println("====== w.w.Run() end") + w.doClose() + }) + + // <-startChan + return vm.ToValue(gojs.MakeMap(w)) + }, + "closeAll": func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + CloseAll() + return nil + }, + } + + gojs.Register("apigo.cc/gojs/client", gojs.Module{ + Object: obj, + Desc: "web client framework by github.com/webview/webview", + TsCode: clientTS, + Example: clientMD, + WaitForStop: Wait, + OnKill: CloseAll, + }) +} diff --git a/aaa/main.go b/aaa/main.go new file mode 100644 index 0000000..f0c88ac --- /dev/null +++ b/aaa/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "runtime" + + webview "github.com/webview/webview_go" +) + +func main() { + runtime.LockOSThread() // 锁定当前线程为主线程 + w := webview.New(false) + defer w.Destroy() + w.SetTitle("WebView Demo") + w.SetSize(800, 600, webview.HintNone) + w.Navigate("https://example.com") // 或本地文件:"file:///path/to/index.html" + w.Run() +} diff --git a/cgo.yml b/cgo.yml index 853fe84..abe3003 100644 --- a/cgo.yml +++ b/cgo.yml @@ -1,3 +1,3 @@ -CGO_ENABLED: true +# CGO_ENABLED: true CGO_CPPFLAGS: - -I%PROJECT%/include diff --git a/client.go b/client.go index 9b4b6a8..3571fab 100644 --- a/client.go +++ b/client.go @@ -2,8 +2,6 @@ package client import ( _ "embed" - "os" - "path/filepath" "strings" "sync" "time" @@ -11,9 +9,11 @@ import ( "apigo.cc/gojs" "apigo.cc/gojs/goja" "github.com/ssgo/tool/watcher" - webview "github.com/webview/webview_go" ) +// TODO 桌面应用使用 https://github.com/neutralinojs/neutralinojs +// TODO App使用 https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile + //go:embed client.ts var clientTS string @@ -43,19 +43,6 @@ func Unbind(name string) { bindsLock.Unlock() } -type Webview struct { - id uint64 - w webview.WebView - isRunning bool - // isAsync bool - isDebug bool - webRoot string - watcher *watcher.Watcher - html string - url string - closeChan chan bool -} - func Wait() { // 如果有窗口在运行,等待窗口关闭 windowsLock.RLock() @@ -84,107 +71,6 @@ func CloseAll() { } } -func (w *Webview) close() { - // fmt.Println("====== w.Close()") - if w.isRunning { - w.isRunning = false - // w.closeChan = make(chan bool, 1) - // fmt.Println("====== w.w.Terminate()") - w.w.Terminate() - // fmt.Println("====== w.w.Terminate() ok") - // <-w.closeChan - // w.closeChan = nil - } -} - -func (w *Webview) doClose() { - // fmt.Println("====== w.doClose()") - if w.watcher != nil { - w.watcher.Stop() - w.watcher = nil - } - windowsLock.Lock() - delete(windows, w.id) - n := len(windows) - windowsLock.Unlock() - - // fmt.Println("====== w.w.Destroy()") - w.w.Destroy() - // fmt.Println("====== w.w.Destroy() ok") - - // 解除 w.Close 的等待 - if w.closeChan != nil { - w.closeChan <- true - } - - // 解除 gojs.WaitAll 的等待 - if waitChan != nil && n == 0 { - waitChan <- true - } -} - -func (w *Webview) loadHtml(html string) { - w.w.SetHtml(html) -} - -func (w *Webview) loadURL(url string) { - if url != "" { - if strings.Contains(url, "://") { - w.w.Navigate(url) - } else { - if !filepath.IsAbs(url) { - curPath, _ := os.Getwd() - url = filepath.Join(curPath, url) - } - w.webRoot = filepath.Dir(url) - url = "file://" + url - w.w.Navigate(url) - } - } - w.w.Navigate(url) -} - -func (w *Webview) reload() { - if w.html != "" { - w.loadHtml(w.html) - } else { - w.loadURL(w.url) - } -} - -func (w *Webview) setTitle(title string) { - w.w.SetTitle(title) -} - -func (w *Webview) setSize(width int, height int, sizeMode string) { - w.w.SetSize(width, height, getSizeMode(sizeMode)) -} - -func (w *Webview) eval(code string) { - w.w.Eval(code) -} - -func (w *Webview) Close(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { - w.close() - return nil -} - -func (w *Webview) LoadHtml(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { - args := gojs.MakeArgs(&argsIn, vm).Check(1) - w.loadHtml(args.Str(0)) - return nil -} - -func (w *Webview) LoadURL(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { - args := gojs.MakeArgs(&argsIn, vm).Check(1) - w.loadURL(args.Str(0)) - return nil -} - -func (w *Webview) Dispatch(f func()) { - w.w.Dispatch(f) -} - func (w *Webview) SetTitle(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { args := gojs.MakeArgs(&argsIn, vm).Check(1) w.setTitle(args.Str(0)) @@ -203,36 +89,21 @@ func (w *Webview) Eval(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { return nil } -func (w *Webview) binds() { - tmpBinds := map[string]any{} - bindsLock.RLock() - for k, v := range binds { - tmpBinds[k] = v - } - bindsLock.RUnlock() - for k, v := range tmpBinds { - _ = w.w.Bind(k, v) - } - _ = w.w.Bind("__closeWindow", w.close) - _ = w.w.Bind("__setTitle", w.setTitle) - _ = w.w.Bind("__setSize", w.setSize) - _ = w.w.Bind("__eval", w.eval) - w.w.Init(appCode) +func (w *Webview) Close(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + w.close() + return nil } -func getSizeMode(sizeMode string) webview.Hint { - switch sizeMode { - case "none": - return webview.HintNone - case "fixed": - return webview.HintFixed - case "min": - return webview.HintMin - case "max": - return webview.HintMax - default: - return webview.HintNone - } +func (w *Webview) LoadHtml(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(1) + w.loadHtml(args.Str(0)) + return nil +} + +func (w *Webview) LoadURL(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(1) + w.loadURL(args.Str(0)) + return nil } func init() { @@ -275,30 +146,37 @@ func init() { w := &Webview{isDebug: isDebug, html: html, url: url} // startChan := make(chan bool, 1) + // fmt.Println("====== start") gojs.RunInMain(func() { + // fmt.Println("====== gojs.RunInMain") windowsLock.Lock() w.id = windowsId windowsId++ windows[w.id] = w windowsLock.Unlock() - w.w = webview.New(isDebug) - w.w.SetTitle(title) - w.w.SetSize(width, height, getSizeMode(sizeMode)) + // fmt.Println("====== w.New()") + w.New(isDebug) + // fmt.Println("====== 1") + w.setTitle(title) + // fmt.Println("====== 2") + w.setSize(width, height, sizeMode) + // fmt.Println("====== 3") w.binds() + // fmt.Println("====== w.Run()") if w.isDebug { var isWaitingRun = false if w.webRoot == "" && w.html != "" { w.webRoot = "." } if w.webRoot != "" { - w.watcher, _ = watcher.Start([]string{w.webRoot}, []string{"html", "js", "css"}, func(filename string, event string) { + w.watcher, _ = watcher.Start([]string{w.webRoot}, []string{"html", "js", "css"}, nil, func(filename string, event string) { if !isWaitingRun { isWaitingRun = true go func() { time.Sleep(time.Millisecond * 10) isWaitingRun = false - w.w.Eval("location.reload()") + w.eval("location.reload()") }() } }) @@ -307,10 +185,12 @@ func init() { w.isRunning = true + // fmt.Println("====== w.reload()") w.reload() // startChan <- true - w.w.Run() - // fmt.Println("====== w.w.Run() end") + // fmt.Println("====== w.Run()") + w.Run() + // fmt.Println("====== w.Run() end") w.doClose() }) @@ -325,7 +205,7 @@ func init() { gojs.Register("apigo.cc/gojs/client", gojs.Module{ Object: obj, - Desc: "web client framework by github.com/webview/webview", + Desc: "web client framework by github.com/chromedp/chromedp or github.com/webview/webview", TsCode: clientTS, Example: clientMD, WaitForStop: Wait, diff --git a/client_chrome.go b/client_chrome.go new file mode 100644 index 0000000..7d0d458 --- /dev/null +++ b/client_chrome.go @@ -0,0 +1,352 @@ +//go:build !cgo +// +build !cgo + +package client + +import ( + "bufio" + "context" + _ "embed" + "fmt" + "net/url" + "os" + "path/filepath" + "reflect" + "runtime" + "strconv" + "strings" + + chromeRuntime "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" + "github.com/ssgo/tool/watcher" + "github.com/ssgo/u" +) + +type Webview struct { + id uint64 + ctx context.Context + cancel context.CancelFunc + isRunning bool + // isAsync bool + isDebug bool + webRoot string + watcher *watcher.Watcher + html string + url string + closeChan chan bool + width int + height int + title string + evals []string + bindFuncs map[string]any +} + +var chromeVersion = -1 + +func (w *Webview) New(isDebug bool) { + if chromeVersion == -1 { + chromeVersion = getChromeVersion() + } + w.isDebug = isDebug +} + +func (w *Webview) close() { + // fmt.Println("====== w.Close()") + if w.isRunning { + w.isRunning = false + // w.closeChan = make(chan bool, 1) + // fmt.Println("====== w.w.Terminate()") + w.cancel() + // fmt.Println("====== w.w.Terminate() ok") + // <-w.closeChan + // w.closeChan = nil + } +} + +func (w *Webview) doClose() { + // fmt.Println("====== w.doClose()") + if w.watcher != nil { + w.watcher.Stop() + w.watcher = nil + } + windowsLock.Lock() + delete(windows, w.id) + n := len(windows) + windowsLock.Unlock() + + // fmt.Println("====== w.w.Destroy()") + if w.cancel != nil { + w.cancel() + } + // fmt.Println("====== w.w.Destroy() ok") + + // 解除 w.Close 的等待 + if w.closeChan != nil { + w.closeChan <- true + } + + // 解除 gojs.WaitAll 的等待 + if waitChan != nil && n == 0 { + waitChan <- true + } +} + +func (w *Webview) loadHtml(html string) { + w.url = "data:text/html;charset=utf-8," + url.PathEscape(html) + if w.ctx != nil { + chromedp.Run(w.ctx, chromedp.Navigate(w.url)) + } +} + +func (w *Webview) loadURL(url string) { + if url != "" { + if strings.Contains(url, "://") { + // chromedp.Run(w.ctx, chromedp.Navigate(url)) + w.url = url + } else { + if !filepath.IsAbs(url) { + curPath, _ := os.Getwd() + url = filepath.Join(curPath, url) + } + w.webRoot = filepath.Dir(url) + w.url = "file://" + url + // chromedp.Run(w.ctx, chromedp.Navigate(url)) + } + } else { + // chromedp.Run(w.ctx, chromedp.Navigate("about:blank")) + w.url = "about:blank" + } + + if w.ctx != nil { + chromedp.Run(w.ctx, chromedp.Navigate(w.url)) + } +} + +func (w *Webview) reload() { + if w.html != "" { + w.loadHtml(w.html) + } else { + w.loadURL(w.url) + } +} + +func (w *Webview) setTitle(title string) { + w.title = title + if w.ctx != nil { + chromedp.Run(w.ctx, chromedp.Evaluate(`document.title = "`+w.title+`"`, nil)) + } +} + +func (w *Webview) setSize(width int, height int, sizeMode string) { + w.width = width + w.height = height + if w.ctx != nil { + // chromedp.Run(w.ctx, chromedp.EmulateViewport(int64(w.width), int64(w.height))) + } +} + +func (w *Webview) eval(code string) any { + if w.ctx == nil { + w.evals = append(w.evals, code) + return nil + } + + var result any + chromedp.Run(w.ctx, chromedp.Evaluate(code, &result)) + return result +} + +func (w *Webview) Dispatch(f func()) { + // w.w.Dispatch(f) + f() +} + +func (w *Webview) Run() { + // chromeVersion = 0 + if chromeVersion > 0 { + // 根据版本调整配置 + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", false), // 显示窗口 + chromedp.Flag("window-size", fmt.Sprintf("%d,%d", w.width, w.height)), // 窗口尺寸 + chromedp.Flag("app", w.url), // 直接启动到目标页 + chromedp.Flag("disable-notifications", true), // 禁用通知 + chromedp.Flag("no-first-run", true), // 跳过首次运行 + chromedp.Flag("no-default-browser-check", true), // 禁用默认浏览器检查 + chromedp.Flag("disable-component-update", true), // 禁用组件更新 + chromedp.Flag("disable-background-networking", true), // 禁用后台网络 + chromedp.Flag("disable-client-side-phishing-detection", true), // 禁用钓鱼检测 + ) + + if chromeVersion >= 115 { + // 新版本 + opts = append(opts, + chromedp.Flag("enable-automation", false), + // chromedp.Flag("excludeSwitches", []string{"enable-automation"}), + chromedp.Flag("useAutomationExtension", false), + ) + } else if chromeVersion >= 90 { + // Chrome 90+ 参数 + opts = append(opts, + chromedp.Flag("disable-blink-features", "AutomationControlled"), + ) + } else { + // Chrome 89 及以下参数 + opts = append(opts, + chromedp.Flag("disable-infobars", true), + chromedp.Flag("disable-extensions", true), + ) + } + + allocCtx, cancel1 := chromedp.NewExecAllocator(context.Background(), opts...) + ctx, cancel2 := chromedp.NewContext(allocCtx) + w.ctx = ctx + w.cancel = func() { + cancel2() + cancel1() + } + + codes := []string{getDetectionScript(chromeVersion)} + if w.evals != nil { + codes = append(codes, strings.Join(w.evals, "\n")) + } + + chromedp.ListenTarget(w.ctx, func(ev any) { + if ev, ok := ev.(*chromeRuntime.EventBindingCalled); ok { + if fn, ok := w.bindFuncs[ev.Name]; ok { + fnV := reflect.ValueOf(fn) + if fnV.Kind() == reflect.Func { + var args []reflect.Value + if ev.Payload != "" { + argsV := u.FinalValue(reflect.ValueOf(u.UnJson(ev.Payload, nil))) + if argsV.Kind() == reflect.Slice { + for i := 0; i < fnV.Type().NumIn(); i++ { + argType := fnV.Type().In(i) + argValueP := reflect.New(argType) + argValue := argValueP.Elem() + if i < argsV.Len() { + u.Convert(argsV.Index(i), argValueP) + } + args = append(args, argValue) + } + } + go func() { + _ = fnV.Call(args) + }() + } + } + } + } + }) + + actions := []chromedp.Action{} + + for k, _ := range w.bindFuncs { + actions = append(actions, chromeRuntime.AddBinding(k)) + codes = append(codes, fmt.Sprintf("window.__%s = %s; window.%s = function () { __%s(JSON.stringify(Array.from(arguments))) }", k, k, k, k)) + } + codes = append(codes, appCode) + actions = append(actions, chromedp.Evaluate(strings.Join(codes, "\n"), nil)) + + exitChan := make(chan bool, 1) + chromedp.Run(w.ctx, actions...) + + go func() { + <-w.ctx.Done() + exitChan <- true + }() + + <-exitChan + } else { + if runtime.GOOS == "windows" { + u.RunCommand("start", w.url) + } else if runtime.GOOS == "darwin" { + u.RunCommand("open", w.url) + } else { + u.RunCommand("xdg-open", w.url) + } + + fmt.Println("URL: ", w.url) + fmt.Println("Press Enter to exit...") + reader := bufio.NewReader(os.Stdin) + _, _ = reader.ReadString('\n') + } +} + +func (w *Webview) binds() { + w.bindFuncs = map[string]any{} + bindsLock.RLock() + for k, v := range binds { + w.bindFuncs[k] = v + } + bindsLock.RUnlock() + + w.bindFuncs["__closeWindow"] = w.close + w.bindFuncs["__setTitle"] = w.setTitle + w.bindFuncs["__setSize"] = w.setSize + w.bindFuncs["__eval"] = w.eval +} + +// 获取浏览器版本(精确到主版本号) +func getChromeVersion() int { + var ua string + ctx, cancel := chromedp.NewContext(context.Background()) + defer cancel() + if err := chromedp.Run(ctx, + chromedp.Evaluate("navigator.userAgent", &ua), + ); err != nil { + return 0 + } + + // 解析示例:Mozilla/5.0... Chrome/124.0.0.0... + start := strings.Index(ua, "Chrome/") + if start == -1 { + return 0 + } + + versionPart := ua[start+7:] + end := strings.Index(versionPart, ".") + if end == -1 { + return 0 + } + + version, _ := strconv.Atoi(versionPart[:end]) + return version +} + +// 生成版本自适应的检测脚本 +func getDetectionScript(version int) string { + baseJS := ` + (() => { + // 核心属性修改 + Object.defineProperty(navigator, 'webdriver', { + get: () => false, + configurable: true + }); + + // 通用内存清理 + const legacyProps = ['$cdc', '__driver_evaluate']; + const modernProps = ['_cdc', '__driver', '__automation']; + [...legacyProps, ...modernProps].forEach(p => { + try { delete window[p] } catch(e){} + try { delete document[p] } catch(e){} + });` + + if version >= 120 { + // 修复点:将 JS 模板字符串改为普通字符串拼接 + baseJS += ` + // 伪造 Chrome 运行时 + window.chrome = { + runtime: { + id: 'mock_extension', + getManifest: () => ({ version: '1.0' }), + getURL: function(path) { + return 'chrome-extension://mock_extension/' + path; + }, + onMessage: { addListener: function(){} } + } + };` + } + + baseJS += "\n})();" + return baseJS + "\n" +} diff --git a/client_webview.go b/client_webview.go new file mode 100644 index 0000000..ab16fa9 --- /dev/null +++ b/client_webview.go @@ -0,0 +1,153 @@ +//go:build cgo +// +build cgo + +package client + +import ( + _ "embed" + "os" + "path/filepath" + "strings" + + "github.com/ssgo/tool/watcher" + webview "github.com/webview/webview_go" +) + +type Webview struct { + id uint64 + w webview.WebView + isRunning bool + // isAsync bool + isDebug bool + webRoot string + watcher *watcher.Watcher + html string + url string + closeChan chan bool +} + +func (w *Webview) New(isDebug bool) { + w.w = webview.New(isDebug) +} + +func (w *Webview) close() { + // fmt.Println("====== w.Close()") + if w.isRunning { + w.isRunning = false + // w.closeChan = make(chan bool, 1) + // fmt.Println("====== w.w.Terminate()") + w.w.Terminate() + // fmt.Println("====== w.w.Terminate() ok") + // <-w.closeChan + // w.closeChan = nil + } +} + +func (w *Webview) doClose() { + // fmt.Println("====== w.doClose()") + if w.watcher != nil { + w.watcher.Stop() + w.watcher = nil + } + windowsLock.Lock() + delete(windows, w.id) + n := len(windows) + windowsLock.Unlock() + + // fmt.Println("====== w.w.Destroy()") + w.w.Destroy() + // fmt.Println("====== w.w.Destroy() ok") + + // 解除 w.Close 的等待 + if w.closeChan != nil { + w.closeChan <- true + } + + // 解除 gojs.WaitAll 的等待 + if waitChan != nil && n == 0 { + waitChan <- true + } +} + +func (w *Webview) loadHtml(html string) { + w.w.SetHtml(html) +} + +func (w *Webview) loadURL(url string) { + if url != "" { + if strings.Contains(url, "://") { + w.w.Navigate(url) + } else { + if !filepath.IsAbs(url) { + curPath, _ := os.Getwd() + url = filepath.Join(curPath, url) + } + w.webRoot = filepath.Dir(url) + url = "file://" + url + w.w.Navigate(url) + } + } else { + w.w.Navigate("about:blank") + } +} + +func (w *Webview) reload() { + if w.html != "" { + w.loadHtml(w.html) + } else { + w.loadURL(w.url) + } +} + +func (w *Webview) setTitle(title string) { + w.w.SetTitle(title) +} + +func (w *Webview) setSize(width int, height int, sizeMode string) { + w.w.SetSize(width, height, getSizeMode(sizeMode)) +} + +func (w *Webview) eval(code string) any { + w.w.Eval(code) + return nil +} + +func (w *Webview) Dispatch(f func()) { + w.w.Dispatch(f) +} + +func (w *Webview) Run() { + w.w.Run() +} + +func (w *Webview) binds() { + tmpBinds := map[string]any{} + bindsLock.RLock() + for k, v := range binds { + tmpBinds[k] = v + } + bindsLock.RUnlock() + for k, v := range tmpBinds { + _ = w.w.Bind(k, v) + } + _ = w.w.Bind("__closeWindow", w.close) + _ = w.w.Bind("__setTitle", w.setTitle) + _ = w.w.Bind("__setSize", w.setSize) + _ = w.w.Bind("__eval", w.eval) + w.w.Init(appCode) +} + +func getSizeMode(sizeMode string) webview.Hint { + switch sizeMode { + case "none": + return webview.HintNone + case "fixed": + return webview.HintFixed + case "min": + return webview.HintMin + case "max": + return webview.HintMax + default: + return webview.HintNone + } +} diff --git a/demo/app/.gitignore b/demo/app/.gitignore new file mode 100644 index 0000000..4a3dc9d --- /dev/null +++ b/demo/app/.gitignore @@ -0,0 +1,13 @@ +# Developer tools' files +.lite_workspace.lua + +# Neutralinojs binaries and builds +/bin +/dist + +# Neutralinojs client (minified) +neutralino.js + +# Neutralinojs related files +.storage +*.log diff --git a/demo/app/LICENSE b/demo/app/LICENSE new file mode 100644 index 0000000..2f31491 --- /dev/null +++ b/demo/app/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Neutralinojs and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/demo/app/README.md b/demo/app/README.md new file mode 100644 index 0000000..fe348b7 --- /dev/null +++ b/demo/app/README.md @@ -0,0 +1,15 @@ +# neutralinojs-minimal + +The default template for a Neutralinojs app. It's possible to use your favorite frontend framework by using [these steps](https://neutralino.js.org/docs/getting-started/using-frontend-libraries). + +## Contributors + +[![Contributors](https://contrib.rocks/image?repo=neutralinojs/neutralinojs-minimal)](https://github.com/neutralinojs/neutralinojs-minimal/graphs/contributors) + +## License + +[MIT](LICENSE) + +## Icon credits + +- `trayIcon.png` - Made by [Freepik](https://www.freepik.com) and downloaded from [Flaticon](https://www.flaticon.com) diff --git a/demo/app/neutralino.config.json b/demo/app/neutralino.config.json new file mode 100644 index 0000000..8d47bc6 --- /dev/null +++ b/demo/app/neutralino.config.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://raw.githubusercontent.com/neutralinojs/neutralinojs/main/schemas/neutralino.config.schema.json", + "applicationId": "js.neutralino.sample", + "version": "1.0.0", + "defaultMode": "window", + "port": 0, + "documentRoot": "/resources/", + "url": "/", + "enableServer": true, + "enableNativeAPI": true, + "tokenSecurity": "one-time", + "logging": { + "enabled": true, + "writeToLogFile": true + }, + "nativeAllowList": [ + "app.*", + "os.*", + "debug.log" + ], + "globalVariables": { + "TEST1": "Hello", + "TEST2": [ + 2, + 4, + 5 + ], + "TEST3": { + "value1": 10, + "value2": {} + } + }, + "modes": { + "window": { + "title": "app", + "width": 800, + "height": 500, + "minWidth": 400, + "minHeight": 200, + "center": true, + "fullScreen": false, + "alwaysOnTop": false, + "icon": "/resources/icons/appIcon.png", + "enableInspector": true, + "borderless": false, + "maximize": false, + "hidden": false, + "resizable": true, + "exitProcessOnClose": false + }, + "browser": { + "globalVariables": { + "TEST": "Test value browser" + }, + "nativeBlockList": [ + "filesystem.*" + ] + }, + "cloud": { + "url": "/resources/#cloud", + "nativeAllowList": [ + "app.*" + ] + }, + "chrome": { + "width": 800, + "height": 500, + "args": "--user-agent=\"Neutralinojs chrome mode\"", + "nativeBlockList": [ + "filesystem.*", + "os.*" + ] + } + }, + "cli": { + "binaryName": "app", + "resourcesPath": "/resources/", + "extensionsPath": "/extensions/", + "clientLibrary": "/resources/js/neutralino.js", + "binaryVersion": "6.1.0", + "clientVersion": "6.1.0" + } +} \ No newline at end of file diff --git a/demo/app/resources/icons/appIcon.png b/demo/app/resources/icons/appIcon.png new file mode 100644 index 0000000..d708bc3 Binary files /dev/null and b/demo/app/resources/icons/appIcon.png differ diff --git a/demo/app/resources/icons/logo.gif b/demo/app/resources/icons/logo.gif new file mode 100644 index 0000000..058c9bf Binary files /dev/null and b/demo/app/resources/icons/logo.gif differ diff --git a/demo/app/resources/icons/trayIcon.png b/demo/app/resources/icons/trayIcon.png new file mode 100644 index 0000000..bcfeb18 Binary files /dev/null and b/demo/app/resources/icons/trayIcon.png differ diff --git a/demo/app/resources/index.html b/demo/app/resources/index.html new file mode 100644 index 0000000..edcee61 --- /dev/null +++ b/demo/app/resources/index.html @@ -0,0 +1,27 @@ + + + + + NeutralinoJs sample app + + + +
+

NeutralinoJs

+
+
+ Neutralinojs +
+ Docs · + Video tutorial +
+
+ + + + + + diff --git a/demo/app/resources/js/main.js b/demo/app/resources/js/main.js new file mode 100644 index 0000000..09c297f --- /dev/null +++ b/demo/app/resources/js/main.js @@ -0,0 +1,99 @@ +// This is just a sample app. You can structure your Neutralinojs app code as you wish. +// This example app is written with vanilla JavaScript and HTML. +// Feel free to use any frontend framework you like :) +// See more details: https://neutralino.js.org/docs/how-to/use-a-frontend-library + +/* + Function to display information about the Neutralino app. + This function updates the content of the 'info' element in the HTML + with details regarding the running Neutralino application, including + its ID, port, operating system, and version information. +*/ +function showInfo() { + document.getElementById('info').innerHTML = ` + ${NL_APPID} is running on port ${NL_PORT} inside ${NL_OS} +

+ server: v${NL_VERSION} . client: v${NL_CVERSION} + `; +} + +/* + Function to open the official Neutralino documentation in the default web browser. +*/ +function openDocs() { + Neutralino.os.open("https://neutralino.js.org/docs"); +} + +/* + Function to open a tutorial video on Neutralino's official YouTube channel in the default web browser. +*/ +function openTutorial() { + Neutralino.os.open("https://www.youtube.com/c/CodeZri"); +} + +/* + Function to set up a system tray menu with options specific to the window mode. + This function checks if the application is running in window mode, and if so, + it defines the tray menu items and sets up the tray accordingly. +*/ +function setTray() { + // Tray menu is only available in window mode + if(NL_MODE != "window") { + console.log("INFO: Tray menu is only available in the window mode."); + return; + } + + // Define tray menu items + let tray = { + icon: "/resources/icons/trayIcon.png", + menuItems: [ + {id: "VERSION", text: "Get version"}, + {id: "SEP", text: "-"}, + {id: "QUIT", text: "Quit"} + ] + }; + + // Set the tray menu + Neutralino.os.setTray(tray); +} + +/* + Function to handle click events on the tray menu items. + This function performs different actions based on the clicked item's ID, + such as displaying version information or exiting the application. +*/ +function onTrayMenuItemClicked(event) { + switch(event.detail.id) { + case "VERSION": + // Display version information + Neutralino.os.showMessageBox("Version information", + `Neutralinojs server: v${NL_VERSION} | Neutralinojs client: v${NL_CVERSION}`); + break; + case "QUIT": + // Exit the application + Neutralino.app.exit(); + break; + } +} + +/* + Function to handle the window close event by gracefully exiting the Neutralino application. +*/ +function onWindowClose() { + Neutralino.app.exit(); +} + +// Initialize Neutralino +Neutralino.init(); + +// Register event listeners +Neutralino.events.on("trayMenuItemClicked", onTrayMenuItemClicked); +Neutralino.events.on("windowClose", onWindowClose); + +// Conditional initialization: Set up system tray if not running on macOS +if(NL_OS != "Darwin") { // TODO: Fix https://github.com/neutralinojs/neutralinojs/issues/615 + setTray(); +} + +// Display app information +showInfo(); diff --git a/demo/app/resources/js/neutralino.d.ts b/demo/app/resources/js/neutralino.d.ts new file mode 100644 index 0000000..97dc7e6 --- /dev/null +++ b/demo/app/resources/js/neutralino.d.ts @@ -0,0 +1,897 @@ +// Type definitions for Neutralino 6.1.0 +// Project: https://github.com/neutralinojs +// Definitions project: https://github.com/neutralinojs/neutralino.js + +declare namespace Neutralino { + +namespace filesystem { + interface DirectoryEntry { + entry: string; + path: string; + type: string; + } + interface FileReaderOptions { + pos: number; + size: number; + } + interface DirectoryReaderOptions { + recursive: boolean; + } + interface OpenedFile { + id: number; + eof: boolean; + pos: number; + lastRead: number; + } + interface Stats { + size: number; + isFile: boolean; + isDirectory: boolean; + createdAt: number; + modifiedAt: number; + } + interface Watcher { + id: number; + path: string; + } + interface CopyOptions { + recursive: boolean; + overwrite: boolean; + skip: boolean; + } + interface PathParts { + rootName: string; + rootDirectory: string; + rootPath: string; + relativePath: string; + parentPath: string; + filename: string; + stem: string; + extension: string; + } + interface Permissions { + all: boolean; + ownerAll: boolean; + ownerRead: boolean; + ownerWrite: boolean; + ownerExec: boolean; + groupAll: boolean; + groupRead: boolean; + groupWrite: boolean; + groupExec: boolean; + othersAll: boolean; + othersRead: boolean; + othersWrite: boolean; + othersExec: boolean; + } + type PermissionsMode = "ADD" | "REPLACE" | "REMOVE"; + function createDirectory(path: string): Promise; + function remove(path: string): Promise; + function writeFile(path: string, data: string): Promise; + function appendFile(path: string, data: string): Promise; + function writeBinaryFile(path: string, data: ArrayBuffer): Promise; + function appendBinaryFile(path: string, data: ArrayBuffer): Promise; + function readFile(path: string, options?: FileReaderOptions): Promise; + function readBinaryFile(path: string, options?: FileReaderOptions): Promise; + function openFile(path: string): Promise; + function createWatcher(path: string): Promise; + function removeWatcher(id: number): Promise; + function getWatchers(): Promise; + function updateOpenedFile(id: number, event: string, data?: any): Promise; + function getOpenedFileInfo(id: number): Promise; + function readDirectory(path: string, options?: DirectoryReaderOptions): Promise; + function copy(source: string, destination: string, options?: CopyOptions): Promise; + function move(source: string, destination: string): Promise; + function getStats(path: string): Promise; + function getAbsolutePath(path: string): Promise; + function getRelativePath(path: string, base?: string): Promise; + function getPathParts(path: string): Promise; + function getPermissions(path: string): Promise; + function setPermissions(path: string, permissions: Permissions, mode: PermissionsMode): Promise; +} +namespace os { + // debug + enum LoggerType { + WARNING = "WARNING", + ERROR = "ERROR", + INFO = "INFO" + } + // os + enum Icon { + WARNING = "WARNING", + ERROR = "ERROR", + INFO = "INFO", + QUESTION = "QUESTION" + } + enum MessageBoxChoice { + OK = "OK", + OK_CANCEL = "OK_CANCEL", + YES_NO = "YES_NO", + YES_NO_CANCEL = "YES_NO_CANCEL", + RETRY_CANCEL = "RETRY_CANCEL", + ABORT_RETRY_IGNORE = "ABORT_RETRY_IGNORE" + } + //clipboard + enum ClipboardFormat { + unknown = "unknown", + text = "text", + image = "image" + } + // NL_GLOBALS + enum Mode { + window = "window", + browser = "browser", + cloud = "cloud", + chrome = "chrome" + } + enum OperatingSystem { + Linux = "Linux", + Windows = "Windows", + Darwin = "Darwin", + FreeBSD = "FreeBSD", + Unknown = "Unknown" + } + enum Architecture { + x64 = "x64", + arm = "arm", + itanium = "itanium", + ia32 = "ia32", + unknown = "unknown" + } + interface ExecCommandOptions { + stdIn?: string; + background?: boolean; + cwd?: string; + } + interface ExecCommandResult { + pid: number; + stdOut: string; + stdErr: string; + exitCode: number; + } + interface SpawnedProcess { + id: number; + pid: number; + } + interface SpawnedProcessOptions { + cwd?: string; + envs?: Record; + } + interface Envs { + [key: string]: string; + } + interface OpenDialogOptions { + multiSelections?: boolean; + filters?: Filter[]; + defaultPath?: string; + } + interface FolderDialogOptions { + defaultPath?: string; + } + interface SaveDialogOptions { + forceOverwrite?: boolean; + filters?: Filter[]; + defaultPath?: string; + } + interface Filter { + name: string; + extensions: string[]; + } + interface TrayOptions { + icon: string; + menuItems: TrayMenuItem[]; + } + interface TrayMenuItem { + id?: string; + text: string; + isDisabled?: boolean; + isChecked?: boolean; + } + type KnownPath = "config" | "data" | "cache" | "documents" | "pictures" | "music" | "video" | "downloads" | "savedGames1" | "savedGames2" | "temp"; + function execCommand(command: string, options?: ExecCommandOptions): Promise; + function spawnProcess(command: string, options?: SpawnedProcessOptions): Promise; + function updateSpawnedProcess(id: number, event: string, data?: any): Promise; + function getSpawnedProcesses(): Promise; + function getEnv(key: string): Promise; + function getEnvs(): Promise; + function showOpenDialog(title?: string, options?: OpenDialogOptions): Promise; + function showFolderDialog(title?: string, options?: FolderDialogOptions): Promise; + function showSaveDialog(title?: string, options?: SaveDialogOptions): Promise; + function showNotification(title: string, content: string, icon?: Icon): Promise; + function showMessageBox(title: string, content: string, choice?: MessageBoxChoice, icon?: Icon): Promise; + function setTray(options: TrayOptions): Promise; + function open(url: string): Promise; + function getPath(name: KnownPath): Promise; +} +namespace computer { + interface MemoryInfo { + physical: { + total: number; + available: number; + }; + virtual: { + total: number; + available: number; + }; + } + interface KernelInfo { + variant: string; + version: string; + } + interface OSInfo { + name: string; + description: string; + version: string; + } + interface CPUInfo { + vendor: string; + model: string; + frequency: number; + architecture: string; + logicalThreads: number; + physicalCores: number; + physicalUnits: number; + } + interface Display { + id: number; + resolution: Resolution; + dpi: number; + bpp: number; + refreshRate: number; + } + interface Resolution { + width: number; + height: number; + } + interface MousePosition { + x: number; + y: number; + } + function getMemoryInfo(): Promise; + function getArch(): Promise; + function getKernelInfo(): Promise; + function getOSInfo(): Promise; + function getCPUInfo(): Promise; + function getDisplays(): Promise; + function getMousePosition(): Promise; +} +namespace storage { + function setData(key: string, data: string): Promise; + function getData(key: string): Promise; + function getKeys(): Promise; +} +namespace debug { + // debug + enum LoggerType { + WARNING = "WARNING", + ERROR = "ERROR", + INFO = "INFO" + } + // os + enum Icon { + WARNING = "WARNING", + ERROR = "ERROR", + INFO = "INFO", + QUESTION = "QUESTION" + } + enum MessageBoxChoice { + OK = "OK", + OK_CANCEL = "OK_CANCEL", + YES_NO = "YES_NO", + YES_NO_CANCEL = "YES_NO_CANCEL", + RETRY_CANCEL = "RETRY_CANCEL", + ABORT_RETRY_IGNORE = "ABORT_RETRY_IGNORE" + } + //clipboard + enum ClipboardFormat { + unknown = "unknown", + text = "text", + image = "image" + } + // NL_GLOBALS + enum Mode { + window = "window", + browser = "browser", + cloud = "cloud", + chrome = "chrome" + } + enum OperatingSystem { + Linux = "Linux", + Windows = "Windows", + Darwin = "Darwin", + FreeBSD = "FreeBSD", + Unknown = "Unknown" + } + enum Architecture { + x64 = "x64", + arm = "arm", + itanium = "itanium", + ia32 = "ia32", + unknown = "unknown" + } + function log(message: string, type?: LoggerType): Promise; +} +namespace app { + interface OpenActionOptions { + url: string; + } + interface RestartOptions { + args: string; + } + function exit(code?: number): Promise; + function killProcess(): Promise; + function restartProcess(options?: RestartOptions): Promise; + function getConfig(): Promise; + function broadcast(event: string, data?: any): Promise; + function readProcessInput(readAll?: boolean): Promise; + function writeProcessOutput(data: string): Promise; + function writeProcessError(data: string): Promise; +} +namespace window { + interface WindowOptions extends WindowSizeOptions, WindowPosOptions { + title?: string; + icon?: string; + fullScreen?: boolean; + alwaysOnTop?: boolean; + enableInspector?: boolean; + borderless?: boolean; + maximize?: boolean; + hidden?: boolean; + maximizable?: boolean; + useSavedState?: boolean; + exitProcessOnClose?: boolean; + extendUserAgentWith?: string; + injectGlobals?: boolean; + injectClientLibrary?: boolean; + injectScript?: string; + processArgs?: string; + } + interface WindowSizeOptions { + width?: number; + height?: number; + minWidth?: number; + minHeight?: number; + maxWidth?: number; + maxHeight?: number; + resizable?: boolean; + } + interface WindowPosOptions { + x: number; + y: number; + } + interface WindowMenu extends Array { + } + interface WindowMenuItem { + id?: string; + text: string; + isDisabled?: boolean; + isChecked?: boolean; + menuItems?: WindowMenuItem[]; + } + function setTitle(title: string): Promise; + function getTitle(): Promise; + function maximize(): Promise; + function unmaximize(): Promise; + function isMaximized(): Promise; + function minimize(): Promise; + function unminimize(): Promise; + function isMinimized(): Promise; + function setFullScreen(): Promise; + function exitFullScreen(): Promise; + function isFullScreen(): Promise; + function show(): Promise; + function hide(): Promise; + function isVisible(): Promise; + function focus(): Promise; + function setIcon(icon: string): Promise; + function move(x: number, y: number): Promise; + function center(): Promise; + type DraggableRegionOptions = { + /** + * If set to `true`, the region will always capture the pointer, + * ensuring dragging doesn't break on fast pointer movement. + * Note that it prevents child elements from receiving any pointer events. + * Defaults to `false`. + */ + alwaysCapture?: boolean; + /** + * Minimum distance between cursor's starting and current position + * after which dragging is started. This helps prevent accidental dragging + * while interacting with child elements. + * Defaults to `10`. (In pixels.) + */ + dragMinDistance?: number; + }; + function setDraggableRegion(domElementOrId: string | HTMLElement, options?: DraggableRegionOptions): Promise<{ + success: true; + message: string; + }>; + function unsetDraggableRegion(domElementOrId: string | HTMLElement): Promise<{ + success: true; + message: string; + }>; + function setSize(options: WindowSizeOptions): Promise; + function getSize(): Promise; + function getPosition(): Promise; + function setAlwaysOnTop(onTop: boolean): Promise; + function create(url: string, options?: WindowOptions): Promise; + function snapshot(path: string): Promise; + function setMainMenu(options: WindowMenu): Promise; +} +namespace events { + interface Response { + success: boolean; + message: string; + } + type Builtin = "ready" | "trayMenuItemClicked" | "windowClose" | "serverOffline" | "clientConnect" | "clientDisconnect" | "appClientConnect" | "appClientDisconnect" | "extClientConnect" | "extClientDisconnect" | "extensionReady" | "neuDev_reloadApp"; + function on(event: string, handler: (ev: CustomEvent) => void): Promise; + function off(event: string, handler: (ev: CustomEvent) => void): Promise; + function dispatch(event: string, data?: any): Promise; + function broadcast(event: string, data?: any): Promise; +} +namespace extensions { + interface ExtensionStats { + loaded: string[]; + connected: string[]; + } + function dispatch(extensionId: string, event: string, data?: any): Promise; + function broadcast(event: string, data?: any): Promise; + function getStats(): Promise; +} +namespace updater { + interface Manifest { + applicationId: string; + version: string; + resourcesURL: string; + } + function checkForUpdates(url: string): Promise; + function install(): Promise; +} +namespace clipboard { + interface ClipboardImage { + width: number; + height: number; + bpp: number; + bpr: number; + redMask: number; + greenMask: number; + blueMask: number; + redShift: number; + greenShift: number; + blueShift: number; + data: ArrayBuffer; + } + // debug + enum LoggerType { + WARNING = "WARNING", + ERROR = "ERROR", + INFO = "INFO" + } + // os + enum Icon { + WARNING = "WARNING", + ERROR = "ERROR", + INFO = "INFO", + QUESTION = "QUESTION" + } + enum MessageBoxChoice { + OK = "OK", + OK_CANCEL = "OK_CANCEL", + YES_NO = "YES_NO", + YES_NO_CANCEL = "YES_NO_CANCEL", + RETRY_CANCEL = "RETRY_CANCEL", + ABORT_RETRY_IGNORE = "ABORT_RETRY_IGNORE" + } + //clipboard + enum ClipboardFormat { + unknown = "unknown", + text = "text", + image = "image" + } + // NL_GLOBALS + enum Mode { + window = "window", + browser = "browser", + cloud = "cloud", + chrome = "chrome" + } + enum OperatingSystem { + Linux = "Linux", + Windows = "Windows", + Darwin = "Darwin", + FreeBSD = "FreeBSD", + Unknown = "Unknown" + } + enum Architecture { + x64 = "x64", + arm = "arm", + itanium = "itanium", + ia32 = "ia32", + unknown = "unknown" + } + function getFormat(): Promise; + function readText(): Promise; + function readImage(format?: string): Promise; + function writeText(data: string): Promise; + function writeImage(image: ClipboardImage): Promise; + function readHTML(): Promise; + function writeHTML(data: string): Promise; + function clear(): Promise; +} +namespace resources { + interface Stats { + size: number; + isFile: boolean; + isDirectory: boolean; + } + function getFiles(): Promise; + function getStats(path: string): Promise; + function extractFile(path: string, destination: string): Promise; + function extractDirectory(path: string, destination: string): Promise; + function readFile(path: string): Promise; + function readBinaryFile(path: string): Promise; +} +namespace server { + function mount(path: string, target: string): Promise; + function unmount(path: string): Promise; + function getMounts(): Promise; +} +namespace custom { + function getMethods(): Promise; +} +interface InitOptions { + exportCustomMethods?: boolean; +} +function init(options?: InitOptions): void; +type ErrorCode = "NE_FS_DIRCRER" | "NE_FS_RMDIRER" | "NE_FS_FILRDER" | "NE_FS_FILWRER" | "NE_FS_FILRMER" | "NE_FS_NOPATHE" | "NE_FS_COPYFER" | "NE_FS_MOVEFER" | "NE_OS_INVMSGA" | "NE_OS_INVKNPT" | "NE_ST_INVSTKY" | "NE_ST_STKEYWE" | "NE_RT_INVTOKN" | "NE_RT_NATPRME" | "NE_RT_APIPRME" | "NE_RT_NATRTER" | "NE_RT_NATNTIM" | "NE_CL_NSEROFF" | "NE_EX_EXTNOTC" | "NE_UP_CUPDMER" | "NE_UP_CUPDERR" | "NE_UP_UPDNOUF" | "NE_UP_UPDINER"; +interface Error { + code: ErrorCode; + message: string; +} +interface OpenActionOptions { + url: string; +} +interface RestartOptions { + args: string; +} +interface MemoryInfo { + physical: { + total: number; + available: number; + }; + virtual: { + total: number; + available: number; + }; +} +interface KernelInfo { + variant: string; + version: string; +} +interface OSInfo { + name: string; + description: string; + version: string; +} +interface CPUInfo { + vendor: string; + model: string; + frequency: number; + architecture: string; + logicalThreads: number; + physicalCores: number; + physicalUnits: number; +} +interface Display { + id: number; + resolution: Resolution; + dpi: number; + bpp: number; + refreshRate: number; +} +interface Resolution { + width: number; + height: number; +} +interface MousePosition { + x: number; + y: number; +} +interface ClipboardImage { + width: number; + height: number; + bpp: number; + bpr: number; + redMask: number; + greenMask: number; + blueMask: number; + redShift: number; + greenShift: number; + blueShift: number; + data: ArrayBuffer; +} +interface ExtensionStats { + loaded: string[]; + connected: string[]; +} +interface DirectoryEntry { + entry: string; + path: string; + type: string; +} +interface FileReaderOptions { + pos: number; + size: number; +} +interface DirectoryReaderOptions { + recursive: boolean; +} +interface OpenedFile { + id: number; + eof: boolean; + pos: number; + lastRead: number; +} +interface Stats { + size: number; + isFile: boolean; + isDirectory: boolean; + createdAt: number; + modifiedAt: number; +} +interface Watcher { + id: number; + path: string; +} +interface CopyOptions { + recursive: boolean; + overwrite: boolean; + skip: boolean; +} +interface PathParts { + rootName: string; + rootDirectory: string; + rootPath: string; + relativePath: string; + parentPath: string; + filename: string; + stem: string; + extension: string; +} +interface Permissions { + all: boolean; + ownerAll: boolean; + ownerRead: boolean; + ownerWrite: boolean; + ownerExec: boolean; + groupAll: boolean; + groupRead: boolean; + groupWrite: boolean; + groupExec: boolean; + othersAll: boolean; + othersRead: boolean; + othersWrite: boolean; + othersExec: boolean; +} +type PermissionsMode = "ADD" | "REPLACE" | "REMOVE"; +interface ExecCommandOptions { + stdIn?: string; + background?: boolean; + cwd?: string; +} +interface ExecCommandResult { + pid: number; + stdOut: string; + stdErr: string; + exitCode: number; +} +interface SpawnedProcess { + id: number; + pid: number; +} +interface SpawnedProcessOptions { + cwd?: string; + envs?: Record; +} +interface Envs { + [key: string]: string; +} +interface OpenDialogOptions { + multiSelections?: boolean; + filters?: Filter[]; + defaultPath?: string; +} +interface FolderDialogOptions { + defaultPath?: string; +} +interface SaveDialogOptions { + forceOverwrite?: boolean; + filters?: Filter[]; + defaultPath?: string; +} +interface Filter { + name: string; + extensions: string[]; +} +interface TrayOptions { + icon: string; + menuItems: TrayMenuItem[]; +} +interface TrayMenuItem { + id?: string; + text: string; + isDisabled?: boolean; + isChecked?: boolean; +} +type KnownPath = "config" | "data" | "cache" | "documents" | "pictures" | "music" | "video" | "downloads" | "savedGames1" | "savedGames2" | "temp"; +interface Manifest { + applicationId: string; + version: string; + resourcesURL: string; +} +interface WindowOptions extends WindowSizeOptions, WindowPosOptions { + title?: string; + icon?: string; + fullScreen?: boolean; + alwaysOnTop?: boolean; + enableInspector?: boolean; + borderless?: boolean; + maximize?: boolean; + hidden?: boolean; + maximizable?: boolean; + useSavedState?: boolean; + exitProcessOnClose?: boolean; + extendUserAgentWith?: string; + injectGlobals?: boolean; + injectClientLibrary?: boolean; + injectScript?: string; + processArgs?: string; +} +interface WindowSizeOptions { + width?: number; + height?: number; + minWidth?: number; + minHeight?: number; + maxWidth?: number; + maxHeight?: number; + resizable?: boolean; +} +interface WindowPosOptions { + x: number; + y: number; +} +interface WindowMenu extends Array { +} +interface WindowMenuItem { + id?: string; + text: string; + isDisabled?: boolean; + isChecked?: boolean; + menuItems?: WindowMenuItem[]; +} +interface Response { + success: boolean; + message: string; +} +type Builtin = "ready" | "trayMenuItemClicked" | "windowClose" | "serverOffline" | "clientConnect" | "clientDisconnect" | "appClientConnect" | "appClientDisconnect" | "extClientConnect" | "extClientDisconnect" | "extensionReady" | "neuDev_reloadApp"; + +} + +// debug +enum LoggerType { + WARNING = 'WARNING', + ERROR = 'ERROR', + INFO = 'INFO' + } + +// os +enum Icon { + WARNING = 'WARNING', + ERROR = 'ERROR', + INFO = 'INFO', + QUESTION = 'QUESTION' +} + +enum MessageBoxChoice { + OK = 'OK', + OK_CANCEL = 'OK_CANCEL', + YES_NO = 'YES_NO', + YES_NO_CANCEL = 'YES_NO_CANCEL', + RETRY_CANCEL = 'RETRY_CANCEL', + ABORT_RETRY_IGNORE = 'ABORT_RETRY_IGNORE' +} + +//clipboard +enum ClipboardFormat { + unknown = 'unknown', + text = 'text', + image = 'image' +} + +// NL_GLOBALS +enum Mode { + window = 'window', + browser = 'browser', + cloud = 'cloud', + chrome = 'chrome' +} + +enum OperatingSystem { + Linux = 'Linux', + Windows = 'Windows', + Darwin = 'Darwin', + FreeBSD = 'FreeBSD', + Unknown = 'Unknown' +} + +enum Architecture { + x64 = 'x64', + arm = 'arm', + itanium = 'itanium', + ia32 = 'ia32', + unknown = 'unknown' +} + + +interface Response { + success: boolean; + message: string; + } + + type Builtin = + 'ready' | + 'trayMenuItemClicked' | + 'windowClose' | + 'serverOffline' | + 'clientConnect' | + 'clientDisconnect' | + 'appClientConnect' | + 'appClientDisconnect' | + 'extClientConnect' | + 'extClientDisconnect' | + 'extensionReady' | + 'neuDev_reloadApp' + + +// --- globals --- +/** Mode of the application: window, browser, cloud, or chrome */ +declare const NL_MODE: Mode; +/** Application port */ +declare const NL_PORT: number; +/** Command-line arguments */ +declare const NL_ARGS: string[]; +/** Basic authentication token */ +declare const NL_TOKEN: string; +/** Neutralinojs client version */ +declare const NL_CVERSION: string; +/** Application identifier */ +declare const NL_APPID: string; +/** Application version */ +declare const NL_APPVERSION: string; +/** Application path */ +declare const NL_PATH: string; +/** Application data path */ +declare const NL_DATAPATH: string; +/** Returns true if extensions are enabled */ +declare const NL_EXTENABLED: boolean; +/** Returns true if the client library is injected */ +declare const NL_GINJECTED: boolean; +/** Returns true if globals are injected */ +declare const NL_CINJECTED: boolean; +/** Operating system name: Linux, Windows, Darwin, FreeBSD, or Uknown */ +declare const NL_OS: OperatingSystem; +/** CPU architecture: x64, arm, itanium, ia32, or unknown */ +declare const NL_ARCH: Architecture; +/** Neutralinojs server version */ +declare const NL_VERSION: string; +/** Current working directory */ +declare const NL_CWD: string; +/** Identifier of the current process */ +declare const NL_PID: string; +/** Source of application resources: bundle or directory */ +declare const NL_RESMODE: string; +/** Release commit of the client library */ +declare const NL_CCOMMIT: string; +/** An array of custom methods */ +declare const NL_CMETHODS: string[]; + diff --git a/demo/app/resources/styles.css b/demo/app/resources/styles.css new file mode 100644 index 0000000..5b0e4b9 --- /dev/null +++ b/demo/app/resources/styles.css @@ -0,0 +1,20 @@ +body { + background-color: white; +} + +#neutralinoapp { + text-align: center; + -webkit-user-select: none; + user-select: none; + cursor: default; +} + +#neutralinoapp h1{ + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + font-size: 20px; +} + +#neutralinoapp > div { + font-size: 16px; + font-weight: normal; +} diff --git a/go.mod b/go.mod index 24fef97..db4746a 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,30 @@ module apigo.cc/gojs/client -go 1.18 +go 1.24 require ( - apigo.cc/gojs v0.0.3 - github.com/ssgo/tool v0.4.27 + apigo.cc/gojs v0.0.25 + github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 + github.com/chromedp/chromedp v0.14.0 + github.com/ssgo/tool v0.4.29 + github.com/ssgo/u v1.7.21 github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 ) require ( - github.com/dlclark/regexp2 v1.11.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect - github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect - github.com/ssgo/config v1.7.7 // indirect - github.com/ssgo/log v1.7.7 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect + github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect + github.com/ssgo/config v1.7.9 // indirect + github.com/ssgo/log v1.7.9 // indirect github.com/ssgo/standard v1.7.7 // indirect - github.com/ssgo/u v1.7.9 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/tests/client.go b/tests/client.go new file mode 100644 index 0000000..23c4652 --- /dev/null +++ b/tests/client.go @@ -0,0 +1,45 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "apigo.cc/gojs" + "apigo.cc/gojs/client" + "github.com/ssgo/u" +) + +func main() { + gojs.ExportForDev() + testOK := false + client.Bind("setTestOK", func(testIsOK bool) { + testOK = testIsOK + }) + + scriptFile := "client.js" + if len(os.Args) > 1 { + scriptFile = os.Args[1] + } + + exePath, _ := os.Executable() + appDir := "" + if strings.Contains(exePath, "Contents/MacOS") { + appDir = filepath.Join(filepath.Dir(filepath.Dir(exePath)), "Resources") + os.Chdir(appDir) + } + u.WriteFile("/tmp/aaa.txt", appDir) + + r, err := gojs.RunFile(scriptFile) + if err != nil { + fmt.Println(u.Red(err.Error())) + } + gojs.WaitAll() + + if !testOK { + fmt.Println(u.BRed("test failed")) + } else { + fmt.Println(u.BGreen("test OK"), r) + } +} diff --git a/tests/client.html b/tests/client.html index 366c4c3..b3bc6b2 100644 --- a/tests/client.html +++ b/tests/client.html @@ -13,7 +13,8 @@ app.setSize(1200, 400, "fixed") setTimeout(() => { setTestOK(true) - app.close() + document.querySelector('._dialogButton').click() + // app.close() }, 1000) }, 1000) }) diff --git a/tests/client.js b/tests/client.js index f4c8bcb..7762d43 100644 --- a/tests/client.js +++ b/tests/client.js @@ -5,4 +5,5 @@ client.open({ height: 600, title: 'Hello', url: 'client.html', + isDebug: false, }) diff --git a/tests/client_test.go b/tests/client_test.go deleted file mode 100644 index a09b880..0000000 --- a/tests/client_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package client_test - -import ( - "fmt" - "testing" - - "apigo.cc/gojs" - "apigo.cc/gojs/client" -) - -func TestClient(t *testing.T) { - gojs.ExportForDev() - testOK := false - client.Bind("setTestOK", func(testIsOK bool) { - testOK = testIsOK - }) - - r, err := gojs.RunFile("client.js") - if err != nil { - t.Fatal(err) - } - gojs.WaitAll() - - if !testOK { - t.Fatal("test failed") - } else { - fmt.Println("test OK", r) - } -} diff --git a/tests/test_chromedp.sh b/tests/test_chromedp.sh new file mode 100755 index 0000000..353e5dc --- /dev/null +++ b/tests/test_chromedp.sh @@ -0,0 +1,2 @@ +export CGO_ENABLED=0 +go run . diff --git a/tests/test_webview.sh b/tests/test_webview.sh new file mode 100755 index 0000000..ae360a5 --- /dev/null +++ b/tests/test_webview.sh @@ -0,0 +1,2 @@ +export CGO_ENABLED=1 +go run .