chore: migrate and structure cmbone project
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package services
|
||||
|
||||
type AppConfigService struct {
|
||||
store *SuiStore
|
||||
}
|
||||
|
||||
// 创建新的AppConfigService实例
|
||||
func NewAppConfigService(store *SuiStore) *AppConfigService {
|
||||
return &AppConfigService{
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// 根据key获取配置项
|
||||
func (r *AppConfigService) GetAppConfig(key string) (string, error) {
|
||||
row := r.store.DB.QueryRow(`
|
||||
SELECT value
|
||||
FROM appconfig
|
||||
WHERE key = ?
|
||||
`, key)
|
||||
|
||||
var value string
|
||||
err := row.Scan(&value)
|
||||
if err != nil {
|
||||
return "en", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// 设置或更新配置项
|
||||
func (r *AppConfigService) SetAppConfig(key, value string) error {
|
||||
_, err := r.store.DB.Exec(`
|
||||
INSERT INTO appconfig (key, type, value, description)
|
||||
VALUES (?, 'user', ?, '')
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
||||
`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取应用语言
|
||||
func (s *AppConfigService) GetLanguage() string {
|
||||
lang, err := s.GetAppConfig("language")
|
||||
if err != nil || lang == "" {
|
||||
return "en"
|
||||
}
|
||||
return lang
|
||||
}
|
||||
|
||||
// 设置应用语言
|
||||
func (s *AppConfigService) SetLanguage(lang string) error {
|
||||
return s.SetAppConfig("language", lang)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cmbone/internal/models"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
||||
type AppService struct {
|
||||
App *application.App
|
||||
SecondWindow *application.WebviewWindow
|
||||
MainWindow *application.WebviewWindow
|
||||
|
||||
Config *AppConfigService
|
||||
|
||||
Tray *application.SystemTray
|
||||
I18nFS embed.FS
|
||||
Menu *application.Menu
|
||||
MenuQuit *application.MenuItem
|
||||
MenuSetting *application.MenuItem
|
||||
MenuSecondBar *application.MenuItem
|
||||
MenuVersion *application.MenuItem
|
||||
}
|
||||
|
||||
const HotkeyShowSecondWindow = 1
|
||||
|
||||
func ConfigureAppService(a *AppService, app *application.App, i18nFS embed.FS, suidb *SuiStore) {
|
||||
a.App = app
|
||||
a.I18nFS = i18nFS
|
||||
a.Config = NewAppConfigService(suidb)
|
||||
|
||||
isWindows := runtime.GOOS == "windows"
|
||||
mainWindow := app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
|
||||
Title: "main",
|
||||
Frameless: isWindows,
|
||||
Mac: application.MacWindow{
|
||||
InvisibleTitleBarHeight: 50,
|
||||
Backdrop: application.MacBackdropTranslucent,
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
},
|
||||
Windows: application.WindowsWindow{
|
||||
DisableIcon: true,
|
||||
},
|
||||
URL: "/",
|
||||
})
|
||||
|
||||
secondWindow := app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
|
||||
Title: "second",
|
||||
Name: "second",
|
||||
Width: 340,
|
||||
Height: 320,
|
||||
Mac: application.MacWindow{
|
||||
InvisibleTitleBarHeight: 50,
|
||||
TitleBar: application.MacTitleBarHidden,
|
||||
Backdrop: application.MacBackdropTransparent,
|
||||
},
|
||||
BackgroundColour: application.NewRGB(27, 38, 54),
|
||||
URL: "/#/second",
|
||||
})
|
||||
secondWindow.Hide()
|
||||
a.SecondWindow = secondWindow
|
||||
a.MainWindow = mainWindow
|
||||
|
||||
RegisterWindowAndCallback(HotkeyShowSecondWindow, a.SecondWindow, func() {
|
||||
if a.SecondWindow == nil {
|
||||
return
|
||||
}
|
||||
a.SecondWindow.Show()
|
||||
a.SecondWindow.Focus()
|
||||
})
|
||||
|
||||
a.MainWindow.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
a.MainWindow.Hide()
|
||||
e.Cancel()
|
||||
})
|
||||
|
||||
a.SecondWindow.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
a.SecondWindow.Hide()
|
||||
e.Cancel()
|
||||
})
|
||||
|
||||
initTray(a, NewAppConfigService(suidb).GetLanguage())
|
||||
}
|
||||
|
||||
func (a *AppService) OpenSecondWindow() {
|
||||
if a.SecondWindow != nil {
|
||||
a.SecondWindow.Show()
|
||||
}
|
||||
}
|
||||
|
||||
func initTray(a *AppService, langValue string) {
|
||||
a.Tray = a.App.NewSystemTray()
|
||||
a.Tray.SetLabel("cmbone")
|
||||
loadAppMenu(a, langValue)
|
||||
}
|
||||
|
||||
func loadLang(i18nemb embed.FS, lang string) (*models.Lang, error) {
|
||||
file := fmt.Sprintf("frontend/src/locales/%s.json", lang)
|
||||
data, err := i18nemb.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var i18nva models.Lang
|
||||
err = json.Unmarshal(data, &i18nva)
|
||||
return &i18nva, err
|
||||
}
|
||||
|
||||
func loadAppMenu(a *AppService, langValue string) {
|
||||
lang, langerr := loadLang(a.I18nFS, langValue)
|
||||
if langerr != nil {
|
||||
log.Printf("load language %q failed: %v", langValue, langerr)
|
||||
return
|
||||
}
|
||||
a.Menu = application.NewMenu()
|
||||
a.MenuSecondBar = a.Menu.Add(lang.AppMenu.Second).OnClick(func(ctx *application.Context) {
|
||||
a.SecondWindow.Show()
|
||||
a.SecondWindow.Focus()
|
||||
})
|
||||
a.MenuSetting = a.Menu.Add(lang.AppMenu.Preferences).OnClick(func(ctx *application.Context) {
|
||||
a.MainWindow.Show()
|
||||
a.MainWindow.Focus()
|
||||
})
|
||||
a.Menu.AddSeparator()
|
||||
item := a.Menu.Add(lang.AppMenu.Version)
|
||||
item.SetEnabled(false)
|
||||
a.MenuVersion = item
|
||||
a.Menu.AddSeparator()
|
||||
a.MenuQuit = a.Menu.Add(lang.AppMenu.Quit).OnClick(func(ctx *application.Context) {
|
||||
a.App.Quit()
|
||||
})
|
||||
a.Tray.SetMenu(a.Menu)
|
||||
}
|
||||
|
||||
func updateAppMenu(a *AppService, langValue string) {
|
||||
lang, langerr := loadLang(a.I18nFS, langValue)
|
||||
if langerr != nil {
|
||||
log.Printf("load language %q failed: %v", langValue, langerr)
|
||||
return
|
||||
}
|
||||
a.MenuSecondBar.SetLabel(lang.AppMenu.Second)
|
||||
a.MenuSetting.SetLabel(lang.AppMenu.Preferences)
|
||||
a.MenuVersion.SetLabel(lang.AppMenu.Version)
|
||||
a.MenuQuit.SetLabel(lang.AppMenu.Quit)
|
||||
if runtime.GOOS == "darwin" {
|
||||
a.Menu.Update()
|
||||
} else {
|
||||
a.Tray.SetMenu(a.Menu)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AppService) SetLanguage(lang string) error {
|
||||
current := s.Config.GetLanguage()
|
||||
if current == lang {
|
||||
return nil
|
||||
}
|
||||
updateAppMenu(s, lang)
|
||||
return s.Config.SetLanguage(lang)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cmbone/internal/models"
|
||||
hotkey "cmbone/platform"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
type HotkeyService struct {
|
||||
store *SuiStore
|
||||
}
|
||||
|
||||
func NewHotkeyService(store *SuiStore) *HotkeyService {
|
||||
return &HotkeyService{
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *HotkeyService) UpHotkey(id int, key int, modifier int) error {
|
||||
var oldKey, oldModifier uint32
|
||||
if err := cs.store.DB.QueryRow("SELECT keycode, modifiers FROM hotkeys WHERE id = ?", id).Scan(&oldKey, &oldModifier); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := cs.store.DB.Exec(`
|
||||
UPDATE hotkeys
|
||||
SET keycode = ?, modifiers = ?
|
||||
WHERE id = ?
|
||||
`, key, modifier, id)
|
||||
if err == nil {
|
||||
LoadAndRegisterHotkeysFrom(cs.store, id)
|
||||
hotkey.UnregisterHotKey(oldKey, oldModifier)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (cs *HotkeyService) GetHotkeys() ([]models.Hotkey, error) {
|
||||
rows, err := cs.store.DB.Query("SELECT id, keycode, modifiers FROM hotkeys")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hotkeys []models.Hotkey
|
||||
for rows.Next() {
|
||||
var hk models.Hotkey
|
||||
if err := rows.Scan(&hk.ID, &hk.KeyCode, &hk.Modifiers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hotkeys = append(hotkeys, hk)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hotkeys, nil
|
||||
}
|
||||
|
||||
type hotkeyEntry struct {
|
||||
ID int
|
||||
Hotkey models.Hotkey
|
||||
Window application.Window
|
||||
Callback func()
|
||||
}
|
||||
|
||||
var (
|
||||
hotkeys = make(map[int]*hotkeyEntry)
|
||||
mutex sync.RWMutex
|
||||
)
|
||||
|
||||
func RegisterHotkeyEntry(id int, keycode, modifiers uint32, win application.Window, cb func()) {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
hotkeys[id] = &hotkeyEntry{
|
||||
ID: id,
|
||||
Hotkey: models.Hotkey{
|
||||
KeyCode: keycode,
|
||||
Modifiers: modifiers,
|
||||
},
|
||||
Window: win,
|
||||
Callback: cb,
|
||||
}
|
||||
hotkey.RegisterHotKeyWithCallback(keycode, modifiers, cb)
|
||||
}
|
||||
|
||||
func LoadAndRegisterHotkeysFrom(m *SuiStore, id int) {
|
||||
var hk models.Hotkey
|
||||
err := m.DB.QueryRow("SELECT keycode, modifiers FROM hotkeys where id = ?", id).Scan(&hk.KeyCode, &hk.Modifiers)
|
||||
if err != nil {
|
||||
log.Printf("load hotkey %d failed: %v", id, err)
|
||||
return
|
||||
}
|
||||
mutex.RLock()
|
||||
entry, ok := hotkeys[id]
|
||||
mutex.RUnlock()
|
||||
|
||||
if !ok {
|
||||
log.Printf("hotkey %d has no registered window callback", id)
|
||||
return
|
||||
}
|
||||
hotkey.UnregisterHotKey(hk.KeyCode, hk.Modifiers)
|
||||
RegisterHotkeyEntry(id, hk.KeyCode, hk.Modifiers, entry.Window, entry.Callback)
|
||||
}
|
||||
|
||||
func RegisterWindowAndCallback(id int, win application.Window, cb hotkey.HotKeyCallback) {
|
||||
mutex.Lock()
|
||||
hotkeys[id] = &hotkeyEntry{
|
||||
ID: id,
|
||||
Hotkey: models.Hotkey{
|
||||
KeyCode: 0,
|
||||
Modifiers: 0,
|
||||
},
|
||||
Window: win,
|
||||
Callback: cb,
|
||||
}
|
||||
mutex.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cmbone/platform"
|
||||
)
|
||||
|
||||
type OCRService struct{}
|
||||
|
||||
// 识别Base64图片(data:image/png;base64,...)
|
||||
func (o *OCRService) RecognizeImageBase64(base64str string) (string, error) {
|
||||
return platform.RecognizeImageBase64(base64str)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
appConfigDirName = "cmbone"
|
||||
databaseFileName = "cmbone.db"
|
||||
)
|
||||
|
||||
type SuiStore struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func getSafeDBPath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
appDir := filepath.Join(configDir, appConfigDirName)
|
||||
err = os.MkdirAll(appDir, 0755)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(appDir, databaseFileName), nil
|
||||
}
|
||||
|
||||
func NewSuiStore() (*SuiStore, error) {
|
||||
dbPath, err := getSafeDBPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = Migrate(db)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &SuiStore{DB: db}, nil
|
||||
}
|
||||
|
||||
func (cs *SuiStore) Close() {
|
||||
if cs != nil && cs.DB != nil {
|
||||
_ = cs.DB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *SuiStore) Start() error {
|
||||
// 这里可以初始化数据库或其它启动逻辑
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cs *SuiStore) Stop() error {
|
||||
cs.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func Migrate(db *sql.DB) error {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS hotkeys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keycode INTEGER NOT NULL,
|
||||
modifiers INTEGER NOT NULL,
|
||||
description TEXT,
|
||||
target TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS appconfig (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE,
|
||||
type TEXT,
|
||||
value TEXT,
|
||||
description TEXT,
|
||||
state INTEGER DEFAULT 0
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return initDefaultData(db)
|
||||
}
|
||||
|
||||
func initDefaultData(db *sql.DB) error {
|
||||
if _, err := db.Exec(defaultAppConfigSQL()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(defaultHotkeysSQL()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *SuiStore {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
})
|
||||
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
return &SuiStore{DB: db}
|
||||
}
|
||||
|
||||
func TestMigrateCreatesDefaultDataOnce(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := Migrate(store.DB); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
|
||||
var hotkeyCount int
|
||||
if err := store.DB.QueryRow("SELECT COUNT(*) FROM hotkeys").Scan(&hotkeyCount); err != nil {
|
||||
t.Fatalf("count hotkeys: %v", err)
|
||||
}
|
||||
if hotkeyCount != 2 {
|
||||
t.Fatalf("hotkey count = %d, want 2", hotkeyCount)
|
||||
}
|
||||
|
||||
var lang string
|
||||
if err := store.DB.QueryRow("SELECT value FROM appconfig WHERE key = 'language'").Scan(&lang); err != nil {
|
||||
t.Fatalf("query language: %v", err)
|
||||
}
|
||||
if lang != "zh" {
|
||||
t.Fatalf("language = %q, want zh", lang)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigServiceLanguage(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
service := NewAppConfigService(store)
|
||||
|
||||
if got := service.GetLanguage(); got != "zh" {
|
||||
t.Fatalf("default language = %q, want zh", got)
|
||||
}
|
||||
|
||||
if err := service.SetLanguage("en"); err != nil {
|
||||
t.Fatalf("set language: %v", err)
|
||||
}
|
||||
if got := service.GetLanguage(); got != "en" {
|
||||
t.Fatalf("updated language = %q, want en", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
)
|
||||
|
||||
type SystemInfo struct{}
|
||||
|
||||
func NewSystemInfo() *SystemInfo {
|
||||
return &SystemInfo{}
|
||||
}
|
||||
|
||||
func (s *SystemInfo) GetOS() string {
|
||||
return runtime.GOOS // 返回 "darwin" / "windows" / "linux"
|
||||
}
|
||||
|
||||
func defaultHotkeysSQL() string {
|
||||
var sqlhotkeys = `
|
||||
INSERT INTO hotkeys (keycode, modifiers, description, target)
|
||||
SELECT 80, 1, '剪贴板窗口快捷键', '1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hotkeys WHERE target = '1');
|
||||
|
||||
INSERT INTO hotkeys (keycode, modifiers, description, target)
|
||||
SELECT 77, 1, '偏好设置快捷键 ', '2'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hotkeys WHERE target = '2'); `
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
sqlhotkeys = `
|
||||
INSERT INTO hotkeys (keycode, modifiers, description, target)
|
||||
SELECT 34, 768, '剪贴板窗口快捷键', '1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hotkeys WHERE target = '1');
|
||||
|
||||
INSERT INTO hotkeys (keycode, modifiers, description, target)
|
||||
SELECT 46, 768, '偏好设置快捷键 ', '2'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hotkeys WHERE target = '2'); `
|
||||
}
|
||||
return sqlhotkeys
|
||||
}
|
||||
|
||||
func defaultAppConfigSQL() string {
|
||||
return `
|
||||
INSERT OR IGNORE INTO appconfig (key, type, value, description)
|
||||
VALUES ('language', 'system', 'zh', '应用语言,支持 zh/en');`
|
||||
}
|
||||
Reference in New Issue
Block a user