Skip to content

Commit 622abaf

Browse files
authored
Merge pull request #564 from Yumiue/html_gui_build
feat: 添加 Electron 自动构建工作流与更新通知功能
2 parents 51f4f6f + e414514 commit 622abaf

22 files changed

Lines changed: 713 additions & 44 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Release Electron
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
env:
12+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
13+
14+
jobs:
15+
build-electron:
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
include:
20+
- os: windows-latest
21+
artifact_name: windows
22+
- os: macos-latest
23+
artifact_name: macos
24+
- os: ubuntu-latest
25+
artifact_name: linux
26+
27+
runs-on: ${{ matrix.os }}
28+
29+
steps:
30+
- name: Checkout
31+
uses: actions/checkout@v4
32+
with:
33+
fetch-depth: 0
34+
35+
- name: Set up Go
36+
uses: actions/setup-go@v5
37+
with:
38+
go-version-file: 'go.mod'
39+
40+
- name: Set up Node.js
41+
uses: actions/setup-node@v4
42+
with:
43+
node-version: '22'
44+
45+
- name: Inject version from tag
46+
shell: bash
47+
run: |
48+
VERSION=${GITHUB_REF#refs/tags/v}
49+
node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync('web/package.json')); p.version='${VERSION}'; fs.writeFileSync('web/package.json', JSON.stringify(p,null,2)+'\n')"
50+
echo "version=${VERSION}" >> "$GITHUB_ENV"
51+
52+
- name: Install dependencies
53+
run: |
54+
cd web
55+
npm install
56+
57+
- name: Build Electron
58+
run: |
59+
cd web
60+
npm run build:electron -- --publish never
61+
env:
62+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63+
64+
- name: Upload artifacts
65+
uses: softprops/action-gh-release@v2
66+
with:
67+
files: |
68+
web/release/*
69+
name: NeoCode Desktop ${{ env.version }}
70+
env:
71+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

internal/cli/gateway_commands.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"io"
8+
"io/fs"
89
"log"
910
"os"
1011
"os/signal"
@@ -18,6 +19,7 @@ import (
1819
"neo-code/internal/config"
1920
"neo-code/internal/gateway"
2021
gatewayauth "neo-code/internal/gateway/auth"
22+
"neo-code/internal/webassets"
2123
)
2224

2325
const (
@@ -181,16 +183,21 @@ func mustReadInheritedWorkdir(cmd *cobra.Command) string {
181183
}
182184

183185
// defaultGatewayCommandRunner 使用网关服务骨架启动本地 IPC 监听并处理中断退出。
186+
// 如果编译时嵌入了前端资源,自动启用静态文件服务。
184187
func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOptions) error {
185-
return startGatewayServer(ctx, options, "", nil)
188+
var staticFileFS fs.FS
189+
if webassets.IsAvailable() {
190+
staticFileFS = webassets.FS
191+
}
192+
return startGatewayServer(ctx, options, "", staticFileFS, nil)
186193
}
187194

188-
// startGatewayServer 启动网关服务的共享实现,staticFileDir 非空时同时提供 SPA 静态文件服务。
195+
// startGatewayServer 启动网关服务的共享实现,staticFileDir 非空或 staticFileFS 非 nil 时同时提供 SPA 静态文件服务。
189196
// onNetworkReady 在网络服务器开始监听后回调,传出实际监听地址。
190-
func startGatewayServer(ctx context.Context, options gatewayCommandOptions, staticFileDir string, onNetworkReady func(address string)) error {
197+
func startGatewayServer(ctx context.Context, options gatewayCommandOptions, staticFileDir string, staticFileFS fs.FS, onNetworkReady func(address string)) error {
191198
logger := log.New(os.Stderr, "neocode-gateway: ", log.LstdFlags)
192199
logPrefix := "starting gateway"
193-
if staticFileDir != "" {
200+
if staticFileDir != "" || staticFileFS != nil {
194201
logPrefix = "starting gateway with web UI"
195202
}
196203
logger.Printf("%s (log-level=%s)", logPrefix, options.LogLevel)
@@ -286,6 +293,7 @@ func startGatewayServer(ctx context.Context, options gatewayCommandOptions, stat
286293
Metrics: metrics,
287294
AllowedOrigins: gatewayConfig.Security.AllowOrigins,
288295
StaticFileDir: staticFileDir,
296+
StaticFileFS: staticFileFS,
289297
ConnectionCountChanged: func(active int) {
290298
idleCloser.observe(active)
291299
},

internal/cli/web_command.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"io/fs"
78
"log"
89
"net"
910
"net/http"
@@ -16,6 +17,8 @@ import (
1617
"time"
1718

1819
"github.com/spf13/cobra"
20+
21+
"neo-code/internal/webassets"
1922
)
2023

2124
type webCommandOptions struct {
@@ -98,15 +101,26 @@ func runWebCommand(ctx context.Context, options webCommandOptions) error {
98101
return fmt.Errorf("frontend dist not found after build: %w", err)
99102
}
100103
}
101-
logger.Printf("serving web UI from %s", staticDir)
104+
// 2. 确定静态文件来源:外部目录优先,找不到时回退到嵌入资源
105+
var staticFileFS fs.FS
106+
if staticDir == "" {
107+
if webassets.IsAvailable() {
108+
staticFileFS = webassets.FS
109+
logger.Println("serving web UI from embedded assets")
110+
} else {
111+
logger.Println("warning: no web UI assets found (external dist missing and embedded assets not compiled)")
112+
}
113+
} else {
114+
logger.Printf("serving web UI from %s", staticDir)
115+
}
102116

103-
// 2. 启动 Gateway(复用共享启动逻辑,Web 模式跳过 IPC)
117+
// 3. 启动 Gateway(复用共享启动逻辑,Web 模式跳过 IPC)
104118
gatewayOpts := gatewayCommandOptions{
105-
HTTPAddress: resolveWebListenAddress(options.HTTPAddress),
106-
LogLevel: options.LogLevel,
107-
Workdir: options.Workdir,
108-
TokenFile: options.TokenFile,
109-
SkipIPC: true,
119+
HTTPAddress: resolveWebListenAddress(options.HTTPAddress),
120+
LogLevel: options.LogLevel,
121+
Workdir: options.Workdir,
122+
TokenFile: options.TokenFile,
123+
SkipIPC: true,
110124
}
111125

112126
// 网络服务器就绪后打开浏览器
@@ -117,7 +131,7 @@ func runWebCommand(ctx context.Context, options webCommandOptions) error {
117131
}
118132
}
119133

120-
return startGatewayServer(ctx, gatewayOpts, staticDir, onNetworkReady)
134+
return startGatewayServer(ctx, gatewayOpts, staticDir, staticFileFS, onNetworkReady)
121135
}
122136

123137
// resolveWebStaticDir 按 --static-dir → <cwd>/web/dist → <exe_dir>/web/dist 顺序查找前端静态文件。

internal/gateway/network_server.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"fmt"
99
"io"
10+
"io/fs"
1011
"log"
1112
"net"
1213
"net/http"
@@ -68,7 +69,9 @@ type NetworkServerOptions struct {
6869
ConnectionCountChanged func(active int)
6970
// StaticFileDir 可选:如果非空,从该目录提供 SPA 静态文件服务。
7071
StaticFileDir string
71-
listenFn func(network, address string) (net.Listener, error)
72+
// StaticFileFS 可选:如果非 nil,从该 fs.FS 提供 SPA 静态文件服务(优先于 StaticFileDir)。
73+
StaticFileFS fs.FS
74+
listenFn func(network, address string) (net.Listener, error)
7275
}
7376

7477
// NetworkServer 提供 HTTP/WebSocket/SSE 网络访问面的统一入口服务。
@@ -89,8 +92,9 @@ type NetworkServer struct {
8992
metrics *GatewayMetrics
9093
allowedOrigins []string
9194
connectionCountChanged func(active int)
92-
staticFileDir string
93-
startedAt time.Time
95+
staticFileDir string
96+
staticFileFS fs.FS
97+
startedAt time.Time
9498

9599
mu sync.Mutex
96100
server *http.Server
@@ -188,10 +192,11 @@ func NewNetworkServer(options NetworkServerOptions) (*NetworkServer, error) {
188192
metrics: metrics,
189193
allowedOrigins: allowedOrigins,
190194
connectionCountChanged: options.ConnectionCountChanged,
191-
staticFileDir: options.StaticFileDir,
195+
staticFileDir: options.StaticFileDir,
196+
staticFileFS: options.StaticFileFS,
192197
startedAt: time.Now().UTC(),
193-
wsConns: make(map[*websocket.Conn]context.CancelFunc),
194-
sseCancels: make(map[int]context.CancelFunc),
198+
wsConns: make(map[*websocket.Conn]context.CancelFunc),
199+
sseCancels: make(map[int]context.CancelFunc),
195200
}, nil
196201
}
197202

@@ -365,6 +370,9 @@ func (s *NetworkServer) buildHandler(runtimePort RuntimePort) http.Handler {
365370
mux.HandleFunc("/sse", func(writer http.ResponseWriter, request *http.Request) {
366371
s.handleSSERequest(writer, request, runtimePort)
367372
})
373+
if s.staticFileFS != nil {
374+
return WithFSStaticFileHandler(mux, s.staticFileFS, s.logger)
375+
}
368376
if s.staticFileDir != "" {
369377
return WithStaticFileHandler(mux, s.staticFileDir, s.logger)
370378
}

internal/gateway/static_files.go

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package gateway
22

33
import (
4+
"io"
5+
"io/fs"
46
"log"
57
"net/http"
68
"os"
79
"path"
8-
"path/filepath"
910
"strings"
11+
"time"
1012
)
1113

1214
// knownAPIPrefixes 定义属于 Gateway API 的路径前缀,静态文件中间件不会拦截这些路径。
@@ -23,6 +25,18 @@ var knownAPIPrefixes = map[string]bool{
2325
// WithStaticFileHandler 返回一个 http.Handler,将 API 请求转发给 apiHandler,
2426
// 其余请求从 staticDir 提供静态文件。对于 SPA 路由,不存在的路径会回退到 index.html。
2527
func WithStaticFileHandler(apiHandler http.Handler, staticDir string, logger *log.Logger) http.Handler {
28+
if staticDir == "" {
29+
return apiHandler
30+
}
31+
return WithFSStaticFileHandler(apiHandler, os.DirFS(staticDir), logger)
32+
}
33+
34+
// WithFSStaticFileHandler 返回一个 http.Handler,将 API 请求转发给 apiHandler,
35+
// 其余请求从 fsys 提供静态文件。对于 SPA 路由,不存在的路径会回退到 index.html。
36+
func WithFSStaticFileHandler(apiHandler http.Handler, fsys fs.FS, logger *log.Logger) http.Handler {
37+
if fsys == nil {
38+
return apiHandler
39+
}
2640
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
2741
cleanPath := path.Clean("/" + request.URL.Path)
2842

@@ -38,30 +52,49 @@ func WithStaticFileHandler(apiHandler http.Handler, staticDir string, logger *lo
3852
relPath = "index.html"
3953
}
4054

41-
// 检查文件是否存在
42-
fullPath := filepath.Join(staticDir, filepath.FromSlash(relPath))
43-
info, err := os.Stat(fullPath)
44-
if err == nil && !info.IsDir() {
45-
setCacheHeaders(writer, relPath)
46-
http.ServeFile(writer, request, fullPath)
47-
return
55+
// 尝试从 fs.FS 中打开文件
56+
file, err := fsys.Open(relPath)
57+
if err == nil {
58+
stat, statErr := file.Stat()
59+
if statErr == nil && !stat.IsDir() {
60+
setCacheHeaders(writer, relPath)
61+
serveFileContent(writer, request, relPath, stat.ModTime(), file)
62+
_ = file.Close()
63+
return
64+
}
65+
_ = file.Close()
4866
}
4967

5068
// SPA fallback:文件不存在时返回 index.html
51-
indexPath := filepath.Join(staticDir, "index.html")
52-
if _, statErr := os.Stat(indexPath); statErr == nil {
53-
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
54-
http.ServeFile(writer, request, indexPath)
55-
return
69+
indexFile, err := fsys.Open("index.html")
70+
if err == nil {
71+
stat, statErr := indexFile.Stat()
72+
if statErr == nil {
73+
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
74+
serveFileContent(writer, request, "index.html", stat.ModTime(), indexFile)
75+
_ = indexFile.Close()
76+
return
77+
}
78+
_ = indexFile.Close()
5679
}
5780

5881
if logger != nil {
59-
logger.Printf("static files: index.html not found in %s", staticDir)
82+
logger.Printf("static files: index.html not found")
6083
}
6184
http.NotFound(writer, request)
6285
})
6386
}
6487

88+
// serveFileContent 将 fs.File 内容写入 HTTP 响应。如果文件支持 io.ReadSeeker,
89+
// 使用 http.ServeContent 以支持 Range 请求和 If-Modified-Since;否则回退到 io.Copy。
90+
func serveFileContent(writer http.ResponseWriter, request *http.Request, name string, modTime time.Time, file fs.File) {
91+
if rs, ok := file.(io.ReadSeeker); ok {
92+
http.ServeContent(writer, request, name, modTime, rs)
93+
return
94+
}
95+
_, _ = io.Copy(writer, file)
96+
}
97+
6598
// isAPIPath 判断请求路径是否属于 Gateway API。
6699
func isAPIPath(cleanPath string) bool {
67100
if knownAPIPrefixes[cleanPath] {

internal/webassets/assets_embed.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build webembed
2+
3+
package webassets
4+
5+
import (
6+
"io/fs"
7+
"neo-code/web"
8+
)
9+
10+
// FS provides access to the embedded web frontend assets.
11+
// Available only when built with -tags webembed.
12+
// The "dist/" prefix is stripped so paths match what the static file handler expects (e.g. "index.html").
13+
var FS fs.FS
14+
15+
func init() {
16+
if sub, err := fs.Sub(web.DistFS, "dist"); err == nil {
17+
FS = sub
18+
}
19+
}
20+
21+
// IsAvailable reports whether embedded web assets are compiled into the binary.
22+
func IsAvailable() bool {
23+
return FS != nil
24+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//go:build !webembed
2+
3+
package webassets
4+
5+
import "io/fs"
6+
7+
// FS is nil when the webembed build tag is not set.
8+
var FS fs.FS = nil
9+
10+
// IsAvailable reports whether embedded web assets are compiled into the binary.
11+
func IsAvailable() bool {
12+
return false
13+
}

web/dist_embed.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
//go:build webembed
2+
3+
package web
4+
5+
import "embed"
6+
7+
//go:embed all:dist
8+
var DistFS embed.FS

web/dist_noembed.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//go:build !webembed
2+
3+
package web
4+
5+
import "embed"
6+
7+
// DistFS is empty when the webembed build tag is not set.
8+
// Use webassets.IsAvailable() to check before serving.
9+
var DistFS embed.FS

0 commit comments

Comments
 (0)