feat: implement desktop app structure with API base URL handling and bindings for Wails integration

This commit is contained in:
wizardchen
2026-04-11 12:35:25 +08:00
committed by lyingbug
parent 5c4229745d
commit 52bdfd388a
11 changed files with 206 additions and 26 deletions

38
cmd/desktop/app.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"context"
"strings"
)
// App holds Wails-bound state for the desktop shell.
type App struct {
ctx context.Context
backendURL string
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"
}

View File

@@ -1,3 +1,5 @@
//go:build !bindings
package main
import (
@@ -10,6 +12,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
@@ -113,6 +116,13 @@ func main() {
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/desktopLoadConfig 默认找 ./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
@@ -246,6 +256,12 @@ func main() {
OnStartup: app.startup,
OnDomReady: func(ctx context.Context) {
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)
}
},
OnShutdown: app.shutdown,
Bind: []interface{}{
@@ -264,30 +280,6 @@ func main() {
}
}
// App struct
type App struct {
ctx context.Context
backendURL string
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 app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
func (a *App) shutdown(ctx context.Context) {
a.shutdownCh <- struct{}{}
}
func configureDesktopStorage(execPath string) {
if execPath == "" || !strings.Contains(execPath, ".app/Contents/MacOS") {
return

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},
})
}

View File

