跨平台桌面应用(内嵌 Web 界面 + Go RESTful API 服务)

程序启动后在一个原生 WebView 窗口中加载 static 目录下的前端页面,前端通过 Ajax 请求本程序内置的 RESTful API。所有静态资源在编译时通过 //go:embed 内嵌到可执行文件,发布时只需一个二进制。
 
// SIMLive — 自包含桌面应用:内嵌 Web 界面 + Go RESTful API 服务。
//
// 本程序启动后在一个原生 WebView 窗口中加载 static 目录下的前端页面,
// 前端通过 Ajax 请求本程序内置的 RESTful API。所有静态资源在编译时
// 通过 //go:embed 内嵌到可执行文件,发布时只需一个二进制。
package main

import (
    "SIMLive/frameless"
    "embed"
    "encoding/json"
    "io/fs"
    "log"
    "net"
    "net/http"
    "time"

    "github.com/crgimenes/glaze"
)

//go:embed all:static
var staticFS embed.FS

const addr = "127.0.0.1:8080"
const width = 480
const height = 320
const title = "SIMLive 桌面控制台"

const dev = false      // 开发模式:不带窗口,仅把静态资源 + API 挂到 127.0.0.1:8080, 并开启 CORS,供 HBuilder 等工具单独预览 / 调试前端页面。
const titleBar = false // 带标题栏窗口(最小化,最大化,关闭)

func main() {
    mux := http.NewServeMux()
    mux.Handle("/", http.FileServer(http.FS(mustSub(staticFS, "static")))) // 静态文件托管
    mux.HandleFunc("/api/hello", handleHello)                              // RESTful API

    if dev {
        log.Printf("开发模式:请在 HBuilder 中预览 static 目录,或直接访问 http://%s/index.html", addr)
        if err := http.ListenAndServe(addr, withCORS(mux)); err != nil {
            log.Fatalf("开发服务器启动失败: %v", err)
        }
        return
    }

    if titleBar {
        // 带标题栏窗口(最小化,最大化,关闭)
        if err := glaze.AppWindow(glaze.AppOptions{
            Title:     title,
            Width:     width,
            Height:    height,
            Transport: glaze.AppTransportTCP,
            Addr:      addr,
            Handler:   mux,
        }); err != nil {
            log.Fatalf("窗口运行失败: %v", err)
        }
    } else {
        // 以无边框窗体呈现
        if err := runWindow(mux, addr); err != nil {
            log.Fatalf("窗口运行失败: %v", err)
        }
    }
}

// runWindow 是 AppWindow 的等价实现, 整个标题栏(即去除最小化/最大化/关闭按钮)。
func runWindow(handler http.Handler, addr string) error {
    ln, err := net.Listen("tcp", addr)
    if err != nil {
        return err
    }
    defer ln.Close()

    srv := &http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second}
    defer srv.Close()
    go func() { _ = srv.Serve(ln) }()

    // glaze.New 会将当前 goroutine 绑定到 UI 线程,后续窗口操作都须在它上面进行。
    w, err := glaze.New(false)
    if err != nil {
        return err
    }

    w.SetTitle(title)
    if err := frameless.MakeFrameless(w.Window()); err != nil {
        return err
    }

    w.SetSize(width, height, glaze.HintNone)
    w.Navigate("http://" + ln.Addr().String())

    w.Run()
    w.Destroy()
    return nil
}

// withCORS 仅在开发模式开启,允许 HBuilder 预览页面跨域调用本服务。
func withCORS(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// mustSub 从内嵌 FS 中取子目录,失败即终止。
func mustSub(fsys embed.FS, dir string) fs.FS {
    sub, err := fs.Sub(fsys, dir)
    if err != nil {
        log.Fatalf("无法读取内嵌目录 %q: %v", dir, err)
    }
    return sub
}

// handleHello 返回示例 JSON 数据。新增业务接口时,按相同模式在此注册即可。
func handleHello(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        w.Header().Set("Allow", http.MethodGet)
        http.Error(w, "仅支持 GET", http.StatusMethodNotAllowed)
        return
    }
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    if err := json.NewEncoder(w).Encode(map[string]string{
        "message": "Hello from Go API!",
    }); err != nil {
        log.Printf("写入响应失败: %v", err)
    }
}

程序自启后,加载webview

image

调试模式:Hbuild加载html,访问

image

 

 

posted @ 2026-09-05 11:11  CHHC  阅读(6)  评论(0)    收藏  举报