// ============================================================================ // webservice.go — 工具页 Web 服务 // // 选择任意目录开启本地静态文件浏览服务;readme.md 自动渲染为 HTML; // 支持端口自动探测与运行状态查询。 // ============================================================================ package main import ( "context" "encoding/json" "errors" "fmt" "net" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/gomarkdown/markdown" ) // WebServiceInfo 描述一次 Web 服务运行实例的状态,供前端展示。 type WebServiceInfo struct { Running bool `json:"running"` // 是否运行中 Dir string `json:"dir"` // 服务目录 URL string `json:"url"` // 访问地址 http://127.0.0.1:port/ IndexName string `json:"indexName"` // 实际用作默认页的文件(index.html / readme.md / 空) StartedAt string `json:"startedAt"` // 启动时间 Error string `json:"error"` // 最近一次错误 Connections int64 `json:"connections"` // 累计请求次数 } // webService 持有静态文件服务的运行状态(全局单例,工具页调用)。 type webService struct { mu sync.Mutex server *http.Server dir string url string index string // 默认页文件名 started time.Time conns int64 } var webSrv = &webService{} // pickWebDir 弹出目录选择对话框,返回用户选中的目录(取消则返回空串)。 // 使用 Wails 全局 app 的 Dialog(绑定到主窗口)。 func pickWebDir() (string, error) { if app == nil { return "", errors.New("应用未初始化") } dialog := app.Dialog.OpenFile(). CanChooseDirectories(true). CanChooseFiles(false). SetTitle("选择要浏览的目录") return dialog.PromptForSingleSelection() } // resolveIndexName 判断目录默认页:优先 index.html,其次 readme.md(或 README.md)。 func resolveIndexName(dir string) string { if fileExists(filepath.Join(dir, "index.html")) { return "index.html" } for _, name := range []string{"readme.md", "README.md", "Readme.md"} { if fileExists(filepath.Join(dir, name)) { return name } } return "" } func fileExists(path string) bool { st, err := os.Stat(path) return err == nil && !st.IsDir() } // webHandler 提供静态文件服务,并对「默认页」做兜底: // 请求根路径时,若无 index.html 但有 readme.md,则渲染 README 为 HTML。 func webHandler(dir, indexName string) http.Handler { // 仅当 README 是默认页且无 index.html 时,才用自定义处理器(渲染 Markdown) if indexName != "" && !strings.EqualFold(indexName, "index.html") { readmePath := filepath.Join(dir, indexName) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" || r.URL.Path == "" { data, err := os.ReadFile(readmePath) if err != nil { http.Error(w, "readme.md 读取失败: "+err.Error(), http.StatusInternalServerError) return } html := markdown.ToHTML(data, nil, nil) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(html) return } // 非根路径:交给默认文件服务器(提供子目录/静态资源) http.FileServer(http.Dir(dir)).ServeHTTP(w, r) }) } // 有 index.html(或两者都无):用标准文件服务器,浏览器自动服务 index.html 或目录列表 return http.FileServer(http.Dir(dir)) } // StartWebService 启动静态文件服务,监听 127.0.0.1:0 随机端口。 // 若已运行则先停止旧实例。indexName 为空时自动探测默认页。 func StartWebService(dir string) (WebServiceInfo, error) { if strings.TrimSpace(dir) == "" { return WebServiceInfo{}, errors.New("目录不能为空") } st, err := os.Stat(dir) if err != nil || !st.IsDir() { return WebServiceInfo{}, fmt.Errorf("目录不存在或不是文件夹: %s", dir) } indexName := resolveIndexName(dir) // 先停止已有实例(幂等) stopWebService() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return WebServiceInfo{}, fmt.Errorf("监听端口失败: %w", err) } port := ln.Addr().(*net.TCPAddr).Port mux := http.NewServeMux() handler := webHandler(dir, indexName) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { webSrv.mu.Lock() webSrv.conns++ webSrv.mu.Unlock() handler.ServeHTTP(w, r) })) srv := &http.Server{Handler: mux} webSrv.mu.Lock() webSrv.server = srv webSrv.dir = dir webSrv.url = fmt.Sprintf("http://127.0.0.1:%d/", port) webSrv.index = indexName webSrv.started = time.Now() webSrv.conns = 0 webSrv.mu.Unlock() go func() { _ = srv.Serve(ln) // 阻塞直到 Shutdown;返回 http.ErrServerClosed 属正常 }() return WebServiceInfo{ Running: true, Dir: dir, URL: webSrv.url, IndexName: indexName, StartedAt: webSrv.started.Format("2006-01-02 15:04:05"), }, nil } // StopWebService 停止当前静态文件服务(未运行则无副作用)。 func StopWebService() { stopWebService() } func stopWebService() { webSrv.mu.Lock() defer webSrv.mu.Unlock() if webSrv.server != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) _ = webSrv.server.Shutdown(ctx) cancel() webSrv.server = nil webSrv.url = "" webSrv.dir = "" webSrv.index = "" } } // WebServiceStatus 返回当前 Web 服务状态。 func WebServiceStatus() WebServiceInfo { webSrv.mu.Lock() defer webSrv.mu.Unlock() info := WebServiceInfo{ Running: webSrv.server != nil, Dir: webSrv.dir, URL: webSrv.url, IndexName: webSrv.index, Connections: webSrv.conns, } if !webSrv.started.IsZero() { info.StartedAt = webSrv.started.Format("2006-01-02 15:04:05") } return info } // WebServiceJSON 为前端提供一次调用获取完整状态的辅助(避免多次 IPC)。 func WebServiceJSON() string { b, _ := json.Marshal(WebServiceStatus()) return string(b) }