mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-06-04 13:30:32 +08:00
feat: implement desktop app structure with API base URL handling and bindings for Wails integration
This commit is contained in:
38
cmd/desktop/app.go
Normal file
38
cmd/desktop/app.go
Normal 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"
|
||||
}
|
||||
@@ -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/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
|
||||
@@ -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
|
||||
|
||||
17
cmd/desktop/main_bindings.go
Normal file
17
cmd/desktop/main_bindings.go
Normal 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},
|
||||
})
|
||||
}
|
||||
@@ -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,',
|
||||
|
||||
@@ -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 호출 문서 및 예시 보기, ",
|
||||
|
||||
@@ -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,',
|
||||
|
||||
@@ -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 调用文档和示例,",
|
||||
|
||||
@@ -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
4
frontend/src/wailsjs/go/main/App.d.ts
vendored
Executable 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>;
|
||||
7
frontend/src/wailsjs/go/main/App.js
Executable file
7
frontend/src/wailsjs/go/main/App.js
Executable 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']();
|
||||
}
|
||||
@@ -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..."
|
||||
|
||||
Reference in New Issue
Block a user