WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"context"
"strings"
)
// App holds Wails-bound state for the desktop shell.
type App struct {
ctx context.Context
backendURL string
apiLanBaseURL string
listenPublic bool
shutdownCh chan struct{}
}
// NewApp creates a new App application struct.
func NewApp() *App {
return &App{
shutdownCh: make(chan struct{}, 1),
}
}
// startup is called when the application starts.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
func (a *App) shutdown(ctx context.Context) {
a.shutdownCh <- struct{}{}
}
// GetAPIBaseURL returns the local HTTP base URL for REST API calls (e.g. http://127.0.0.1:PORT/api/v1).
// The desktop shell proxies the webview to this address; window.location.origin is not the API host.
func (a *App) GetAPIBaseURL() string {
if a.backendURL == "" {
return ""
}
return strings.TrimRight(a.backendURL, "/") + "/api/v1"
}
// GetDesktopHTTPPortSetting returns the saved local API port (0 = random port each launch).
func (a *App) GetDesktopHTTPPortSetting() int {
return LoadDesktopPrefsHTTPPort()
}
// SetDesktopHTTPPortSetting saves the preferred local API port to application support. Restart the app for it to take effect unless it matches the current listener.
func (a *App) SetDesktopHTTPPortSetting(port int) error {
return SaveDesktopHTTPPortPreference(port)
}
// GetDesktopHTTPBindPublicSetting returns whether API listens on all interfaces (0.0.0.0).
func (a *App) GetDesktopHTTPBindPublicSetting() bool {
return LoadDesktopHTTPBindPublic()
}
// SetDesktopHTTPBindPublicSetting saves LAN/public listen preference. Restart the app for it to take effect.
func (a *App) SetDesktopHTTPBindPublicSetting(v bool) error {
return SaveDesktopHTTPBindPublicPreference(v)
}
// GetAPILanBaseURL returns a suggested base URL for other devices on the LAN (…/api/v1), or empty if not in bind-public mode or IP detection failed.
func (a *App) GetAPILanBaseURL() string {
return a.apiLanBaseURL
}
// GetDesktopListenPublicActive is true when this session’s API server is listening on all interfaces (runtime), not the saved preference.
func (a *App) GetDesktopListenPublicActive() bool {
return a.listenPublic
}
// CheckForUpdates manually triggers the update check from the frontend.
func (a *App) CheckForUpdates() {
if a.ctx != nil {
checkUpdate(a.ctx, desktopAboutVersion(), true, false)
}
}
// AutoCheckForUpdates silently checks for updates and downloads them.
func (a *App) AutoCheckForUpdates() {
if a.ctx != nil {
checkUpdate(a.ctx, desktopAboutVersion(), false, true)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>
+521
View File
@@ -0,0 +1,521 @@
//go:build !bindings
package main
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/menu/keys"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/container"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/runtime"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/joho/godotenv"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// dragHandlerJS is injected into the webview on DomReady.
// It bypasses Wails' built-in CSS-variable-based drag detection (which uses
// getComputedStyle and has timing/inheritance issues with dynamic SPA content)
// and instead uses robust DOM-traversal via el.closest() plus a Y-position
// fallback for the macOS title-bar region on layout containers. The "drag"
// message is sent directly through the WKWebView script-message bridge,
// which the native Objective-C handler in WailsContext.m converts to
// [NSWindow performWindowDragWithEvent:].
const dragHandlerJS = `(function(){
if(window.__wkDragBound)return;
window.__wkDragBound=true;
document.documentElement.classList.add('wails-desktop');
// Disable rubber-band overscroll that reveals the dark window background
document.documentElement.style.overscrollBehavior='none';
document.body.style.overscrollBehavior='none';
if(window.wails&&window.wails.flags){
window.wails.flags.cssDragProperty='__disabled__';
window.wails.flags.cssDragValue='__never__';
}
// Prevent native text/image drag-out to fix the "selected and dragged away" issue
window.addEventListener('dragstart', function(e){
e.preventDefault();
}, true);
var TITLEBAR_H=38;
// We specifically look for Wails' inline style attributes injected by Vue,
// and custom drag classes, avoiding generic headers like .section-header
var dragSel='.logo_row,.menu_top,.drag-region,[data-wails-drag],' +
'[style*="--wails-draggable: drag"],[style*="--wails-draggable:drag"]';
var noDragSel='button,a,input,select,textarea,[role="button"],' +
'.t-button,.t-input,.t-select,.t-textarea,' +
'.header-actions,.header-action-btn,.sidebar-toggle,.logo_box,' +
'.close-btn,.menu_item,.submenu,.submenu_item,.menu_bottom,' +
'.t-popup,.t-dropdown,.t-tooltip,.t-dialog,[data-no-drag],' +
'[style*="--wails-draggable: no-drag"],[style*="--wails-draggable:no-drag"]';
var layoutClasses=['main','chat','dialogue-wrap','kb-list-container','kb-list-content',
'agent-list-container','agent-list-content','org-list-container','org-list-content','aside_box',
'ks-container','ks-content','settings-overlay','knowledge-layout',
'faq-manager-wrapper','login-layout'];
function sendDrag(){
try{window.webkit.messageHandlers.external.postMessage('drag')}
catch(_){try{window.WailsInvoke('drag')}catch(e){}}
}
function isLayoutEl(el){
for(var i=0;i<layoutClasses.length;i++){
if(el.classList.contains(layoutClasses[i]))return true;
}
var tag=el.tagName;
return tag==='BODY'||tag==='HTML';
}
function shouldDrag(el,y){
if(!(el instanceof Element))return false;
if(el.closest(noDragSel))return false;
if(el.closest(dragSel))return true;
if(y<=TITLEBAR_H&&isLayoutEl(el))return true;
return false;
}
window.addEventListener('mousedown',function(e){
var target=e.target;
if(target&&target.nodeType===Node.TEXT_NODE){
target=target.parentElement;
}
if(e.button!==0||e.detail!==1)return;
if(!shouldDrag(target,e.clientY))return;
e.preventDefault();
sendDrag();
},true);
// Intercept external link clicks and window.open so they open in the system browser
document.addEventListener('click',function(e){
var el=e.target;
while(el&&el.tagName!=='A')el=el.parentElement;
if(!el||!el.href)return;
var href=el.href;
if(href.indexOf('http://')===0||href.indexOf('https://')===0){
if(window.runtime&&window.runtime.BrowserOpenURL){
e.preventDefault();
e.stopPropagation();
window.runtime.BrowserOpenURL(href);
}
}
},true);
var origOpen=window.open;
window.open=function(url){
if(url&&(typeof url==='string')&&(url.indexOf('http://')===0||url.indexOf('https://')===0)){
if(window.runtime&&window.runtime.BrowserOpenURL){
window.runtime.BrowserOpenURL(url);
return null;
}
}
return origOpen.apply(window,arguments);
};
})();`
// wailsThemeSyncJS:与 index.html 首屏一致,在 DomReady 再跑一遍,覆盖 Ctrl+R 后 runtime 就绪时机
const wailsThemeSyncJS = `(function(){try{var t=localStorage.getItem('WeKnora_theme')||'light';if(t==='system')t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';var bg=t==='dark'?'#181818':'#eee';document.documentElement.setAttribute('theme-mode',t);document.documentElement.style.background=bg;document.documentElement.style.minHeight='100%';document.documentElement.style.colorScheme=t==='dark'?'dark':'light';if(document.body){document.body.style.background=bg;document.body.style.minHeight='100%';}var w=window.runtime;if(!w)return;if(t==='dark'){if(w.WindowSetDarkTheme)w.WindowSetDarkTheme();if(w.WindowSetBackgroundColour)w.WindowSetBackgroundColour(24,24,24,255);}else{if(w.WindowSetLightTheme)w.WindowSetLightTheme();if(w.WindowSetBackgroundColour)w.WindowSetBackgroundColour(238,238,238,255);}}catch(e){}})()`
const weknoraGitHubRepoURL = "https://github.com/Tencent/WeKnora"
func main() {
// For macOS .app bundle, the working directory is usually "/" or the MacOS folder.
// We need to change the working directory to the Resources folder where our configs are.
execPath, errPath := os.Executable()
if errPath == nil && strings.Contains(execPath, ".app/Contents/MacOS") {
resPath := filepath.Join(filepath.Dir(filepath.Dir(execPath)), "Resources")
_ = os.Chdir(resPath)
} else if _, err := os.Stat(filepath.Join("config", "config.yaml")); os.IsNotExist(err) {
// wails build 生成绑定时 cwd 多为 cmd/desktop,LoadConfig 默认找 ./config/config.yaml;
// 仓库实际配置在 <repo>/config/,向上两级即可。
repoRoot := filepath.Clean(filepath.Join("..", ".."))
if _, err := os.Stat(filepath.Join(repoRoot, "config", "config.yaml")); err == nil {
_ = os.Chdir(repoRoot)
}
}
// Load .env explicitly for the desktop app so DB_DRIVER gets loaded
_ = godotenv.Load()
configureDesktopStorage(execPath)
logger.ConfigureFromEnv()
// Set Gin mode
if os.Getenv("GIN_MODE") == "release" {
gin.SetMode(gin.ReleaseMode)
} else {
gin.SetMode(gin.DebugMode)
}
// Mute Gin's per-route registration spam; replaced by a single
// summary printed after router build.
runtime.SilenceGinRouteSpam()
runtime.LogStartupEnv(context.Background())
// Build dependency injection container
c := container.BuildContainer(runtime.GetContainer())
// Initialize the WeKnora App struct
app := NewApp()
// Error channel to capture server startup errors
serverErrCh := make(chan error, 1)
// Run backend in a separate goroutine
go func() {
err := c.Invoke(func(
cfg *config.Config,
router *gin.Engine,
resourceCleaner interfaces.ResourceCleaner,
) error {
server := &http.Server{Handler: router}
runtime.LogGinRouteCount(context.Background())
// 127.0.0.1 + saved port from settings (desktop-prefs.json), or :0 for random free port.
addr := desktopBackendListenAddr()
listener, err := listenWithRetry(addr, 10, 300*time.Millisecond)
if err != nil {
return fmt.Errorf("failed to start server: %v", err)
}
tcpAddr := listener.Addr().(*net.TCPAddr)
port := tcpAddr.Port
app.listenPublic = LoadDesktopHTTPBindPublic()
// Reverse proxy and webview API calls always use loopback; avoid 0.0.0.0 / [::] as dial target.
app.backendURL = fmt.Sprintf("http://127.0.0.1:%d", port)
app.apiLanBaseURL = ""
if app.listenPublic {
if ip := desktopPreferredLANIPv4(); ip != nil {
app.apiLanBaseURL = fmt.Sprintf("http://%s:%d/api/v1", ip.String(), port)
}
}
// Handle graceful shutdown from Wails OnShutdown hook
go func() {
<-app.shutdownCh
logger.Infof(context.Background(), "Wails shutting down, stopping Go backend...")
listener.Close()
shutdownTimeout := cfg.Server.ShutdownTimeout
if shutdownTimeout == 0 {
shutdownTimeout = 30 * time.Second
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
server.Close()
}
resourceCleaner.Cleanup(shutdownCtx)
}()
// Also listen for OS signals just in case
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-signals
app.shutdownCh <- struct{}{} // trigger shutdown
}()
logger.Infof(context.Background(), "Server is running at %s (proxy -> %s)", tcpAddr.String(), app.backendURL)
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("server error: %v", err)
}
return nil
})
if err != nil {
serverErrCh <- err
logger.Fatalf(context.Background(), "Failed to run backend: %v", err)
}
}()
// Give the server a moment to start and determine its port
time.Sleep(500 * time.Millisecond)
// Create application with options
// macOS app menu
AppMenu := menu.NewMenu()
FileMenu := AppMenu.AddSubmenu("WeKnora Lite")
FileMenu.AddText("About WeKnora", keys.CmdOrCtrl("i"), func(_ *menu.CallbackData) {
if app.ctx == nil {
return
}
choice, err := wailsruntime.MessageDialog(app.ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.InfoDialog,
Title: "WeKnora Lite",
Message: fmt.Sprintf("WeKnora Lite — Desktop Edition\n\nA RAG framework for document understanding and semantic Q&A over complex, heterogeneous content.\n\nVersion %s\n© 2026 Tencent\n\nGitHub:\n%s", desktopAboutVersion(), weknoraGitHubRepoURL),
Buttons: []string{"Open GitHub", "OK"},
DefaultButton: "OK",
})
if err != nil {
logger.Warnf(context.Background(), "About dialog: %v", err)
return
}
if choice == "Open GitHub" {
wailsruntime.BrowserOpenURL(app.ctx, weknoraGitHubRepoURL)
}
})
FileMenu.AddText("Check for Updates...", nil, func(_ *menu.CallbackData) {
if app.ctx == nil {
return
}
checkUpdate(app.ctx, desktopAboutVersion(), true, false)
})
FileMenu.AddSeparator()
FileMenu.AddText("Quit", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) {
app.shutdown(context.Background())
os.Exit(0)
})
AppMenu.Append(menu.EditMenu())
ViewMenu := AppMenu.AddSubmenu("View")
ViewMenu.AddText("Reload", keys.CmdOrCtrl("r"), func(_ *menu.CallbackData) {
if app.ctx != nil {
wailsruntime.EventsEmit(app.ctx, "app:reload")
}
})
// Wait for the backend URL to be set
targetURL, _ := url.Parse(app.backendURL)
proxy := httputil.NewSingleHostReverseProxy(targetURL)
// Start Wails application
// We use a Reverse Proxy to seamlessly proxy Wails' frontend to our Go backend
err := wails.Run(&options.App{
Title: "WeKnora Lite",
Width: 1280,
Height: 800,
DisableResize: false,
Menu: AppMenu,
AssetServer: &assetserver.Options{
Handler: proxy,
},
StartHidden: false, // Show window on startup
OnStartup: app.startup,
OnDomReady: func(ctx context.Context) {
wailsruntime.WindowExecJS(ctx, wailsThemeSyncJS)
wailsruntime.WindowExecJS(ctx, dragHandlerJS)
// 注入真实 API 根路径(与 window.location.origin 不同);无 Go 绑定时仍可显示。
if u := strings.TrimSpace(app.backendURL); u != "" {
apiRoot := strings.TrimRight(u, "/") + "/api/v1"
inject := fmt.Sprintf(`try{window.__WEKNORA_API_BASE__=%s}catch(e){}`, strconv.Quote(apiRoot))
wailsruntime.WindowExecJS(ctx, inject)
}
if lan := strings.TrimSpace(app.apiLanBaseURL); lan != "" {
injectLan := fmt.Sprintf(`try{window.__WEKNORA_API_LAN_BASE__=%s}catch(e){}`, strconv.Quote(lan))
wailsruntime.WindowExecJS(ctx, injectLan)
}
},
OnShutdown: app.shutdown,
Bind: []interface{}{
app,
},
BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 255},
Mac: &mac.Options{
TitleBar: mac.TitleBarHiddenInset(),
WebviewIsTransparent: false,
WindowIsTranslucent: false,
},
})
if err != nil {
println("Error:", err.Error())
}
}
func configureDesktopStorage(execPath string) {
if execPath == "" || !strings.Contains(execPath, ".app/Contents/MacOS") {
return
}
appSupportDir, err := defaultMacAppSupportDir(execPath)
if err != nil {
logger.Warnf(context.Background(), "Failed to resolve app support dir: %v", err)
return
}
legacyResourcesDir := filepath.Join(filepath.Dir(filepath.Dir(execPath)), "Resources")
targetDataDir := filepath.Join(appSupportDir, "data")
migrateLegacyDesktopData(legacyResourcesDir, targetDataDir)
dbPath := resolveDesktopDataPath(os.Getenv("DB_PATH"), filepath.Join("data", "weknora.db"), appSupportDir)
filesPath := resolveDesktopDataPath(os.Getenv("LOCAL_STORAGE_BASE_DIR"), filepath.Join("data", "files"), appSupportDir)
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
logger.Warnf(context.Background(), "Failed to create desktop DB directory %s: %v", filepath.Dir(dbPath), err)
}
if err := os.MkdirAll(filesPath, 0o755); err != nil {
logger.Warnf(context.Background(), "Failed to create desktop files directory %s: %v", filesPath, err)
}
_ = os.Setenv("DB_PATH", dbPath)
_ = os.Setenv("LOCAL_STORAGE_BASE_DIR", filesPath)
}
func defaultMacAppSupportDir(execPath string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
appName := "WeKnora Lite"
if idx := strings.Index(execPath, ".app/Contents/MacOS"); idx >= 0 {
bundleName := filepath.Base(execPath[:idx+4])
if trimmed := strings.TrimSuffix(bundleName, ".app"); trimmed != "" {
appName = trimmed
}
}
return filepath.Join(homeDir, "Library", "Application Support", appName), nil
}
func resolveDesktopDataPath(rawPath, defaultRelativePath, appSupportDir string) string {
trimmed := strings.TrimSpace(rawPath)
if trimmed == "" {
trimmed = defaultRelativePath
}
if filepath.IsAbs(trimmed) {
return filepath.Clean(trimmed)
}
trimmed = strings.TrimPrefix(trimmed, "."+string(filepath.Separator))
return filepath.Join(appSupportDir, filepath.Clean(trimmed))
}
// desktopBackendListenAddr returns the TCP address for the embedded Gin server (Wails desktop).
// Binds 127.0.0.1 by default, or 0.0.0.0 when http_bind_public is set in desktop-prefs.json.
func desktopBackendListenAddr() string {
host := "127.0.0.1"
if LoadDesktopHTTPBindPublic() {
host = "0.0.0.0"
}
pref := LoadDesktopPrefsHTTPPort()
if pref >= 1 && pref <= 65535 {
return net.JoinHostPort(host, strconv.Itoa(pref))
}
return net.JoinHostPort(host, "0")
}
// desktopPreferredLANIPv4 picks a non-loopback IPv4 for “other devices on this network” URL hints.
func desktopPreferredLANIPv4() net.IP {
ifaces, err := net.Interfaces()
if err == nil {
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, aerr := iface.Addrs()
if aerr != nil {
continue
}
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok || ipNet.IP == nil {
continue
}
ip4 := ipNet.IP.To4()
if ip4 == nil || ip4.IsLoopback() {
continue
}
if ip4.IsPrivate() {
return ip4
}
}
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, aerr := iface.Addrs()
if aerr != nil {
continue
}
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok || ipNet.IP == nil {
continue
}
ip4 := ipNet.IP.To4()
if ip4 == nil || ip4.IsLoopback() || ip4.IsUnspecified() {
continue
}
return ip4
}
}
}
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
return nil
}
defer conn.Close()
la, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok || la.IP == nil {
return nil
}
return la.IP.To4()
}
func migrateLegacyDesktopData(resourcesDir, targetDataDir string) {
legacyDataDir := filepath.Join(resourcesDir, "data")
if info, err := os.Stat(legacyDataDir); err != nil || !info.IsDir() {
return
}
if _, err := os.Stat(targetDataDir); err == nil {
return
}
if err := os.MkdirAll(filepath.Dir(targetDataDir), 0o755); err != nil {
logger.Warnf(context.Background(), "Failed to create app support parent dir %s: %v", filepath.Dir(targetDataDir), err)
return
}
if err := os.Rename(legacyDataDir, targetDataDir); err != nil {
logger.Warnf(context.Background(), "Failed to migrate legacy desktop data from %s to %s: %v", legacyDataDir, targetDataDir, err)
return
}
logger.Infof(context.Background(), "Migrated legacy desktop data to %s", targetDataDir)
}
func listenWithRetry(addr string, maxRetries int, baseDelay time.Duration) (net.Listener, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
listener, err := net.Listen("tcp", addr)
if err == nil {
return listener, nil
}
lastErr = err
if i < maxRetries-1 {
delay := baseDelay * time.Duration(1<<uint(i))
if delay > 3*time.Second {
delay = 3 * time.Second
}
time.Sleep(delay)
}
}
return nil, lastErr
}
+17
View File
@@ -0,0 +1,17 @@
//go:build bindings
package main
import (
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
)
// Wails「生成绑定」阶段使用 -tags bindings 单独编译本文件,不启动 Gin/数据库,避免依赖本机 Postgres。
func main() {
app := NewApp()
_ = wails.Run(&options.App{
Title: "WeKnora Lite",
Bind: []interface{}{app},
})
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
const desktopPrefsFileName = "desktop-prefs.json"
type desktopPrefs struct {
HTTPPort int `json:"http_port"`
HTTPBindPublic bool `json:"http_bind_public"`
}
func desktopPrefsDir() (string, error) {
cfg, err := os.UserConfigDir()
if err != nil {
return "", err
}
dir := filepath.Join(cfg, "WeKnora Lite")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func desktopPrefsFilePath() (string, error) {
dir, err := desktopPrefsDir()
if err != nil {
return "", err
}
return filepath.Join(dir, desktopPrefsFileName), nil
}
func loadDesktopPrefs() desktopPrefs {
path, err := desktopPrefsFilePath()
if err != nil {
return desktopPrefs{}
}
data, err := os.ReadFile(path)
if err != nil {
return desktopPrefs{}
}
var p desktopPrefs
if json.Unmarshal(data, &p) != nil {
return desktopPrefs{}
}
if p.HTTPPort < 0 || p.HTTPPort > 65535 {
p.HTTPPort = 0
}
return p
}
func saveDesktopPrefs(p desktopPrefs) error {
path, err := desktopPrefsFilePath()
if err != nil {
return err
}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
// LoadDesktopPrefsHTTPPort returns http_port from prefs file, or 0 if unset / invalid (ephemeral port on each launch).
func LoadDesktopPrefsHTTPPort() int {
return loadDesktopPrefs().HTTPPort
}
// LoadDesktopHTTPBindPublic returns whether the embedded API server should listen on all interfaces (0.0.0.0).
func LoadDesktopHTTPBindPublic() bool {
return loadDesktopPrefs().HTTPBindPublic
}
// SaveDesktopHTTPPortPreference persists listen port preference. port 0 means use a random free port on each launch.
func SaveDesktopHTTPPortPreference(port int) error {
if port < 0 || port > 65535 {
return fmt.Errorf("invalid port")
}
cur := loadDesktopPrefs()
cur.HTTPPort = port
return saveDesktopPrefs(cur)
}
// SaveDesktopHTTPBindPublicPreference persists whether to listen on 0.0.0.0 for LAN/public access.
func SaveDesktopHTTPBindPublicPreference(v bool) error {
cur := loadDesktopPrefs()
cur.HTTPBindPublic = v
return saveDesktopPrefs(cur)
}
+413
View File
@@ -0,0 +1,413 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/handler"
"github.com/Tencent/WeKnora/internal/logger"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/mod/semver"
)
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
type githubRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Assets []githubAsset `json:"assets"`
}
func checkUpdate(ctx context.Context, currentVersion string, showUpToDate bool, autoDownload bool) {
go func() {
if currentVersion == "unknown" || currentVersion == "" {
if showUpToDate {
wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.InfoDialog,
Title: "Check for Updates",
Message: "Unable to determine the current version. Cannot check for updates.",
Buttons: []string{"OK"},
DefaultButton: "OK",
})
}
return
}
if !strings.HasPrefix(currentVersion, "v") {
currentVersion = "v" + currentVersion
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", "https://api.github.com/repos/Tencent/WeKnora/releases/latest", nil)
if err != nil {
logger.Warnf(context.Background(), "Check update failed: %v", err)
return
}
// Add User-Agent header which is required/recommended by GitHub API
req.Header.Set("User-Agent", "WeKnora-Lite-Desktop-App")
// Add Authorization header if GITHUB_TOKEN is present to increase rate limit
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
logger.Warnf(context.Background(), "Check update failed: %v", err)
if showUpToDate {
wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.ErrorDialog,
Title: "Check Update Failed",
Message: fmt.Sprintf("Failed to connect to GitHub to check for updates:\n%v", err),
Buttons: []string{"OK"},
DefaultButton: "OK",
})
}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Warnf(context.Background(), "GitHub API returned status code: %d", resp.StatusCode)
if showUpToDate {
msg := fmt.Sprintf("GitHub API returned an unexpected status code: %d", resp.StatusCode)
if resp.StatusCode == 403 {
msg = "GitHub API rate limit exceeded. Please try again later."
}
wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.ErrorDialog,
Title: "Check Update Failed",
Message: msg,
Buttons: []string{"OK"},
DefaultButton: "OK",
})
}
return
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
logger.Warnf(context.Background(), "Failed to parse release info: %v", err)
return
}
latestVersion := release.TagName
if !strings.HasPrefix(latestVersion, "v") {
latestVersion = "v" + latestVersion
}
if semver.IsValid(latestVersion) && semver.IsValid(currentVersion) {
if semver.Compare(latestVersion, currentVersion) > 0 {
assetURL, assetName := findBestAsset(release.Assets, runtime.GOOS, runtime.GOARCH)
if autoDownload && assetURL != "" {
// Silent download in background
downloadAndInstall(ctx, assetURL, assetName, currentVersion, latestVersion, true)
return
}
msg := fmt.Sprintf("A new version of WeKnora Lite is available!\n\nCurrent version: %s\nLatest version: %s\n\nWould you like to download it now?", currentVersion, latestVersion)
choice, _ := wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.InfoDialog,
Title: "Update Available",
Message: msg,
Buttons: []string{"Download", "Cancel"},
DefaultButton: "Download",
})
if choice == "Download" {
if assetURL != "" {
downloadAndInstall(ctx, assetURL, assetName, currentVersion, latestVersion, false)
} else {
// Fallback to opening the release page if no specific asset is found
wailsruntime.BrowserOpenURL(ctx, release.HTMLURL)
}
}
return
}
}
if showUpToDate {
wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.InfoDialog,
Title: "Up to Date",
Message: fmt.Sprintf("You are using the latest version of WeKnora Lite.\n\nCurrent version: %s", currentVersion),
Buttons: []string{"OK"},
DefaultButton: "OK",
})
}
}()
}
func findBestAsset(assets []githubAsset, goos, goarch string) (string, string) {
osKeyword := ""
switch goos {
case "darwin":
osKeyword = "mac"
case "windows":
osKeyword = "win"
case "linux":
osKeyword = "linux"
}
archKeyword := ""
switch goarch {
case "amd64":
archKeyword = "amd64"
case "arm64":
archKeyword = "arm64"
}
// 1. Try to match both OS and Arch
for _, asset := range assets {
name := strings.ToLower(asset.Name)
if strings.Contains(name, osKeyword) && (strings.Contains(name, archKeyword) || strings.Contains(name, "universal") || strings.Contains(name, "aarch64")) {
return asset.BrowserDownloadURL, asset.Name
}
}
// 2. Try to match OS only (e.g. universal binaries without arch in name)
for _, asset := range assets {
name := strings.ToLower(asset.Name)
if strings.Contains(name, osKeyword) {
return asset.BrowserDownloadURL, asset.Name
}
}
// 3. MacOS specific fallback (e.g. .dmg)
if goos == "darwin" {
for _, asset := range assets {
if strings.HasSuffix(strings.ToLower(asset.Name), ".dmg") {
return asset.BrowserDownloadURL, asset.Name
}
}
}
// 4. Windows specific fallback (e.g. .exe)
if goos == "windows" {
for _, asset := range assets {
if strings.HasSuffix(strings.ToLower(asset.Name), ".exe") {
return asset.BrowserDownloadURL, asset.Name
}
}
}
return "", ""
}
func downloadAndInstall(ctx context.Context, url string, filename string, currentVersion string, latestVersion string, silent bool) {
tempDir := os.TempDir()
savePath := filepath.Join(tempDir, filename)
go func() {
logger.Infof(context.Background(), "Starting download from %s to %s", url, savePath)
resp, err := http.Get(url)
if err != nil {
logger.Warnf(context.Background(), "Download failed: %v", err)
if !silent {
wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.ErrorDialog,
Title: "Download Failed",
Message: fmt.Sprintf("Failed to download the update:\n%v", err),
Buttons: []string{"OK"},
DefaultButton: "OK",
})
}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Warnf(context.Background(), "Download failed, server returned status: %d", resp.StatusCode)
if !silent {
wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.ErrorDialog,
Title: "Download Failed",
Message: fmt.Sprintf("Server returned status: %d", resp.StatusCode),
Buttons: []string{"OK"},
DefaultButton: "OK",
})
}
return
}
out, err := os.Create(savePath)
if err != nil {
logger.Warnf(context.Background(), "Failed to create file for download: %v", err)
if !silent {
wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.ErrorDialog,
Title: "Save Failed",
Message: fmt.Sprintf("Failed to save the update file:\n%v", err),
Buttons: []string{"OK"},
DefaultButton: "OK",
})
}
return
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
logger.Warnf(context.Background(), "Error occurred during download copying: %v", err)
if !silent {
wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.ErrorDialog,
Title: "Download Error",
Message: fmt.Sprintf("An error occurred while downloading:\n%v", err),
Buttons: []string{"OK"},
DefaultButton: "OK",
})
}
return
}
logger.Infof(context.Background(), "Download completed successfully: %s", savePath)
// Prompt user to restart
choice, _ := wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.InfoDialog,
Title: "Update Ready",
Message: fmt.Sprintf("WeKnora Lite %s has been downloaded successfully.\n\nWould you like to restart and install the new version now?", latestVersion),
Buttons: []string{"Restart Now", "Later"},
DefaultButton: "Restart Now",
})
if choice == "Restart Now" {
applyUpdateAndRestart(ctx, savePath)
}
}()
}
// desktopAboutVersion 优先使用构建脚本注入的 handler.Version,否则尝试读取仓库根目录 VERSION(本地 wails dev 等未带 ldflags 时)。
func desktopAboutVersion() string {
if v := strings.TrimSpace(handler.Version); v != "" && v != "unknown" {
return v
}
for _, p := range []string{
"VERSION",
filepath.Join("..", "..", "VERSION"),
filepath.Join("..", "..", "..", "VERSION"),
} {
b, err := os.ReadFile(p)
if err != nil {
continue
}
if v := strings.TrimSpace(string(b)); v != "" {
return v
}
}
return "unknown"
}
// applyUpdateAndRestart applies the downloaded update and restarts the application
func applyUpdateAndRestart(ctx context.Context, savePath string) {
if runtime.GOOS == "windows" {
scriptPath := filepath.Join(os.TempDir(), "weknora_update.bat")
execPath, err := os.Executable()
if err != nil {
logger.Warnf(context.Background(), "Failed to get executable path: %v", err)
return
}
scriptContent := fmt.Sprintf(`@echo off
timeout /t 2 /nobreak
start /wait "" "%s" /S
start "" "%s"
del "%%~f0"
`, savePath, execPath)
os.WriteFile(scriptPath, []byte(scriptContent), 0755)
cmd := exec.Command("cmd.exe", "/C", "start", "/b", scriptPath)
cmd.Start()
wailsruntime.Quit(ctx)
} else if runtime.GOOS == "darwin" {
if strings.HasSuffix(strings.ToLower(savePath), ".dmg") {
go func() {
execPath, err := os.Executable()
if err != nil {
logger.Warnf(context.Background(), "Failed to get executable path: %v", err)
exec.Command("open", savePath).Start()
wailsruntime.Quit(ctx)
return
}
appBundlePath := filepath.Dir(filepath.Dir(filepath.Dir(execPath)))
if !strings.HasSuffix(appBundlePath, ".app") {
exec.Command("open", savePath).Start()
wailsruntime.Quit(ctx)
return
}
mountPoint := filepath.Join(os.TempDir(), "WeKnoraUpdateMount")
os.MkdirAll(mountPoint, 0755)
cmdMount := exec.Command("hdiutil", "attach", savePath, "-mountpoint", mountPoint, "-nobrowse", "-quiet")
if err := cmdMount.Run(); err != nil {
logger.Warnf(context.Background(), "Failed to mount dmg: %v", err)
exec.Command("open", savePath).Start()
wailsruntime.Quit(ctx)
return
}
entries, err := os.ReadDir(mountPoint)
if err != nil {
exec.Command("hdiutil", "detach", mountPoint, "-force").Run()
exec.Command("open", savePath).Start()
wailsruntime.Quit(ctx)
return
}
var newAppPath string
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".app") {
newAppPath = filepath.Join(mountPoint, entry.Name())
break
}
}
if newAppPath == "" {
exec.Command("hdiutil", "detach", mountPoint, "-force").Run()
exec.Command("open", savePath).Start()
wailsruntime.Quit(ctx)
return
}
appDir := filepath.Dir(appBundlePath)
scriptPath := filepath.Join(os.TempDir(), "weknora_update.sh")
scriptContent := fmt.Sprintf(`#!/bin/bash
sleep 2
if ! (rm -rf "%s" && cp -a "%s" "%s"); then
osascript -e "do shell script \"rm -rf \\\"%s\\\" && cp -a \\\"%s\\\" \\\"%s\\\"\" with administrator privileges"
fi
hdiutil detach "%s" -force
open "%s"
rm "$0"
`, appBundlePath, newAppPath, appDir, appBundlePath, newAppPath, appDir, mountPoint, appBundlePath)
os.WriteFile(scriptPath, []byte(scriptContent), 0755)
exec.Command("bash", scriptPath).Start()
wailsruntime.Quit(ctx)
}()
} else {
exec.Command("open", savePath).Start()
wailsruntime.Quit(ctx)
}
} else {
exec.Command("xdg-open", savePath).Start()
wailsruntime.Quit(ctx)
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "WeKnora Lite",
"outputfilename": "WeKnora Lite",
"frontend:dir": "../../frontend",
"wailsjsdir": "../../frontend/src",
"author": {
"name": "WeKnora Team",
"email": ""
},
"info": {
"companyName": "Tencent",
"productName": "WeKnora Lite",
"productVersion": "1.0.0",
"copyright": "Copyright 2026 Tencent",
"comments": "WeKnora Desktop Edition"
},
"mac": {
"category": "public.app-category.productivity",
"titlebar": "hiddenInset"
}
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"context"
"database/sql"
"fmt"
_ "github.com/duckdb/duckdb-go/v2"
)
// duckdbExtensions is the list of DuckDB extensions required by WeKnora's
// data analysis tool. `spatial` is used for layer metadata (st_read_meta)
// so we can enumerate sheet names from Excel files, while `excel` provides
// the dedicated read_xlsx reader with proper type inference.
var duckdbExtensions = []string{"spatial", "excel"}
func downloadExtensions() {
ctx := context.Background()
sqlDB, err := sql.Open("duckdb", ":memory:")
if err != nil {
panic(err)
}
defer sqlDB.Close()
for _, ext := range duckdbExtensions {
if _, err := sqlDB.ExecContext(ctx, fmt.Sprintf("INSTALL %s;", ext)); err != nil {
panic(fmt.Errorf("failed to install %s extension: %w", ext, err))
}
if _, err := sqlDB.ExecContext(ctx, fmt.Sprintf("LOAD %s;", ext)); err != nil {
panic(fmt.Errorf("failed to load %s extension: %w", ext, err))
}
}
}
func main() {
downloadExtensions()
}
+125
View File
@@ -0,0 +1,125 @@
// Bootstrap-time hooks that run after the DI container is built but
// before the HTTP server starts listening. These are deliberately
// best-effort: any failure here only warns and does NOT abort startup.
// The reasoning is that an operator running with a misconfigured env
// var should still be able to bring the server up (and fix the issue
// from the running instance) rather than have a typo brick the deploy.
package main
import (
"context"
"os"
"strings"
"go.uber.org/dig"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// bootstrapEnvVar is the env var that names the email of the user who
// may be promoted to system administrator when the deployment has no
// existing system administrators.
//
// Why an env var (vs a CLI subcommand)?
// - Zero-friction in docker-compose / k8s deploys: set it once in the
// manifest and the very first user account that signs up with that
// email is auto-promoted, with no extra ops step.
// - Idempotent: if the user is already a system admin, bootstrapping is
// a no-op.
// - Safe to leave set: once at least one system admin exists, the env
// var stops granting privileges. That prevents a UI revoke from being
// silently undone on the next restart.
const bootstrapEnvVar = "WEKNORA_BOOTSTRAP_SYSTEM_ADMIN_EMAIL"
// runStartupBootstrap consults the env and applies any one-shot
// bootstrap actions. Currently it only handles system-admin promotion;
// future bootstrap steps (default model seeding, etc.) can be added
// here as additional dig.Invoke calls.
func runStartupBootstrap(c *dig.Container) {
ctx := context.Background()
// Legacy hash repair for migration 000065 placeholder rows. Invoked each
// startup but short-circuits with a cheap EXISTS once every row is
// backfilled (no api_key decryption on the steady-state path).
if err := c.Invoke(func(apiKeySvc interfaces.TenantAPIKeyService) {
if n, err := apiKeySvc.BackfillMissingKeyHashes(ctx); err != nil {
logger.Warnf(ctx, "[bootstrap] tenant api key hash backfill failed: %v", err)
} else if n > 0 {
logger.Infof(ctx, "[bootstrap] backfilled %d legacy tenant api key hash(es)", n)
}
}); err != nil {
logger.Warnf(ctx, "[bootstrap] failed to resolve TenantAPIKeyService: %v", err)
}
email := strings.TrimSpace(os.Getenv(bootstrapEnvVar))
if email == "" {
return
}
// dig.Invoke resolves UserService from the container; if user
// service registration is broken we want to know loudly, but still
// not abort startup — bootstrap is best-effort.
if err := c.Invoke(func(userSvc interfaces.UserService) {
bootstrapSystemAdmin(ctx, userSvc, email)
}); err != nil {
logger.Warnf(ctx, "[bootstrap] failed to resolve UserService: %v", err)
}
}
// bootstrapSystemAdmin promotes the user identified by `email` to system
// administrator only when the deployment currently has no system admins.
// The function is idempotent and non-fatal — it warns and returns on
// every error path.
//
// The bootstrap intentionally does NOT create a user when the email is
// not yet registered: account creation is a workflow with side effects
// (password hashing, tenant assignment, audit) that we don't want to
// short-circuit. Operators should sign up normally first, then set the
// env var on the next restart.
func bootstrapSystemAdmin(ctx context.Context, userSvc interfaces.UserService, email string) {
user, err := userSvc.GetUserByEmail(ctx, email)
if err != nil {
// "not found" surfaces as an error in this codebase; treat it
// gently — operators commonly set the var before the user has
// signed up. The next restart after registration will succeed.
logger.Warnf(ctx,
"[bootstrap] %s=%s: user lookup failed (have they signed up yet?): %v",
bootstrapEnvVar, email, err)
return
}
if user == nil {
logger.Warnf(ctx,
"[bootstrap] %s=%s: no matching user (will retry on next restart)",
bootstrapEnvVar, email)
return
}
if user.IsSystemAdmin {
logger.Infof(ctx,
"[bootstrap] %s=%s: user %s is already a system admin (no-op)",
bootstrapEnvVar, email, user.ID)
return
}
_, total, err := userSvc.ListSystemAdmins(ctx, 0, 1)
if err != nil {
logger.Warnf(ctx,
"[bootstrap] %s=%s: cannot verify existing system admins, skipping promotion: %v",
bootstrapEnvVar, email, err)
return
}
if total > 0 {
logger.Infof(ctx,
"[bootstrap] %s=%s: %d system admin(s) already exist; not promoting user %s",
bootstrapEnvVar, email, total, user.ID)
return
}
user.IsSystemAdmin = true
if err := userSvc.UpdateUser(ctx, user); err != nil {
logger.Warnf(ctx,
"[bootstrap] %s=%s: failed to promote user %s: %v",
bootstrapEnvVar, email, user.ID, err)
return
}
logger.Infof(ctx,
"[bootstrap] promoted user %s (%s) to system admin via %s",
user.ID, email, bootstrapEnvVar)
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"context"
"net"
"time"
"github.com/Tencent/WeKnora/internal/logger"
)
func listenWithRetry(addr string, maxRetries int, baseDelay time.Duration) (net.Listener, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
listener, err := net.Listen("tcp", addr)
if err == nil {
return listener, nil
}
lastErr = err
if i < maxRetries-1 {
delay := baseDelay * time.Duration(1<<uint(i))
if delay > 3*time.Second {
delay = 3 * time.Second
}
logger.Warnf(context.Background(), "Port %s in use, retrying in %v... (%d/%d)", addr, delay, i+1, maxRetries)
time.Sleep(delay)
}
}
return nil, lastErr
}
+143
View File
@@ -0,0 +1,143 @@
// Package main is the main package for the WeKnora server
// It contains the main function and the entry point for the server
//
// @title WeKnora API
// @version 1.0
// @description WeKnora 知识库管理系统 API 文档
// @termsOfService http://swagger.io/terms/
//
// @contact.name WeKnora Github
// @contact.url https://github.com/Tencent/WeKnora
//
// @BasePath /api/v1
//
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description 用户登录认证:输入 Bearer {token} 格式的 JWT 令牌
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name X-API-Key
// @description API Key 认证:空间 Key 固定访问所属空间;平台 Key 调用空间接口时需同时传 X-Tenant-ID
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"time"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/container"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/runtime"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
func main() {
// Set Gin mode
if os.Getenv("GIN_MODE") == "release" {
gin.SetMode(gin.ReleaseMode)
} else {
gin.SetMode(gin.DebugMode)
}
// Mute Gin's per-route registration spam (one line per route × ~150
// routes) — replaced by a single summary printed after router build.
runtime.SilenceGinRouteSpam()
// Print the env banner before container build so operators see what
// config landed even when DB / storage init fails.
runtime.LogStartupEnv(context.Background())
runtime.MarkServerStarted()
// Build dependency injection container
c := container.BuildContainer(runtime.GetContainer())
// One-shot bootstrap hooks (e.g. promote env-named user to system
// admin). Best-effort: never aborts startup — see bootstrap.go.
runStartupBootstrap(c)
// Run application
err := c.Invoke(func(
cfg *config.Config,
router *gin.Engine,
resourceCleaner interfaces.ResourceCleaner,
systemSettingSvc interfaces.SystemSettingService,
) error {
// Create HTTP server
server := &http.Server{
Handler: router,
}
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
listener, err := listenWithRetry(addr, 10, 300*time.Millisecond)
if err != nil {
return fmt.Errorf("failed to start server: %v", err)
}
ctx, done := context.WithCancel(context.Background())
// Start the system_settings pubsub subscriber. Runs in its own
// goroutine and exits when ctx is cancelled at shutdown. Best-
// effort: an error here only warns (Redis may legitimately be
// disabled in lite-mode deployments — the service no-ops in
// that case anyway).
if err := systemSettingSvc.SubscribeRedis(ctx); err != nil {
logger.Warnf(ctx, "[system_settings] subscribe failed: %v", err)
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, shutdownSignals...)
go func() {
sig := <-signals
logger.Infof(context.Background(), "Received signal: %v, starting server shutdown...", sig)
// Close listener first to release port immediately,
// so the next process can bind during our graceful drain.
listener.Close()
shutdownTimeout := cfg.Server.ShutdownTimeout
if shutdownTimeout == 0 {
shutdownTimeout = 30 * time.Second
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer shutdownCancel()
// Second signal → force close all connections immediately
go func() {
sig := <-signals
logger.Warnf(context.Background(), "Received second signal: %v, forcing shutdown...", sig)
server.Close()
}()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Errorf(context.Background(), "Server forced to shutdown: %v", err)
server.Close()
}
logger.Info(context.Background(), "Cleaning up resources...")
errs := resourceCleaner.Cleanup(shutdownCtx)
if len(errs) > 0 {
logger.Errorf(context.Background(), "Errors occurred during resource cleanup: %v", errs)
}
logger.Info(context.Background(), "Server has exited")
done()
}()
runtime.LogGinRouteCount(context.Background())
logger.Infof(context.Background(), "Server is running at %s", addr)
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("server error: %v", err)
}
<-ctx.Done()
return nil
})
if err != nil {
logger.Fatalf(context.Background(), "Failed to run application: %v", err)
}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package main
import (
"os"
"syscall"
)
var shutdownSignals = []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP}
+10
View File
@@ -0,0 +1,10 @@
//go:build windows
package main
import (
"os"
"syscall"
)
var shutdownSignals = []os.Signal{syscall.SIGINT, syscall.SIGTERM}