@@ -1955,6 +1955,10 @@ export default {
description: 'View and manage your API key',
keyLabel: 'API Key',
keyDescription: 'Secret used for API requests. Keep it safe.',
urlLabel: 'API URL',
urlDescription: 'Base path for REST API requests; append the specific endpoint when calling.',
copyUrlTitle: 'Copy API URL',
urlCopySuccess: 'API URL copied to clipboard',
copyTitle: 'Copy API Key',
docLabel: 'API Documentation',
docDescription: 'View complete API documentation and examples,',

View File

@@ -1403,6 +1403,10 @@ export default {
description: "API 키 보기 및 관리",
keyLabel: "API 키",
keyDescription: "API 호출에 사용되는 키, 안전하게 보관하세요",
urlLabel: "API URL",
urlDescription: "REST API 기본 경로입니다. 호출 시 구체적인 엔드포인트 경로를 이어서 사용하세요.",
copyUrlTitle: "API URL 복사",
urlCopySuccess: "API URL이 클립보드에 복사되었습니다",
copyTitle: "API 키 복사",
docLabel: "API 문서",
docDescription: "전체 API 호출 문서 및 예시 보기, ",

View File

@@ -1250,6 +1250,10 @@ export default {
description: 'Просматривайте и управляйте своим API-ключом',
keyLabel: 'API Key',
keyDescription: 'Ключ для API-запросов. Храните его в безопасности.',
urlLabel: 'URL API',
urlDescription: 'Базовый путь REST API; при вызове добавляйте конкретный путь эндпоинта.',
copyUrlTitle: 'Скопировать URL API',
urlCopySuccess: 'URL API скопирован в буфер обмена',
copyTitle: 'Скопировать API Key',
docLabel: 'Документация API',
docDescription: 'Ознакомьтесь с полной документацией и примерами API,',

View File

@@ -1397,6 +1397,10 @@ export default {
description: "查看和管理您的 API 密钥",
keyLabel: "API Key",
keyDescription: "用于 API 调用的密钥,请妥善保管",
urlLabel: "API 地址",
urlDescription: "REST API 的基础路径,请求时在末尾拼接具体接口路径",
copyUrlTitle: "复制 API 地址",
urlCopySuccess: "API 地址已复制到剪贴板",
copyTitle: "复制 API Key",
docLabel: "API 文档",
docDescription: "查看完整的 API 调用文档和示例,",

View File

@@ -55,6 +55,32 @@
</div>
</div>
<!-- API base URL -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('tenant.api.urlLabel') }}</label>
<p class="desc">{{ $t('tenant.api.urlDescription') }}</p>
</div>
<div class="setting-control">
<div class="api-key-control">
<t-input
:model-value="apiBaseUrlDisplay"
readonly
type="text"
style="width: 100%; font-family: monospace; font-size: 12px;"
/>
<t-button
size="small"
variant="text"
@click="copyApiUrl"
:title="$t('tenant.api.copyUrlTitle')"
>
<t-icon name="file-copy" />
</t-button>
</div>
</div>
</div>
<!-- API docs -->
<div class="setting-row">
<div class="setting-info">
@@ -123,6 +149,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getCurrentUser, type TenantInfo, type UserInfo } from '@/api/auth'
import { getApiBaseUrl } from '@/utils/api-base'
import { MessagePlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
@@ -134,6 +161,8 @@ const userInfo = ref<UserInfo | null>(null)
const loading = ref(true)
const error = ref('')
const showApiKey = ref(false)
/** WeKnora Lite (Wails): real API origin is loopback + dynamic port, not window.location.origin */
const wailsApiBaseURL = ref<string | null>(null)
// Computed
const displayApiKey = computed(() => {
@@ -148,6 +177,54 @@ const displayApiKey = computed(() => {
return masked
})
const apiBaseUrlDisplay = computed(() => {
if (wailsApiBaseURL.value) {
return wailsApiBaseURL.value
}
const configured = getApiBaseUrl().trim().replace(/\/$/, '')
let origin = typeof window !== 'undefined' ? window.location.origin : ''
if (!origin || origin === 'null') {
origin = ''
}
const base = configured || origin
return `${base}/api/v1`
})
type WeKnoraDesktopWindow = Window & {
__WEKNORA_API_BASE__?: string
go?: {
main?: {
App?: {
GetAPIBaseURL?: () => Promise<string> | string
}
}
}
}
async function tryLoadWailsApiBaseURL() {
const win = window as WeKnoraDesktopWindow
for (let i = 0; i < 40; i++) {
const injected = win.__WEKNORA_API_BASE__
if (typeof injected === 'string' && injected.trim()) {
wailsApiBaseURL.value = injected.trim().replace(/\/$/, '')
return
}
const fn = win.go?.main?.App?.GetAPIBaseURL
if (typeof fn === 'function') {
try {
const raw = await Promise.resolve(fn())
if (typeof raw === 'string' && raw.trim()) {
wailsApiBaseURL.value = raw.trim().replace(/\/$/, '')
}
} catch {
/* binding error */
}
return
}
await new Promise((r) => setTimeout(r, 50))
}
}
// Methods
const loadInfo = async () => {
try {
@@ -203,6 +280,21 @@ const copyApiKey = async () => {
}
}
const copyApiUrl = async () => {
const text = apiBaseUrlDisplay.value
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
} else {
fallbackCopyText(text)
}
MessagePlugin.success(t('tenant.api.urlCopySuccess'))
} catch {
fallbackCopyText(text)
MessagePlugin.success(t('tenant.api.urlCopySuccess'))
}
}
const formatDate = (dateStr: string | undefined) => {
if (!dateStr) return t('tenant.unknown')
@@ -223,6 +315,7 @@ const formatDate = (dateStr: string | undefined) => {
// Lifecycle
onMounted(() => {
void tryLoadWailsApiBaseURL()
loadInfo()
})
</script>

4
frontend/src/wailsjs/go/main/App.d.ts vendored Executable file
View File

@@ -0,0 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetAPIBaseURL():Promise<string>;

View File

@@ -0,0 +1,7 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetAPIBaseURL() {
return window['go']['main']['App']['GetAPIBaseURL']();
}

View File

@@ -26,11 +26,19 @@ if [ "${SKIP_FRONTEND:-}" != "1" ]; then
if [ -f frontend/package.json ]; then
echo ">> Building frontend..."
(cd frontend && npm ci --prefer-offline && npm run build)
# Lite 后端从 ./web 提供 SPA见 internal/router serveFrontendStatic与 package-lite.sh 一致须用 dist 更新 web
echo ">> Sync frontend/dist -> web/"
rm -rf web
cp -r frontend/dist web
else
echo ">> No frontend/package.json found, skipping frontend build"
fi
fi
if [ ! -f web/index.html ]; then
echo "WARNING: web/index.html not found. Lite 桌面会从 Resources/web 提供前端;请先完整构建前端(勿使用 SKIP_FRONTEND或手动: cp -r frontend/dist web"
fi
# ── Step 2: Build with Wails ──
echo ">> Building Wails Desktop App..."
@@ -48,12 +56,17 @@ export CGO_CFLAGS="-Wno-deprecated-declarations"
export CGO_LDFLAGS="-Wl,-no_warn_duplicate_libraries"
export EDITION=lite
# Milvus 与 Qdrant 的 gRPC 生成代码均注册名为 "common.proto" 的描述符,同一进程内会冲突。
# -ldflags -X conflictPolicy=warn 只作用于最终链接,无法覆盖 Wails「生成绑定」时单独启动的 go 子进程。
# 官方做法:见 https://protobuf.dev/reference/go/faq#namespace-conflict
export GOLANG_PROTOBUF_REGISTRATION_CONFLICT=warn
# 获取版本号并配置 LDFLAGS
eval "$(./scripts/get_version.sh env)"
LDFLAGS="$(./scripts/get_version.sh ldflags) -X 'google.golang.org/protobuf/reflect/protoregistry.conflictPolicy=warn'"
# 我们实际上只用 Wails 构建外壳,前端仍然由后台服务提供
(cd cmd/desktop && wails build -skipbindings -clean -tags "sqlite_fts5" -ldflags="$LDFLAGS" -o "${APP_NAME}")
# 默认生成 Wails 绑定(供 window.go.main.App.* 等);若加 -skipbindings 则需在 OnDomReady 注入 __WEKNORA_API_BASE__
(cd cmd/desktop && wails build -clean -tags "sqlite_fts5" -ldflags="$LDFLAGS" -o "${APP_NAME}")
# ── Step 3: Copy generated .app to dist ──
echo ">> Assembling package..."