webservice.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. // ============================================================================
  2. // webservice.go — 工具页 Web 服务
  3. //
  4. // 选择任意目录开启本地静态文件浏览服务;readme.md 自动渲染为 HTML;
  5. // 支持端口自动探测与运行状态查询。
  6. // ============================================================================
  7. package main
  8. import (
  9. "context"
  10. "encoding/json"
  11. "errors"
  12. "fmt"
  13. "net"
  14. "net/http"
  15. "os"
  16. "path/filepath"
  17. "strings"
  18. "sync"
  19. "time"
  20. "github.com/gomarkdown/markdown"
  21. )
  22. // WebServiceInfo 描述一次 Web 服务运行实例的状态,供前端展示。
  23. type WebServiceInfo struct {
  24. Running bool `json:"running"` // 是否运行中
  25. Dir string `json:"dir"` // 服务目录
  26. URL string `json:"url"` // 访问地址 http://127.0.0.1:port/
  27. IndexName string `json:"indexName"` // 实际用作默认页的文件(index.html / readme.md / 空)
  28. StartedAt string `json:"startedAt"` // 启动时间
  29. Error string `json:"error"` // 最近一次错误
  30. Connections int64 `json:"connections"` // 累计请求次数
  31. }
  32. // webService 持有静态文件服务的运行状态(全局单例,工具页调用)。
  33. type webService struct {
  34. mu sync.Mutex
  35. server *http.Server
  36. dir string
  37. url string
  38. index string // 默认页文件名
  39. started time.Time
  40. conns int64
  41. }
  42. var webSrv = &webService{}
  43. // pickWebDir 弹出目录选择对话框,返回用户选中的目录(取消则返回空串)。
  44. // 使用 Wails 全局 app 的 Dialog(绑定到主窗口)。
  45. func pickWebDir() (string, error) {
  46. if app == nil {
  47. return "", errors.New("应用未初始化")
  48. }
  49. dialog := app.Dialog.OpenFile().
  50. CanChooseDirectories(true).
  51. CanChooseFiles(false).
  52. SetTitle("选择要浏览的目录")
  53. return dialog.PromptForSingleSelection()
  54. }
  55. // resolveIndexName 判断目录默认页:优先 index.html,其次 readme.md(或 README.md)。
  56. func resolveIndexName(dir string) string {
  57. if fileExists(filepath.Join(dir, "index.html")) {
  58. return "index.html"
  59. }
  60. for _, name := range []string{"readme.md", "README.md", "Readme.md"} {
  61. if fileExists(filepath.Join(dir, name)) {
  62. return name
  63. }
  64. }
  65. return ""
  66. }
  67. func fileExists(path string) bool {
  68. st, err := os.Stat(path)
  69. return err == nil && !st.IsDir()
  70. }
  71. // webHandler 提供静态文件服务,并对「默认页」做兜底:
  72. // 请求根路径时,若无 index.html 但有 readme.md,则渲染 README 为 HTML。
  73. func webHandler(dir, indexName string) http.Handler {
  74. // 仅当 README 是默认页且无 index.html 时,才用自定义处理器(渲染 Markdown)
  75. if indexName != "" && !strings.EqualFold(indexName, "index.html") {
  76. readmePath := filepath.Join(dir, indexName)
  77. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  78. if r.URL.Path == "/" || r.URL.Path == "" {
  79. data, err := os.ReadFile(readmePath)
  80. if err != nil {
  81. http.Error(w, "readme.md 读取失败: "+err.Error(), http.StatusInternalServerError)
  82. return
  83. }
  84. html := markdown.ToHTML(data, nil, nil)
  85. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  86. w.Write(html)
  87. return
  88. }
  89. // 非根路径:交给默认文件服务器(提供子目录/静态资源)
  90. http.FileServer(http.Dir(dir)).ServeHTTP(w, r)
  91. })
  92. }
  93. // 有 index.html(或两者都无):用标准文件服务器,浏览器自动服务 index.html 或目录列表
  94. return http.FileServer(http.Dir(dir))
  95. }
  96. // StartWebService 启动静态文件服务,监听 127.0.0.1:0 随机端口。
  97. // 若已运行则先停止旧实例。indexName 为空时自动探测默认页。
  98. func StartWebService(dir string) (WebServiceInfo, error) {
  99. if strings.TrimSpace(dir) == "" {
  100. return WebServiceInfo{}, errors.New("目录不能为空")
  101. }
  102. st, err := os.Stat(dir)
  103. if err != nil || !st.IsDir() {
  104. return WebServiceInfo{}, fmt.Errorf("目录不存在或不是文件夹: %s", dir)
  105. }
  106. indexName := resolveIndexName(dir)
  107. // 先停止已有实例(幂等)
  108. stopWebService()
  109. ln, err := net.Listen("tcp", "127.0.0.1:0")
  110. if err != nil {
  111. return WebServiceInfo{}, fmt.Errorf("监听端口失败: %w", err)
  112. }
  113. port := ln.Addr().(*net.TCPAddr).Port
  114. mux := http.NewServeMux()
  115. handler := webHandler(dir, indexName)
  116. mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  117. webSrv.mu.Lock()
  118. webSrv.conns++
  119. webSrv.mu.Unlock()
  120. handler.ServeHTTP(w, r)
  121. }))
  122. srv := &http.Server{Handler: mux}
  123. webSrv.mu.Lock()
  124. webSrv.server = srv
  125. webSrv.dir = dir
  126. webSrv.url = fmt.Sprintf("http://127.0.0.1:%d/", port)
  127. webSrv.index = indexName
  128. webSrv.started = time.Now()
  129. webSrv.conns = 0
  130. webSrv.mu.Unlock()
  131. go func() {
  132. _ = srv.Serve(ln) // 阻塞直到 Shutdown;返回 http.ErrServerClosed 属正常
  133. }()
  134. return WebServiceInfo{
  135. Running: true, Dir: dir, URL: webSrv.url, IndexName: indexName,
  136. StartedAt: webSrv.started.Format("2006-01-02 15:04:05"),
  137. }, nil
  138. }
  139. // StopWebService 停止当前静态文件服务(未运行则无副作用)。
  140. func StopWebService() {
  141. stopWebService()
  142. }
  143. func stopWebService() {
  144. webSrv.mu.Lock()
  145. defer webSrv.mu.Unlock()
  146. if webSrv.server != nil {
  147. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  148. _ = webSrv.server.Shutdown(ctx)
  149. cancel()
  150. webSrv.server = nil
  151. webSrv.url = ""
  152. webSrv.dir = ""
  153. webSrv.index = ""
  154. }
  155. }
  156. // WebServiceStatus 返回当前 Web 服务状态。
  157. func WebServiceStatus() WebServiceInfo {
  158. webSrv.mu.Lock()
  159. defer webSrv.mu.Unlock()
  160. info := WebServiceInfo{
  161. Running: webSrv.server != nil, Dir: webSrv.dir, URL: webSrv.url,
  162. IndexName: webSrv.index, Connections: webSrv.conns,
  163. }
  164. if !webSrv.started.IsZero() {
  165. info.StartedAt = webSrv.started.Format("2006-01-02 15:04:05")
  166. }
  167. return info
  168. }
  169. // WebServiceJSON 为前端提供一次调用获取完整状态的辅助(避免多次 IPC)。
  170. func WebServiceJSON() string {
  171. b, _ := json.Marshal(WebServiceStatus())
  172. return string(b)
  173. }