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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user