The very basic client app, not adapted for mobiles.
This commit is contained in:
6
client/Taskfile.yml
Normal file
6
client/Taskfile.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
client: ./cmd/client
|
||||
translations_checker: ./cmd/translations_checker
|
88
client/cmd/client/Taskfile.yml
Normal file
88
client/cmd/client/Taskfile.yml
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
version: "3"
|
||||
|
||||
vars:
|
||||
ClientBinary: name.pztrn.bunker.client{{exeExt}}
|
||||
MetadataParams: --metadata "Branch={{ .BRANCH }}" --metadata "Build={{ .BUILD }}" --metadata "BuildDate={{ .BUILD_DATE }}" --metadata "Commit={{ .COMMIT }}" --metadata "Version={{ .VERSION }}"
|
||||
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
tasks:
|
||||
build:
|
||||
desc: "Build client."
|
||||
dir: ./client/cmd/client
|
||||
internal: true
|
||||
cmds:
|
||||
- task: ::ensure-builddir
|
||||
- task: cleanup
|
||||
- fyne build --release -o ../../../.build/{{ .ClientBinary }} --pprof --pprof-port 6060 {{ .MetadataParams }}
|
||||
|
||||
build-debug:
|
||||
desc: "Build client in debug mode."
|
||||
internal: true
|
||||
dir: ./client/cmd/client
|
||||
cmds:
|
||||
- task: ::ensure-builddir
|
||||
- task: cleanup
|
||||
- fyne build -o ../../../.build/{{ .ClientBinary }} --tags debug {{ .MetadataParams }}
|
||||
|
||||
# build-production:
|
||||
# desc: "Build production package for current OS."
|
||||
# dir: ./client/cmd/client
|
||||
# cmds:
|
||||
# - task: ::ensure-builddir
|
||||
# - task: cleanup
|
||||
# - fyne package --release --app-id name.pztrn.bunker.client -executable name.pztrn.bunker.client -icon ../../Icon.png --release --name "pztrn's Bunker" --app-version "{{ .CLIENT_VERSION }}" --app-build "{{ .BUILD }}" {{ .MetadataParams }}
|
||||
|
||||
# build-web:
|
||||
# desc: "Build web version."
|
||||
# dir: ./client/cmd/client
|
||||
# cmds:
|
||||
# - fyne package -os web --app-id name.pztrn.bunker.client -icon ../../Icon.png --release --name "pztrn's Bunker" --app-version "{{ .CLIENT_VERSION }}" --app-build "{{ .BUILD }}" {{ .MetadataParams }}
|
||||
|
||||
build-darwin-amd64:
|
||||
desc: "Build darwin/arm64 using fyne-cross"
|
||||
cmds:
|
||||
- task: ::ensure-builddir
|
||||
- task: cleanup
|
||||
- fyne-cross darwin -app-version="{{ .CLIENT_VERSION }}" -app-build="{{ .BUILD }}" -arch=amd64 -category=6007 -pull client/cmd/client
|
||||
|
||||
build-darwin-arm64:
|
||||
desc: "Build darwin/arm64 using fyne-cross"
|
||||
cmds:
|
||||
- task: ::ensure-builddir
|
||||
- task: cleanup
|
||||
- fyne-cross darwin -app-version="{{ .CLIENT_VERSION }}" -app-build="{{ .BUILD }}" -arch=arm64 -category=6007 -pull client/cmd/client
|
||||
|
||||
build-linux-amd64:
|
||||
desc: "Build linux/amd64 using fyne-cross"
|
||||
cmds:
|
||||
- task: ::ensure-builddir
|
||||
- task: cleanup
|
||||
- fyne-cross linux -app-id=name.pztrn.bunker.client -app-version="{{ .CLIENT_VERSION }}" -app-build="{{ .BUILD }}" -icon=client/Icon.png -arch=amd64 -name="pztrn's Bunker" -pull client/cmd/client
|
||||
|
||||
build-windows-amd64:
|
||||
desc: "Build windows/amd64 using fyne-cross"
|
||||
cmds:
|
||||
- task: ::ensure-builddir
|
||||
- task: cleanup
|
||||
- fyne-cross windows -app-id=name.pztrn.bunker.client -app-version="{{ .CLIENT_VERSION }}" -app-build="{{ .BUILD }}" -icon=client/Icon.png -arch=amd64 -name="pztrn's Bunker" -pull client/cmd/client
|
||||
|
||||
cleanup:
|
||||
desc: "Cleanup build environment."
|
||||
cmds:
|
||||
- rm .build/{{ .ClientBinary }}
|
||||
ignore_error: true
|
||||
|
||||
run:
|
||||
desc: "Launch client."
|
||||
cmds:
|
||||
- task: build
|
||||
- .build/{{ .ClientBinary }}
|
||||
|
||||
run-debug:
|
||||
desc: "Launch client in debug mode."
|
||||
cmds:
|
||||
- task: build-debug
|
||||
- .build/{{ .ClientBinary }}
|
69
client/cmd/client/main.go
Normal file
69
client/cmd/client/main.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"bunker/client/internal/application"
|
||||
"bunker/client/internal/services/core/database"
|
||||
"bunker/client/internal/services/core/mainwindow"
|
||||
"bunker/client/internal/services/core/options"
|
||||
"bunker/client/internal/services/core/translations"
|
||||
"bunker/commons"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.Info("Starting Bunker client...")
|
||||
|
||||
_ = slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
|
||||
app := application.New()
|
||||
|
||||
checkError(translations.Initialize(app))
|
||||
checkError(database.Initialize(app))
|
||||
checkError(options.Initialize(app))
|
||||
checkError(mainwindow.Initialize(app))
|
||||
|
||||
checkError(app.Start())
|
||||
}
|
||||
|
||||
func checkError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fyneApp := app.NewWithID(commons.ClientAppID)
|
||||
|
||||
wnd := fyneApp.NewWindow("pztrn's Bunker - Error occurred!")
|
||||
//nolint:mnd
|
||||
wnd.Resize(fyne.NewSize(550, 300))
|
||||
|
||||
lbl := widget.NewLabel("Error appeared while starting pztrn's Bunker:")
|
||||
lbl.Wrapping = fyne.TextWrapWord
|
||||
|
||||
errLabel := widget.NewLabel(err.Error())
|
||||
errLabel.Wrapping = fyne.TextWrapWord
|
||||
|
||||
lbl2 := widget.NewLabel("Please, report this to developer!")
|
||||
lbl2.Wrapping = fyne.TextWrapWord
|
||||
|
||||
copyToClipboardButton := widget.NewButton("Copy to clipboard", func() {
|
||||
fyneApp.Clipboard().SetContent(err.Error())
|
||||
})
|
||||
|
||||
wnd.SetContent(container.NewVBox(
|
||||
lbl,
|
||||
errLabel,
|
||||
lbl2,
|
||||
copyToClipboardButton,
|
||||
))
|
||||
|
||||
wnd.ShowAndRun()
|
||||
|
||||
os.Exit(1)
|
||||
}
|
26
client/cmd/translations_checker/Taskfile.yml
Normal file
26
client/cmd/translations_checker/Taskfile.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
version: "3"
|
||||
|
||||
vars:
|
||||
TCClientBinary: name.pztrn.bunker.app.translations_checker{{exeExt}}
|
||||
|
||||
tasks:
|
||||
build:
|
||||
desc: "Build translations checker."
|
||||
dir: ./client/cmd/translations_checker
|
||||
cmds:
|
||||
- task: ::ensure-builddir
|
||||
- task: cleanup
|
||||
- go build -o ../../../.build/{{ .TCClientBinary }} main.go
|
||||
|
||||
cleanup:
|
||||
desc: "Cleanup build environment for translations checker."
|
||||
cmds:
|
||||
- rm .build/{{ .TCClientBinary }}
|
||||
ignore_error: true
|
||||
|
||||
run:
|
||||
desc: "Launch translations checker."
|
||||
cmds:
|
||||
- task: build
|
||||
- .build/{{ .TCClientBinary }} -translations-path client/internal/langfiles/files
|
128
client/cmd/translations_checker/main.go
Normal file
128
client/cmd/translations_checker/main.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
exitCodeFailedToReadEnglishFile = 3
|
||||
exitCodeFailedToReadTranslationsFile = 6
|
||||
exitCodeFailedToNormalizePath = 2
|
||||
exitCodeFailedToUnmarshalEnglishFile = 4
|
||||
exitCodeFailedToUnmarshalTranslationsFile = 7
|
||||
exitCodeFailedToWalkTranslationsPath = 5
|
||||
exitCodeNoTranslationsPath = 1
|
||||
)
|
||||
|
||||
var translationsPath string
|
||||
|
||||
func main() {
|
||||
flag.StringVar(&translationsPath, "translations-path", "", "Path to translations JSON files.")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if translationsPath == "" {
|
||||
slog.Error("No -translations-path specified.")
|
||||
os.Exit(exitCodeNoTranslationsPath)
|
||||
}
|
||||
|
||||
slog.Info(
|
||||
"Checking translations file for missing translations, using 'en.json' as original source...",
|
||||
"path", translationsPath,
|
||||
)
|
||||
|
||||
dirPath, err := filepath.Abs(translationsPath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to normalize translations files path!", "error", err.Error())
|
||||
os.Exit(exitCodeFailedToNormalizePath)
|
||||
}
|
||||
|
||||
sourceDataAsBytes, err := os.ReadFile(filepath.Join(dirPath, "en.json"))
|
||||
if err != nil {
|
||||
slog.Error("Failed to read 'en.json' file!", "error", err.Error())
|
||||
os.Exit(exitCodeFailedToReadEnglishFile)
|
||||
}
|
||||
|
||||
sourceData := make(map[string]string)
|
||||
|
||||
if err := json.Unmarshal(sourceDataAsBytes, &sourceData); err != nil {
|
||||
slog.Error("Failed to unmarshal 'en.json' file!", "error", err.Error())
|
||||
os.Exit(exitCodeFailedToUnmarshalEnglishFile)
|
||||
}
|
||||
|
||||
langFilesToCheck := make([]string, 0)
|
||||
|
||||
if err := filepath.WalkDir(dirPath, func(path string, _ fs.DirEntry, _ error) error {
|
||||
if strings.Contains(path, ".json") && !strings.Contains(path, "en.json") {
|
||||
langFilesToCheck = append(langFilesToCheck, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
slog.Error("Failed to walk translations path!", "error", err.Error())
|
||||
os.Exit(exitCodeFailedToWalkTranslationsPath)
|
||||
}
|
||||
|
||||
slog.Info("Got langfiles to check", "count", len(langFilesToCheck))
|
||||
|
||||
missingTranslations := make(map[string][]string)
|
||||
|
||||
for _, langFile := range langFilesToCheck {
|
||||
slog.Info("Checking language file...", "file", langFile)
|
||||
|
||||
langFileBytes, err := os.ReadFile(langFile)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read language file!", "file", langFile, "error", err.Error())
|
||||
os.Exit(exitCodeFailedToReadTranslationsFile)
|
||||
}
|
||||
|
||||
langFileData := make(map[string]string)
|
||||
|
||||
if err := json.Unmarshal(langFileBytes, &langFileData); err != nil {
|
||||
slog.Error("Failed to parse language file!", "file", langFile, "error", err.Error())
|
||||
os.Exit(exitCodeFailedToUnmarshalTranslationsFile)
|
||||
}
|
||||
|
||||
for originalTranslation := range sourceData {
|
||||
if _, found := langFileData[originalTranslation]; !found {
|
||||
if _, langFound := missingTranslations[langFile]; !langFound {
|
||||
missingTranslations[langFile] = make([]string, 0)
|
||||
}
|
||||
|
||||
missingTranslations[langFile] = append(missingTranslations[langFile], originalTranslation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingTranslations) == 0 {
|
||||
slog.Info("Yay, no missing translations!")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
for langFile, missing := range missingTranslations {
|
||||
_, fileName := filepath.Split(langFile)
|
||||
|
||||
slog.Info("Found missing translations for file.", "file", fileName)
|
||||
|
||||
sort.Strings(missing)
|
||||
|
||||
//nolint:forbidigo
|
||||
fmt.Println("============================== " + fileName + " MISSING START")
|
||||
|
||||
for _, name := range missing {
|
||||
//nolint:forbidigo
|
||||
fmt.Println(name)
|
||||
}
|
||||
|
||||
//nolint:forbidigo
|
||||
fmt.Println("============================== " + fileName + " MISSING END")
|
||||
}
|
||||
}
|
214
client/internal/application/application.go
Normal file
214
client/internal/application/application.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
errApplication = errors.New("application")
|
||||
errNoMainWindow = errors.New("no main window service registered")
|
||||
)
|
||||
|
||||
// Application is a lifecycle controlling structure for application.
|
||||
type Application struct {
|
||||
fyneApp fyne.App
|
||||
services []Service
|
||||
}
|
||||
|
||||
// New creates new instance of lifecycle controlling structure.
|
||||
func New() *Application {
|
||||
appl := &Application{}
|
||||
|
||||
appl.initialize()
|
||||
|
||||
return appl
|
||||
}
|
||||
|
||||
func (a *Application) configure() error {
|
||||
// First iteration - core services.
|
||||
for _, service := range a.services {
|
||||
if !strings.Contains(service.Name(), "core/") {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("Launching configuration procedure for service", "service", service.Name())
|
||||
|
||||
if err := service.Configure(); err != nil {
|
||||
return fmt.Errorf("configure service '%s': %w", service.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Second iteration - rest of the services.
|
||||
for _, service := range a.services {
|
||||
if strings.Contains(service.Name(), "core/") {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("Launching configuration procedure for service", "service", service.Name())
|
||||
|
||||
if err := service.Configure(); err != nil {
|
||||
return fmt.Errorf("configure service '%s': %w", service.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Application) connectDependencies() error {
|
||||
// First iteration - core services.
|
||||
for _, service := range a.services {
|
||||
if !strings.Contains(service.Name(), "core/") {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("Connecting dependencies for service.", "service", service.Name())
|
||||
|
||||
if err := service.ConnectDependencies(); err != nil {
|
||||
return fmt.Errorf("connect dependencies for service '%s': %w", service.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Second iteration - rest of the services.
|
||||
for _, service := range a.services {
|
||||
if strings.Contains(service.Name(), "core/") {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("Connecting dependencies for service.", "service", service.Name())
|
||||
|
||||
if err := service.ConnectDependencies(); err != nil {
|
||||
return fmt.Errorf("connect dependencies for service '%s': %w", service.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContextWithTimeout returns context.Context with requested timeout.
|
||||
func (a *Application) ContextWithTimeout(timeout time.Duration) context.Context {
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), timeout)
|
||||
|
||||
// As we do not need to call cancelFunc - make linter happy.
|
||||
// This probably will lead to context leak, so it should be investigated.
|
||||
go func(_ context.CancelFunc) {}(cancelFunc)
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (a *Application) initialize() {
|
||||
a.services = make([]Service, 0)
|
||||
|
||||
a.initializeFyne()
|
||||
}
|
||||
|
||||
func (a *Application) launchStartupTasks() error {
|
||||
for _, service := range a.services {
|
||||
if strings.Contains(service.Name(), "mainwindow") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(service.Name(), "core/") {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("Launching startup tasks for service.", "service", service.Name())
|
||||
|
||||
if err := service.LaunchStartupTasks(); err != nil {
|
||||
return fmt.Errorf("launch startup tasks for core/%s: %w", service.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, service := range a.services {
|
||||
if strings.Contains(service.Name(), "core/") {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("Launching startup tasks for service.", "service", service.Name())
|
||||
|
||||
if err := service.LaunchStartupTasks(); err != nil {
|
||||
return fmt.Errorf("launch startup tasks for core/%s: %w", service.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
var mainWindowService Service
|
||||
|
||||
for _, srv := range a.services {
|
||||
if srv.Name() == "core/mainwindow" {
|
||||
mainWindowService = srv
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if mainWindowService == nil {
|
||||
return fmt.Errorf("launch startup tasks: %w", errNoMainWindow)
|
||||
}
|
||||
|
||||
if err := mainWindowService.LaunchStartupTasks(); err != nil {
|
||||
return fmt.Errorf("launch startup tasks for %s: %w", mainWindowService.Name(), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown stops application
|
||||
func (a *Application) Shutdown() error {
|
||||
slog.Info("Stopping pztrn's Bunker...")
|
||||
|
||||
// Сначала тушим фичи.
|
||||
for _, service := range a.services {
|
||||
if !strings.Contains(service.Name(), "features/") {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("Shutting down service.", "service", service.Name())
|
||||
|
||||
if err := service.Shutdown(); err != nil {
|
||||
return fmt.Errorf("shutting down service '%s': %w", service.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Потом тушим ядро.
|
||||
for _, service := range a.services {
|
||||
if !strings.Contains(service.Name(), "core/") {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("Shutting down service.", "service", service.Name())
|
||||
|
||||
if err := service.Shutdown(); err != nil {
|
||||
return fmt.Errorf("shutting down service '%s': %w", service.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start запускает приложение.
|
||||
func (a *Application) Start() error {
|
||||
if err := a.connectDependencies(); err != nil {
|
||||
return fmt.Errorf("%w: %w", errApplication, err)
|
||||
}
|
||||
|
||||
if err := a.configure(); err != nil {
|
||||
return fmt.Errorf("%w: %w", errApplication, err)
|
||||
}
|
||||
|
||||
if err := a.launchStartupTasks(); err != nil {
|
||||
return fmt.Errorf("%w: %w", errApplication, err)
|
||||
}
|
||||
|
||||
a.fyneApp.Run()
|
||||
|
||||
return nil
|
||||
}
|
17
client/internal/application/fyne.go
Normal file
17
client/internal/application/fyne.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"bunker/commons"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
)
|
||||
|
||||
// Fyne возвращает экземпляр Fyne для взаимодействия с ним.
|
||||
func (a *Application) Fyne() fyne.App {
|
||||
return a.fyneApp
|
||||
}
|
||||
|
||||
func (a *Application) initializeFyne() {
|
||||
a.fyneApp = app.NewWithID(commons.ClientAppID)
|
||||
}
|
80
client/internal/application/service.go
Normal file
80
client/internal/application/service.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrServiceAlreadyRegistered returns if trying to register a service with name already taken by other service.
|
||||
ErrServiceAlreadyRegistered = errors.New("service with such name already registered")
|
||||
// ErrServiceNotFound returns if trying to gather service with unknown name.
|
||||
ErrServiceNotFound = errors.New("service with such name wasn't found")
|
||||
)
|
||||
|
||||
// Service is an interface every service should conform to. Specific services will have own interface for
|
||||
// cross-service interation.
|
||||
type Service interface {
|
||||
// Configure configures service. Called after ConnectDependencies and before LaunchStartupTasks.
|
||||
Configure() error
|
||||
// ConnectDependencies gets neccessary dependencies.
|
||||
ConnectDependencies() error
|
||||
// Initialize initializes service's internal state. Called while registering service with Application
|
||||
// lifecycle controller.
|
||||
Initialize() error
|
||||
// Name returns service name.
|
||||
Name() string
|
||||
// LaunchStartupTasks launches tasks on application start. Called after ConnectDependencies and Configure.
|
||||
LaunchStartupTasks() error
|
||||
// Shutdown stops service.
|
||||
Shutdown() error
|
||||
}
|
||||
|
||||
// RegisterService registering service with lifecycle controller for later use in any other service.
|
||||
func (a *Application) RegisterService(srv Service) error {
|
||||
var found bool
|
||||
|
||||
for _, knownService := range a.services {
|
||||
if srv.Name() == knownService.Name() {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
return fmt.Errorf(
|
||||
"%w: RegisterService: check for service '%s' registration: %w",
|
||||
errApplication,
|
||||
srv.Name(),
|
||||
ErrServiceAlreadyRegistered,
|
||||
)
|
||||
}
|
||||
|
||||
if err := srv.Initialize(); err != nil {
|
||||
return fmt.Errorf("%w: RegisterService: initialize service '%s': %w", errApplication, srv.Name(), err)
|
||||
}
|
||||
|
||||
a.services = append(a.services, srv)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service returns requested service.
|
||||
func (a *Application) Service(name string) Service {
|
||||
var srv Service
|
||||
|
||||
for _, knownService := range a.services {
|
||||
if knownService.Name() == name {
|
||||
srv = knownService
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if srv == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
13
client/internal/helpers/sqlx.go
Normal file
13
client/internal/helpers/sqlx.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package helpers
|
||||
|
||||
import "strings"
|
||||
|
||||
// IsConstraintFailed возвращает true если ограничение по индексу было нарушено.
|
||||
func IsConstraintFailed(err error) bool {
|
||||
return strings.Contains(err.Error(), "constraint failed")
|
||||
}
|
||||
|
||||
// IsEmptySliceForIn возвращает true если sqlx вернул ошибку подготовки слайла параметров в sqlx.In.
|
||||
func IsEmptySliceForIn(err error) bool {
|
||||
return strings.Contains(err.Error(), "empty slice passed to 'in' query")
|
||||
}
|
30
client/internal/langfiles/files/en.json
Normal file
30
client/internal/langfiles/files/en.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"about_dialog.about_tab": "About",
|
||||
"about_dialog.about_tab.copyright": "Copyright © {{ .Years }}, Stanislav Nikitin (a.k.a. pztrn). All rights reserved.",
|
||||
"about_dialog.about_tab.summary": "pztrn's Bunker is a self-hosted family or company intranet.",
|
||||
"about_dialog.licenses_tab": "Licenses",
|
||||
"about_dialog.licenses_tab.dependency.fyne": "Cross platform GUI toolkit in Go inspired by Material Design.",
|
||||
"about_dialog.licenses_tab.dependency.goose": "A database migration tool.",
|
||||
"about_dialog.licenses_tab.dependency.gopsutil": "psutil for golang. Used for getting information about Bunker's process and machine it launched on.",
|
||||
"about_dialog.licenses_tab.dependency.modernc_sqlite": "CGo-free port of SQLite/SQLite3.\n\nSQLite is an in-process implementation of a self-contained, serverless, zero-configuration, transactional SQL database engine.",
|
||||
"about_dialog.licenses_tab.dependency.sqlx": "General purpose extensions to golang's database/sql.",
|
||||
"about_dialog.licenses_tab.summary": "This software uses these third-party libraries:",
|
||||
"about_dialog.close_button": "Close",
|
||||
"about_dialog.sysinfo_tab": "System info",
|
||||
"about_dialog.sysinfo_tab.copy_to_clipboard.notification.content": "Copied to clipboard!",
|
||||
"about_dialog.sysinfo_tab.copy_to_clipboard.notification.title": "System information data",
|
||||
"about_dialog.sysinfo_tab.summary": "This tab contains system information that will be needed for bug reporting and features requests.",
|
||||
"about_dialog.title": "About pztrn's Bunker",
|
||||
"about_dialog.version": "Version",
|
||||
"main_menu.about": "?",
|
||||
"main_menu.about.about_bunker_menu_item": "About pztrn's Bunker...",
|
||||
"main_menu.file": "File",
|
||||
"main_menu.file.exit_menu_item": "Exit",
|
||||
"main_menu.file.options_menu_item": "Options",
|
||||
"options_dialog.no_settings": "No settings found for selected section.",
|
||||
"options_dialog.select_section": "Select section on left to see settings.",
|
||||
"search.placeholder": "Enter text to search and press ENTER.",
|
||||
"window.lorem_ipsum.tab_name": "Welcome!",
|
||||
"window.lorem_ipsum.text": "Welcome to pztrn's Bunker!\n\nUnfortunately, something went wrong and while application was successfully initialized and started you see this message.\n\nPlease, restart application using terminal and send logs to developer!",
|
||||
"window.title": "pztrn's Bunker"
|
||||
}
|
30
client/internal/langfiles/files/ru.json
Normal file
30
client/internal/langfiles/files/ru.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"about_dialog.about_tab": "О программе",
|
||||
"about_dialog.about_tab.copyright": "Copyright © {{ .Years }}, Stanislav Nikitin (a.k.a. pztrn). Все права защищены.",
|
||||
"about_dialog.about_tab.summary": "pztrn's Bunker это интранет для вашей семьи или компании.",
|
||||
"about_dialog.licenses_tab": "Лицензии",
|
||||
"about_dialog.licenses_tab.dependency.fyne": "Cross platform GUI toolkit in Go inspired by Material Design.",
|
||||
"about_dialog.licenses_tab.dependency.goose": "A database migration tool.",
|
||||
"about_dialog.licenses_tab.dependency.gopsutil": "psutil for golang. Used for getting information about Bunker's process and machine it launched on.",
|
||||
"about_dialog.licenses_tab.dependency.modernc_sqlite": "CGo-free port of SQLite/SQLite3.\n\nSQLite is an in-process implementation of a self-contained, serverless, zero-configuration, transactional SQL database engine.",
|
||||
"about_dialog.licenses_tab.dependency.sqlx": "General purpose extensions to golang's database/sql.",
|
||||
"about_dialog.licenses_tab.summary": "Это приложение использует следующие сторонние библиотеки:",
|
||||
"about_dialog.close_button": "Закрыть",
|
||||
"about_dialog.sysinfo_tab": "Информация о системе",
|
||||
"about_dialog.sysinfo_tab.copy_to_clipboard.notification.content": "Скопировано в буфер обмена!",
|
||||
"about_dialog.sysinfo_tab.copy_to_clipboard.notification.title": "Информация о системе",
|
||||
"about_dialog.sysinfo_tab.summary": "Эта вкладка содержит информацию, которую необходимо предоставить разработчикам при сообщении об ошибке или при запросе новой функциональности.",
|
||||
"about_dialog.title": "О pztrn's Bunker",
|
||||
"about_dialog.version": "Версия",
|
||||
"main_menu.about": "?",
|
||||
"main_menu.about.about_bunker_menu_item": "О pztrn's Bunker...",
|
||||
"main_menu.file": "Файл",
|
||||
"main_menu.file.exit_menu_item": "Выход",
|
||||
"main_menu.file.options_menu_item": "Настройки",
|
||||
"options_dialog.no_settings": "Для выбранной секции настройки отсутствуют.",
|
||||
"options_dialog.select_section": "Выберите секцию слева для отображения настроек.",
|
||||
"search.placeholder": "Введите текст для поиска и нажмите ENTER.",
|
||||
"window.lorem_ipsum.tab_name": "Добро пожаловать!",
|
||||
"window.lorem_ipsum.text": "Welcome to pztrn's Bunker!\n\nUnfortunately, something went wrong and while application was successfully initialized and started you see this message.\n\nPlease, restart application using terminal and send logs to developer!",
|
||||
"window.title": "pztrn's Bunker"
|
||||
}
|
8
client/internal/langfiles/langfiles.go
Normal file
8
client/internal/langfiles/langfiles.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package langfiles
|
||||
|
||||
import "embed"
|
||||
|
||||
// LangFiles это встроенная ФС, содержащая переводы приложения.
|
||||
//
|
||||
//go:embed files
|
||||
var LangFiles embed.FS
|
44
client/internal/services/core/database.go
Normal file
44
client/internal/services/core/database.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// ServiceNameDatabase is a name for database service.
|
||||
const ServiceNameDatabase = "core/database"
|
||||
|
||||
var (
|
||||
// ErrDatabase indicates that error appeared somewhere in database service.
|
||||
ErrDatabase = errors.New("database service")
|
||||
// ErrDatabaseIsInvalid indicates that database service implementation is invalid.
|
||||
ErrDatabaseIsInvalid = errors.New("database service implementation is invalid")
|
||||
)
|
||||
|
||||
// Database is an interface for database service.
|
||||
type Database interface {
|
||||
// Exec is a proxy for ExecContext from sqlx.
|
||||
Exec(ctx context.Context, query string, params ...interface{}) error
|
||||
// Get is a proxy for GetContext from sqlx.
|
||||
Get(ctx context.Context, target interface{}, query string, params ...interface{}) error
|
||||
// NamedExec is a proxy for NamedExecContext from sqlx.
|
||||
NamedExec(ctx context.Context, query string, param interface{}) error
|
||||
// RegisterMigrations registers migrations for applying from other services. Migrations should reside
|
||||
// in "migrations" directory in passed filesystem.
|
||||
RegisterMigrations(moduleName string, fs fs.FS) error
|
||||
// Select is a proxy for SelectContext from sqlx.
|
||||
Select(ctx context.Context, target interface{}, query string, params ...interface{}) error
|
||||
// Transaction is a wrapper for transactions processing which wraps sqlx's transactions.
|
||||
Transaction(ctx context.Context) (DatabaseTransaction, error)
|
||||
}
|
||||
|
||||
// DatabaseTransaction is an interface for database transactions controllers implementations.
|
||||
type DatabaseTransaction interface {
|
||||
Apply(steps ...TransactionFunc) error
|
||||
}
|
||||
|
||||
// TransactionFunc is a function that is used in transactions to mangle with data.
|
||||
type TransactionFunc func(*sqlx.Tx) error
|
116
client/internal/services/core/database/database.go
Normal file
116
client/internal/services/core/database/database.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
|
||||
"bunker/client/internal/application"
|
||||
"bunker/client/internal/services/core"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
// sqlite adapter.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var _ = core.Database(&database{})
|
||||
|
||||
type database struct {
|
||||
mainWindow core.MainWindow
|
||||
options core.Options
|
||||
app *application.Application
|
||||
db *sqlx.DB
|
||||
migrations map[string]fs.FS
|
||||
dbPath string
|
||||
version int64
|
||||
}
|
||||
|
||||
// Initialize initializes service.
|
||||
func Initialize(app *application.Application) error {
|
||||
db := &database{
|
||||
app: app,
|
||||
}
|
||||
|
||||
if err := app.RegisterService(db); err != nil {
|
||||
return fmt.Errorf("%w: %w", core.ErrMainWindow, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *database) Configure() error {
|
||||
if err := d.configureDBPath(); err != nil {
|
||||
return fmt.Errorf("configure: %w", err)
|
||||
}
|
||||
|
||||
db, err := sqlx.Open("sqlite", d.dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure: open database: %w", err)
|
||||
}
|
||||
|
||||
d.db = db
|
||||
|
||||
slog.Info("Database opened.", "path", d.dbPath)
|
||||
|
||||
if err := d.initializeSysInfoHandler(); err != nil {
|
||||
return fmt.Errorf("configure: %w", err)
|
||||
}
|
||||
|
||||
if err := d.initializeOptions(); err != nil {
|
||||
return fmt.Errorf("configure: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *database) ConnectDependencies() error {
|
||||
mainWindowRaw := d.app.Service(core.ServiceNameMainWindow)
|
||||
if mainWindowRaw == nil {
|
||||
return fmt.Errorf("connect dependencies: get main window: %w", application.ErrServiceNotFound)
|
||||
}
|
||||
|
||||
mainWindow, valid := mainWindowRaw.(core.MainWindow)
|
||||
if !valid {
|
||||
return fmt.Errorf("connect dependencies: type assert main window: %w", core.ErrMainWindowIsInvalid)
|
||||
}
|
||||
|
||||
d.mainWindow = mainWindow
|
||||
|
||||
optionsRaw := d.app.Service(core.ServiceNameOptions)
|
||||
if optionsRaw == nil {
|
||||
return fmt.Errorf("connect dependencies: get options: %w", application.ErrServiceNotFound)
|
||||
}
|
||||
|
||||
options, valid := optionsRaw.(core.Options)
|
||||
if !valid {
|
||||
return fmt.Errorf("connect dependencies: type assert options: %w", core.ErrOptionsIsInvalid)
|
||||
}
|
||||
|
||||
d.options = options
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *database) Initialize() error {
|
||||
d.migrations = make(map[string]fs.FS, 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *database) Name() string {
|
||||
return core.ServiceNameDatabase
|
||||
}
|
||||
|
||||
func (d *database) LaunchStartupTasks() error {
|
||||
// Запускаем миграции.
|
||||
if err := d.applyMigrations(); err != nil {
|
||||
return fmt.Errorf("launch startup tasks: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *database) Shutdown() error {
|
||||
return nil
|
||||
}
|
30
client/internal/services/core/database/dbpath.go
Normal file
30
client/internal/services/core/database/dbpath.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"bunker/commons"
|
||||
)
|
||||
|
||||
func (d *database) configureDBPath() error {
|
||||
rootDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure DB path: get config dir: %w", err)
|
||||
}
|
||||
|
||||
rootDir = filepath.Join(rootDir, commons.ClientAppID)
|
||||
|
||||
//nolint:mnd
|
||||
if err := os.MkdirAll(rootDir, 0o700); err != nil {
|
||||
return fmt.Errorf("configure DB path: create dir: %w", err)
|
||||
}
|
||||
|
||||
d.dbPath = filepath.Join(rootDir, "database.sqlite3")
|
||||
|
||||
slog.Info("Database path configured.", "path", d.dbPath)
|
||||
|
||||
return nil
|
||||
}
|
78
client/internal/services/core/database/migrations.go
Normal file
78
client/internal/services/core/database/migrations.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"bunker/client/internal/services/core"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
var errMigrationsAlreadyRegistered = errors.New("migrations already registered")
|
||||
|
||||
func (d *database) applyMigrations() error {
|
||||
slog.Info("Migrating database...")
|
||||
|
||||
modules := make([]string, 0)
|
||||
|
||||
for module := range d.migrations {
|
||||
modules = append(modules, module)
|
||||
}
|
||||
|
||||
sort.Strings(modules)
|
||||
|
||||
_ = goose.SetDialect(string(goose.DialectSQLite3))
|
||||
|
||||
for _, module := range modules {
|
||||
slog.Info("Migrating database for module...", "module", module)
|
||||
|
||||
goose.SetBaseFS(d.migrations[module])
|
||||
goose.SetTableName(strings.ReplaceAll(module, "/", "_") + "_migrations")
|
||||
|
||||
if err := goose.Up(d.db.DB, "migrations"); err != nil {
|
||||
return fmt.Errorf("%w: applying migrations for module '%s': %w", core.ErrDatabase, module, err)
|
||||
}
|
||||
|
||||
moduleDBVersion, err := goose.GetDBVersion(d.db.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: get database version for module '%s': %w", core.ErrDatabase, module, err)
|
||||
}
|
||||
|
||||
d.version += moduleDBVersion
|
||||
|
||||
slog.Info(
|
||||
"Database for module migrated to latest version",
|
||||
"module", module,
|
||||
"module_db_version", moduleDBVersion,
|
||||
"db_version", d.version,
|
||||
)
|
||||
}
|
||||
|
||||
slog.Info("Database migrated.", "version", d.version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *database) RegisterMigrations(moduleName string, fSys fs.FS) error {
|
||||
slog.Debug("Registering migrations for service.", "service", moduleName)
|
||||
|
||||
if _, found := d.migrations[moduleName]; found {
|
||||
return fmt.Errorf(
|
||||
"%w: RegisterMigrations: module '%s': %w",
|
||||
core.ErrDatabase,
|
||||
moduleName,
|
||||
errMigrationsAlreadyRegistered,
|
||||
)
|
||||
}
|
||||
|
||||
d.migrations[moduleName] = fSys
|
||||
|
||||
slog.Debug("Migrations for service successfully registered.", "service", moduleName)
|
||||
|
||||
return nil
|
||||
}
|
36
client/internal/services/core/database/options.go
Normal file
36
client/internal/services/core/database/options.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bunker/client/internal/services/core/options/dto"
|
||||
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func (d *database) initializeOptions() error {
|
||||
databaseMaxIdleConnsEntry := widget.NewEntry()
|
||||
databaseMaxIdleConns := widget.NewFormItem("Maximum idle connections", databaseMaxIdleConnsEntry)
|
||||
|
||||
databaseMaxOpenedConnsEntry := widget.NewEntry()
|
||||
databaseMaxOpenedConns := widget.NewFormItem("Maximum opened connections", databaseMaxOpenedConnsEntry)
|
||||
|
||||
optionsWidgetForm := widget.NewForm(
|
||||
databaseMaxIdleConns,
|
||||
databaseMaxOpenedConns,
|
||||
)
|
||||
|
||||
if err := d.options.RegisterOptionsWidget(&dto.OptionPane{
|
||||
Widget: optionsWidgetForm,
|
||||
SaveHandler: d.saveOptions,
|
||||
Name: "Database",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register options widget: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *database) saveOptions() error {
|
||||
return nil
|
||||
}
|
80
client/internal/services/core/database/queries.go
Normal file
80
client/internal/services/core/database/queries.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"bunker/client/internal/services/core"
|
||||
)
|
||||
|
||||
// Exec is a proxy for ExecContext from sqlx.
|
||||
func (d *database) Exec(ctx context.Context, query string, params ...interface{}) error {
|
||||
if strings.Contains(query, "?") {
|
||||
query = d.db.Rebind(query)
|
||||
}
|
||||
|
||||
slog.Debug("Executing query.", "query", query, "params", fmt.Sprintf("%+v", params), "module", "core/database")
|
||||
|
||||
if _, err := d.db.ExecContext(ctx, query, params...); err != nil {
|
||||
return fmt.Errorf("%w: failed to Exec(): %w", core.ErrDatabase, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get is a proxy for GetContext from sqlx.
|
||||
func (d *database) Get(ctx context.Context, target interface{}, query string, params ...interface{}) error {
|
||||
if strings.Contains(query, "?") {
|
||||
query = d.db.Rebind(query)
|
||||
}
|
||||
|
||||
slog.Debug(
|
||||
"Getting single data from database with query.",
|
||||
"query", query,
|
||||
"params", fmt.Sprintf("%+v", params),
|
||||
"module", "core/database",
|
||||
)
|
||||
|
||||
if err := d.db.GetContext(ctx, target, query, params...); err != nil {
|
||||
return fmt.Errorf("%w: failed to Get(): %w", core.ErrDatabase, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NamedExec is a proxy for NamedExecContext from sqlx.
|
||||
func (d *database) NamedExec(ctx context.Context, query string, param interface{}) error {
|
||||
if strings.Contains(query, "?") {
|
||||
query = d.db.Rebind(query)
|
||||
}
|
||||
|
||||
slog.Debug("Executing named query.", "query", query, "params", fmt.Sprintf("%+v", param), "module", "core/database")
|
||||
|
||||
if _, err := d.db.NamedExecContext(ctx, query, param); err != nil {
|
||||
return fmt.Errorf("%w: failed to NamedExec(): %w", core.ErrDatabase, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Select is a proxy for SelectContext from sqlx.
|
||||
func (d *database) Select(ctx context.Context, target interface{}, query string, params ...interface{}) error {
|
||||
if strings.Contains(query, "?") {
|
||||
query = d.db.Rebind(query)
|
||||
}
|
||||
|
||||
slog.Debug(
|
||||
"Selecting from database with query.",
|
||||
"query", query,
|
||||
"params", fmt.Sprintf("%+v", params),
|
||||
"module", "core/database",
|
||||
)
|
||||
|
||||
if err := d.db.SelectContext(ctx, target, query, params...); err != nil {
|
||||
return fmt.Errorf("%w: failed to Select(): %w", core.ErrDatabase, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
51
client/internal/services/core/database/sys_info_handler.go
Normal file
51
client/internal/services/core/database/sys_info_handler.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func (d *database) initializeSysInfoHandler() error {
|
||||
if err := d.mainWindow.RegisterAboutWindowSysInfoHandler(
|
||||
"database_data",
|
||||
d.sysInfoHandlerDatabaseVersion,
|
||||
); err != nil {
|
||||
return fmt.Errorf("register database_data handler: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *database) sysInfoHandlerDatabaseVersion() string {
|
||||
var dbFileSize, sizeDivisionsCount int64
|
||||
|
||||
var databaseSizeString string
|
||||
|
||||
fileInfo, err := os.Stat(d.dbPath)
|
||||
if err == nil {
|
||||
dbFileSize = fileInfo.Size()
|
||||
}
|
||||
|
||||
for {
|
||||
//nolint:mnd
|
||||
if dbFileSize > 1024 {
|
||||
dbFileSize /= 1024
|
||||
sizeDivisionsCount++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
switch sizeDivisionsCount {
|
||||
case 1:
|
||||
databaseSizeString = fmt.Sprintf("- Database file size: %d KB", dbFileSize)
|
||||
case 2: //nolint:mnd
|
||||
databaseSizeString = fmt.Sprintf("- Database file size: %d MB", dbFileSize)
|
||||
default:
|
||||
databaseSizeString = fmt.Sprintf("- Database file size: %d GB", dbFileSize)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("### Database\n\n- Database version: %d\n%s\n", d.version, databaseSizeString)
|
||||
}
|
79
client/internal/services/core/database/transaction.go
Normal file
79
client/internal/services/core/database/transaction.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"bunker/client/internal/services/core"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type transaction struct {
|
||||
transaction *sqlx.Tx
|
||||
}
|
||||
|
||||
func (d *database) Transaction(ctx context.Context) (core.DatabaseTransaction, error) {
|
||||
txn, err := d.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: starting transaction: %w", core.ErrDatabase, err)
|
||||
}
|
||||
|
||||
txHandler := &transaction{
|
||||
transaction: txn,
|
||||
}
|
||||
|
||||
return txHandler, nil
|
||||
}
|
||||
|
||||
func (t *transaction) Apply(steps ...core.TransactionFunc) error {
|
||||
for stepNumber, stepFunc := range steps {
|
||||
if err := stepFunc(t.transaction); err != nil {
|
||||
slog.Error(
|
||||
"Error occurred.",
|
||||
"step", stepNumber,
|
||||
"error", err.Error(),
|
||||
"module", "core/database",
|
||||
"subsystem", "transaction",
|
||||
)
|
||||
|
||||
if rollbackErr := t.transaction.Rollback(); rollbackErr != nil {
|
||||
slog.Error(
|
||||
"Transaction rollback failed.",
|
||||
"error", err.Error(),
|
||||
"module", "core/database",
|
||||
"subsystem", "transaction",
|
||||
)
|
||||
|
||||
return fmt.Errorf("%w: transaction rollback: %w", core.ErrDatabase, rollbackErr)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := t.transaction.Commit(); err != nil {
|
||||
slog.Error(
|
||||
"Transaction commit failed.",
|
||||
"error", err.Error(),
|
||||
"module", "core/database",
|
||||
"subsystem", "transaction",
|
||||
)
|
||||
|
||||
if rollbackErr := t.transaction.Rollback(); rollbackErr != nil {
|
||||
slog.Error(
|
||||
"Transaction rollback failed.",
|
||||
"error", err.Error(),
|
||||
"module", "core/database",
|
||||
"subsystem", "transaction",
|
||||
)
|
||||
|
||||
return fmt.Errorf("%w: transaction rollback: %w", core.ErrDatabase, rollbackErr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: transaction commit: %w", core.ErrDatabase, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
32
client/internal/services/core/mainwindow.go
Normal file
32
client/internal/services/core/mainwindow.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
)
|
||||
|
||||
// ServiceNameMainWindow is a name for main window service.
|
||||
const ServiceNameMainWindow = "core/mainwindow"
|
||||
|
||||
var (
|
||||
// ErrMainWindow indicates that error appeared somewhere in main window service.
|
||||
ErrMainWindow = errors.New("main window service")
|
||||
// ErrMainWindowIsInvalid indicates that main window service implementation is invalid.
|
||||
ErrMainWindowIsInvalid = errors.New("main window service implementation is invalid")
|
||||
)
|
||||
|
||||
// MainWindow is an interface for main window service.
|
||||
type MainWindow interface {
|
||||
// AddTab adds tab in main window.
|
||||
AddTab(tab *container.TabItem)
|
||||
// MainWindow returns main window instance (e.g. for using as parent with dialogs).
|
||||
MainWindow() fyne.Window
|
||||
// RegisterAboutWindowSysInfoHandler registers handler for System Info tab in About dialog.
|
||||
RegisterAboutWindowSysInfoHandler(name string, hndl SysInfoHandler) error
|
||||
}
|
||||
|
||||
// SysInfoHandler is a function signature for registering with additional system information handler for About dialog.
|
||||
// It should return valid markdown and should end with "\n".
|
||||
type SysInfoHandler func() string
|
54
client/internal/services/core/mainwindow/about_dialog.go
Normal file
54
client/internal/services/core/mainwindow/about_dialog.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
const (
|
||||
minRowsVisibleInSysInfo = 10
|
||||
startYear = 2025
|
||||
)
|
||||
|
||||
func (m *mainWindow) showAboutDialog() {
|
||||
appNameLbl := canvas.NewText(
|
||||
"pztrn's Bunker",
|
||||
m.app.Fyne().Settings().Theme().Color(
|
||||
theme.ColorNameForeground,
|
||||
m.app.Fyne().Settings().ThemeVariant(),
|
||||
),
|
||||
)
|
||||
appNameLbl.TextSize = 16
|
||||
appNameLbl.TextStyle = fyne.TextStyle{Bold: true}
|
||||
appNameLbl.Alignment = fyne.TextAlignCenter
|
||||
|
||||
appVersionLbl := canvas.NewText(
|
||||
lang.L("about_dialog.version")+" "+m.app.Fyne().Metadata().Custom["Version"],
|
||||
m.app.Fyne().Settings().Theme().Color(
|
||||
theme.ColorNameForeground,
|
||||
m.app.Fyne().Settings().ThemeVariant(),
|
||||
),
|
||||
)
|
||||
appVersionLbl.TextSize = 16
|
||||
appVersionLbl.TextStyle = fyne.TextStyle{Bold: true}
|
||||
appVersionLbl.Alignment = fyne.TextAlignCenter
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
m.generateAboutTab(),
|
||||
m.generateLicensesTab(),
|
||||
m.generateSysInfoTab(),
|
||||
)
|
||||
|
||||
vbox := container.NewVBox(
|
||||
appNameLbl,
|
||||
appVersionLbl,
|
||||
tabs,
|
||||
)
|
||||
|
||||
dlg := dialog.NewCustom(lang.L("about_dialog.title"), lang.L("about_dialog.close_button"), vbox, m.window)
|
||||
dlg.Show()
|
||||
}
|
@@ -0,0 +1,35 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func (m *mainWindow) generateAboutTab() *container.TabItem {
|
||||
copyrightYears := strconv.Itoa(startYear)
|
||||
if time.Now().Year() > startYear {
|
||||
copyrightYears = strconv.Itoa(startYear) + " - " + strconv.Itoa(time.Now().Year())
|
||||
}
|
||||
|
||||
aboutLabel := widget.NewLabel(
|
||||
lang.L("about_dialog.about_tab.summary") +
|
||||
"\n\n" +
|
||||
lang.L("about_dialog.about_tab.copyright", map[string]any{"Years": copyrightYears}),
|
||||
)
|
||||
aboutLabel.Wrapping = fyne.TextWrapWord
|
||||
|
||||
aboutVBox := container.NewVBox(aboutLabel)
|
||||
aboutScroll := container.NewVScroll(aboutVBox)
|
||||
|
||||
aboutScroll.SetMinSize(fyne.NewSize(
|
||||
m.window.Content().Size().Width-m.window.Content().Size().Width/3,
|
||||
m.window.Content().Size().Height-m.window.Content().Size().Height/2.5,
|
||||
))
|
||||
|
||||
return container.NewTabItem(lang.L("about_dialog.about_tab"), aboutScroll)
|
||||
}
|
@@ -0,0 +1,293 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func (m *mainWindow) generateLicensesTab() *container.TabItem {
|
||||
licensesLabel := widget.NewLabel(lang.L("about_dialog.licenses_tab.summary"))
|
||||
licensesLabel.Wrapping = fyne.TextWrapWord
|
||||
|
||||
accordion := widget.NewAccordion(
|
||||
m.generateFyneAccordionItem(),
|
||||
m.generateGooseAccordionItem(),
|
||||
m.generateGopsutilAccordionItem(),
|
||||
m.generateModerncSqliteAccordionItem(),
|
||||
m.generateSqlxAccrodionItem(),
|
||||
)
|
||||
|
||||
licensesVBox := container.NewVBox(licensesLabel, accordion)
|
||||
licensesScroll := container.NewVScroll(licensesVBox)
|
||||
|
||||
return container.NewTabItem(lang.L("about_dialog.licenses_tab"), licensesScroll)
|
||||
}
|
||||
|
||||
func (m *mainWindow) generateFyneAccordionItem() *widget.AccordionItem {
|
||||
summary := widget.NewLabel(lang.L("about_dialog.licenses_tab.dependency.fyne"))
|
||||
summary.Wrapping = fyne.TextWrapWord
|
||||
|
||||
license := widget.NewLabel(`BSD 3-Clause License
|
||||
|
||||
Copyright (C) 2018 Fyne.io developers (see AUTHORS)
|
||||
All rights reserved.
|
||||
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Fyne.io nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.`)
|
||||
license.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
license.Wrapping = fyne.TextWrapWord
|
||||
|
||||
url, _ := url.Parse("https://github.com/fyne-io/fyne")
|
||||
authorsURL, _ := url.Parse("https://raw.githubusercontent.com/fyne-io/fyne/refs/heads/master/AUTHORS")
|
||||
|
||||
return widget.NewAccordionItem("Fyne v2.6.1", container.NewVBox(
|
||||
summary,
|
||||
container.NewHBox(
|
||||
widget.NewHyperlink("Source", url),
|
||||
widget.NewLabel("|"),
|
||||
widget.NewHyperlink("Authors", authorsURL),
|
||||
),
|
||||
license,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *mainWindow) generateGooseAccordionItem() *widget.AccordionItem {
|
||||
summary := widget.NewLabel(lang.L("about_dialog.licenses_tab.dependency.goose"))
|
||||
summary.Wrapping = fyne.TextWrapWord
|
||||
|
||||
license := widget.NewLabel(`MIT License
|
||||
|
||||
Original work Copyright (c) 2012 Liam Staskawicz
|
||||
Modified work Copyright (c) 2016 Vojtech Vitek
|
||||
Modified work Copyright (c) 2021 Michael Fridman, Vojtech Vitek
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`)
|
||||
license.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
license.Wrapping = fyne.TextWrapWord
|
||||
|
||||
url, _ := url.Parse("https://github.com/pressly/goose")
|
||||
authorsURL, _ := url.Parse("https://github.com/pressly/goose/graphs/contributors")
|
||||
|
||||
return widget.NewAccordionItem("goose v3.24.3", container.NewVBox(
|
||||
summary,
|
||||
container.NewHBox(
|
||||
widget.NewHyperlink("Source", url),
|
||||
widget.NewLabel("|"),
|
||||
widget.NewHyperlink("Authors", authorsURL),
|
||||
),
|
||||
license,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *mainWindow) generateGopsutilAccordionItem() *widget.AccordionItem {
|
||||
summary := widget.NewLabel(lang.L("about_dialog.licenses_tab.dependency.gopsutil"))
|
||||
summary.Wrapping = fyne.TextWrapWord
|
||||
|
||||
license := widget.NewLabel(`gopsutil is distributed under BSD license reproduced below.
|
||||
|
||||
Copyright (c) 2014, WAKAYAMA Shirou
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
* Neither the name of the gopsutil authors nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
-------
|
||||
internal/common/binary.go in the gopsutil is copied and modified from golang/encoding/binary.go.
|
||||
|
||||
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.`)
|
||||
license.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
license.Wrapping = fyne.TextWrapWord
|
||||
|
||||
url, _ := url.Parse("https://github.com/shirou/gopsutil")
|
||||
authorsURL, _ := url.Parse("https://github.com/shirou/gopsutil/graphs/contributors")
|
||||
|
||||
return widget.NewAccordionItem("gopsutil v3.24.5", container.NewVBox(
|
||||
summary,
|
||||
container.NewHBox(
|
||||
widget.NewHyperlink("Source", url),
|
||||
widget.NewLabel("|"),
|
||||
widget.NewHyperlink("Authors", authorsURL),
|
||||
),
|
||||
license,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *mainWindow) generateModerncSqliteAccordionItem() *widget.AccordionItem {
|
||||
summary := widget.NewLabel(lang.L("about_dialog.licenses_tab.dependency.modernc_sqlite"))
|
||||
summary.Wrapping = fyne.TextWrapWord
|
||||
|
||||
license := widget.NewLabel(`Copyright (c) 2017 The Sqlite Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
`)
|
||||
license.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
license.Wrapping = fyne.TextWrapWord
|
||||
|
||||
url, _ := url.Parse("https://gitlab.com/cznic/sqlite")
|
||||
authorsURL, _ := url.Parse("https://gitlab.com/cznic/sqlite/-/raw/master/AUTHORS")
|
||||
|
||||
return widget.NewAccordionItem("modernc/sqlite v1.37.1", container.NewVBox(
|
||||
summary,
|
||||
container.NewHBox(
|
||||
widget.NewHyperlink("Source", url),
|
||||
widget.NewLabel("|"),
|
||||
widget.NewHyperlink("Authors", authorsURL),
|
||||
),
|
||||
license,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *mainWindow) generateSqlxAccrodionItem() *widget.AccordionItem {
|
||||
summary := widget.NewLabel(lang.L("about_dialog.licenses_tab.dependency.sqlx"))
|
||||
summary.Wrapping = fyne.TextWrapWord
|
||||
|
||||
license := widget.NewLabel(`Copyright (c) 2013, Jason Moiron
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.`)
|
||||
license.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
license.Wrapping = fyne.TextWrapWord
|
||||
|
||||
url, _ := url.Parse("https://github.com/jmoiron/sqlx")
|
||||
authorsURL, _ := url.Parse("https://github.com/jmoiron/sqlx/graphs/contributors")
|
||||
|
||||
return widget.NewAccordionItem("sqlx v1.4.0", container.NewVBox(
|
||||
summary,
|
||||
container.NewHBox(
|
||||
widget.NewHyperlink("Source", url),
|
||||
widget.NewLabel("|"),
|
||||
widget.NewHyperlink("Authors", authorsURL),
|
||||
),
|
||||
license,
|
||||
))
|
||||
}
|
@@ -0,0 +1,221 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
|
||||
"bunker/client/internal/services/core"
|
||||
"bunker/client/internal/services/core/mainwindow/models"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
)
|
||||
|
||||
var errSysInfoHandlerAlreadyRegistered = errors.New("handler already registered")
|
||||
|
||||
func (m *mainWindow) generateSysInfoTab() *container.TabItem {
|
||||
lbl := widget.NewLabel(lang.L("about_dialog.sysinfo_tab.summary"))
|
||||
lbl.Wrapping = fyne.TextWrapWord
|
||||
|
||||
buildInfo, _ := debug.ReadBuildInfo()
|
||||
|
||||
var builtForOS, builtForArch string
|
||||
|
||||
for _, bi := range buildInfo.Settings {
|
||||
switch bi.Key {
|
||||
case "GOARCH":
|
||||
builtForArch = bi.Value
|
||||
case "GOOS":
|
||||
builtForOS = bi.Value
|
||||
}
|
||||
}
|
||||
|
||||
commons, _ := host.Info()
|
||||
|
||||
cpuInfo, _ := cpu.Info()
|
||||
|
||||
memInfo, _ := mem.VirtualMemory()
|
||||
|
||||
memoryTotal := memInfo.Total
|
||||
memoryTotalDivCount := 0
|
||||
|
||||
var memoryTotalString string
|
||||
|
||||
for {
|
||||
//nolint:mnd
|
||||
if memoryTotal > 1024 {
|
||||
memoryTotal = memoryTotal / 1024
|
||||
memoryTotalDivCount++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
//nolint:mnd
|
||||
switch memoryTotalDivCount {
|
||||
case 1:
|
||||
memoryTotalString = fmt.Sprintf("%d KB", memoryTotal)
|
||||
case 2:
|
||||
memoryTotalString = fmt.Sprintf("%d MB", memoryTotal)
|
||||
default:
|
||||
memoryTotalString = fmt.Sprintf("%d GB", memoryTotal)
|
||||
}
|
||||
|
||||
memoryFree := memInfo.Available
|
||||
memoryFreeDivCount := 0
|
||||
|
||||
var memoryFreeString string
|
||||
|
||||
//nolint:mnd
|
||||
for {
|
||||
if memoryFree > 1024 {
|
||||
memoryFree = memoryFree / 1024
|
||||
memoryFreeDivCount++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
//nolint:mnd
|
||||
switch memoryFreeDivCount {
|
||||
case 1:
|
||||
memoryFreeString = fmt.Sprintf("%d KB", memoryFree)
|
||||
case 2:
|
||||
memoryFreeString = fmt.Sprintf("%d MB", memoryFree)
|
||||
default:
|
||||
memoryFreeString = fmt.Sprintf("%d GB", memoryFree)
|
||||
}
|
||||
|
||||
var (
|
||||
conns int
|
||||
fds int32
|
||||
)
|
||||
|
||||
//nolint:gosec
|
||||
proc, err := process.NewProcess(int32(os.Getpid()))
|
||||
if err == nil {
|
||||
connections, _ := proc.Connections()
|
||||
conns = len(connections)
|
||||
|
||||
fds, _ = proc.NumFDs()
|
||||
}
|
||||
|
||||
sysInfoData := fmt.Sprintf(`## Build information
|
||||
|
||||
- Version: %s (#%s, from %s/%s, on %s)
|
||||
- Built for: %s/%s
|
||||
|
||||
## System information
|
||||
|
||||
- CPU: %s (%d cores)
|
||||
- RAM: %s (%s available)
|
||||
- OS: %s %s
|
||||
- Processes running: %d
|
||||
|
||||
## Launch information
|
||||
|
||||
- Running on: %s/%s
|
||||
- Opened network connections: %d
|
||||
- Opened file descriptors: %d
|
||||
|
||||
## Additional information
|
||||
|
||||
%s
|
||||
`,
|
||||
m.app.Fyne().Metadata().Custom["Version"],
|
||||
m.app.Fyne().Metadata().Custom["Build"],
|
||||
m.app.Fyne().Metadata().Custom["Branch"],
|
||||
m.app.Fyne().Metadata().Custom["Commit"],
|
||||
m.app.Fyne().Metadata().Custom["BuildDate"],
|
||||
builtForOS,
|
||||
builtForArch,
|
||||
cpuInfo[0].ModelName,
|
||||
cpuInfo[0].Cores,
|
||||
memoryTotalString,
|
||||
memoryFreeString,
|
||||
commons.OS,
|
||||
commons.PlatformVersion,
|
||||
commons.Procs,
|
||||
runtime.GOOS,
|
||||
runtime.GOARCH,
|
||||
conns,
|
||||
fds,
|
||||
m.prepareSysInfoAdditionalsForAbout(),
|
||||
)
|
||||
|
||||
sysInfo := widget.NewMultiLineEntry()
|
||||
sysInfo.OnChanged = func(_ string) {
|
||||
sysInfo.SetText(sysInfoData)
|
||||
}
|
||||
sysInfo.SetText(sysInfoData)
|
||||
sysInfo.SetMinRowsVisible(minRowsVisibleInSysInfo)
|
||||
|
||||
copyToClipboard := widget.NewButton("Copy to clipboard", func() {
|
||||
m.app.Fyne().Clipboard().SetContent(sysInfoData)
|
||||
|
||||
m.app.Fyne().SendNotification(&fyne.Notification{
|
||||
Title: lang.L("about_dialog.sysinfo_tab.copy_to_clipboard.notification.title"),
|
||||
Content: lang.L("about_dialog.sysinfo_tab.copy_to_clipboard.notification.content"),
|
||||
})
|
||||
})
|
||||
|
||||
sysInfoVBox := container.NewVBox(
|
||||
lbl,
|
||||
sysInfo,
|
||||
copyToClipboard,
|
||||
)
|
||||
|
||||
return container.NewTabItem(lang.L("about_dialog.sysinfo_tab"), sysInfoVBox)
|
||||
}
|
||||
|
||||
func (m *mainWindow) prepareSysInfoAdditionalsForAbout() string {
|
||||
additionals := ""
|
||||
|
||||
handlersNames := make([]string, 0, len(m.sysInfoHandlers))
|
||||
|
||||
for name := range m.sysInfoHandlers {
|
||||
handlersNames = append(handlersNames, name)
|
||||
}
|
||||
|
||||
sort.Strings(handlersNames)
|
||||
|
||||
for _, name := range handlersNames {
|
||||
additionals += m.sysInfoHandlers[name].Handler() + "\n"
|
||||
}
|
||||
|
||||
return additionals
|
||||
}
|
||||
|
||||
func (m *mainWindow) RegisterAboutWindowSysInfoHandler(name string, hndl core.SysInfoHandler) error {
|
||||
if _, found := m.sysInfoHandlers[name]; found {
|
||||
return fmt.Errorf(
|
||||
"%w: RegisterAboutWindowSysInfoHandler: register '%s': %w",
|
||||
core.ErrMainWindow,
|
||||
name,
|
||||
errSysInfoHandlerAlreadyRegistered,
|
||||
)
|
||||
}
|
||||
|
||||
sysInfoHandler := &models.SysInfoHandler{
|
||||
Name: name,
|
||||
Handler: hndl,
|
||||
}
|
||||
|
||||
m.sysInfoHandlers[name] = sysInfoHandler
|
||||
|
||||
return nil
|
||||
}
|
109
client/internal/services/core/mainwindow/mainwindow.go
Normal file
109
client/internal/services/core/mainwindow/mainwindow.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"bunker/client/internal/application"
|
||||
"bunker/client/internal/services/core"
|
||||
"bunker/client/internal/services/core/mainwindow/models"
|
||||
"bunker/commons"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
var _ = core.MainWindow(&mainWindow{})
|
||||
|
||||
type mainWindow struct {
|
||||
app *application.Application
|
||||
window fyne.Window
|
||||
options core.Options
|
||||
tabs *container.AppTabs
|
||||
sysInfoHandlers map[string]*models.SysInfoHandler
|
||||
}
|
||||
|
||||
// Initialize инициализирует сервис.
|
||||
func Initialize(app *application.Application) error {
|
||||
mainW := &mainWindow{
|
||||
app: app,
|
||||
}
|
||||
|
||||
if err := app.RegisterService(mainW); err != nil {
|
||||
return fmt.Errorf("%w: %w", core.ErrMainWindow, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mainWindow) Configure() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mainWindow) ConnectDependencies() error {
|
||||
optionsRaw := m.app.Service(core.ServiceNameOptions)
|
||||
if optionsRaw == nil {
|
||||
return fmt.Errorf("connect dependencies: get options service: %w", application.ErrServiceNotFound)
|
||||
}
|
||||
|
||||
options, valid := optionsRaw.(core.Options)
|
||||
if !valid {
|
||||
return fmt.Errorf("connect dependencies: type assert options service: %w", core.ErrOptionsIsInvalid)
|
||||
}
|
||||
|
||||
m.options = options
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mainWindow) Initialize() error {
|
||||
m.sysInfoHandlers = make(map[string]*models.SysInfoHandler)
|
||||
|
||||
m.window = m.app.Fyne().NewWindow(lang.L("window.title"))
|
||||
// ToDo: сохранение и восстановление размеров окна.
|
||||
//nolint:mnd
|
||||
m.window.Resize(fyne.NewSize(800, 650))
|
||||
|
||||
m.initializeMenu()
|
||||
|
||||
m.window.SetCloseIntercept(m.stopApp)
|
||||
|
||||
welcomeLabel := widget.NewLabel(lang.L("window.lorem_ipsum.text"))
|
||||
welcomeLabel.Wrapping = fyne.TextWrapWord
|
||||
|
||||
m.tabs = container.NewAppTabs(
|
||||
container.NewTabItem(lang.L("window.lorem_ipsum.tab_name"), welcomeLabel),
|
||||
)
|
||||
m.window.SetContent(m.tabs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mainWindow) MainWindow() fyne.Window {
|
||||
return m.window
|
||||
}
|
||||
|
||||
func (m *mainWindow) Name() string {
|
||||
return core.ServiceNameMainWindow
|
||||
}
|
||||
|
||||
func (m *mainWindow) LaunchStartupTasks() error {
|
||||
m.window.Show()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mainWindow) Shutdown() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mainWindow) stopApp() {
|
||||
if err := m.app.Shutdown(); err != nil {
|
||||
slog.Error("Failed to stop Bunker!", "error", err.Error())
|
||||
|
||||
os.Exit(commons.ExitCodeAppStopFailed)
|
||||
}
|
||||
}
|
35
client/internal/services/core/mainwindow/mainwindow_menu.go
Normal file
35
client/internal/services/core/mainwindow/mainwindow_menu.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
)
|
||||
|
||||
func (m *mainWindow) initializeMenu() {
|
||||
optionsMenuItem := fyne.NewMenuItem(lang.L("main_menu.file.options_menu_item"), func() {
|
||||
slog.Info("Opening options...")
|
||||
|
||||
m.options.ShowOptionsDialog()
|
||||
})
|
||||
|
||||
exitMenuItem := fyne.NewMenuItem(lang.L("main_menu.file.exit_menu_item"), func() {
|
||||
m.stopApp()
|
||||
})
|
||||
exitMenuItem.IsQuit = true
|
||||
|
||||
fileMenu := fyne.NewMenu(lang.L("main_menu.file"), optionsMenuItem, fyne.NewMenuItemSeparator(), exitMenuItem)
|
||||
|
||||
aboutMenuItem := fyne.NewMenuItem(lang.L("main_menu.about.about_bunker_menu_item"), func() {
|
||||
m.showAboutDialog()
|
||||
})
|
||||
|
||||
aboutMenu := fyne.NewMenu(lang.L("main_menu.about"), aboutMenuItem)
|
||||
|
||||
mainMenu := fyne.NewMainMenu(
|
||||
fileMenu,
|
||||
aboutMenu,
|
||||
)
|
||||
m.window.SetMainMenu(mainMenu)
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
// SysInfoHandler содержит в себе информацию об обработчике для Markdown системной информации.
|
||||
type SysInfoHandler struct {
|
||||
Handler func() string
|
||||
Name string
|
||||
}
|
14
client/internal/services/core/mainwindow/tabs.go
Normal file
14
client/internal/services/core/mainwindow/tabs.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
)
|
||||
|
||||
func (m *mainWindow) AddTab(tab *container.TabItem) {
|
||||
if len(m.tabs.Items) == 1 && m.tabs.Items[0].Text == lang.L("window.lorem_ipsum.tab_name") {
|
||||
m.tabs.Remove(m.tabs.Items[0])
|
||||
}
|
||||
|
||||
m.tabs.Append(tab)
|
||||
}
|
25
client/internal/services/core/options.go
Normal file
25
client/internal/services/core/options.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"bunker/client/internal/services/core/options/dto"
|
||||
)
|
||||
|
||||
// ServiceNameOptions это название для сервиса работы с настройками.
|
||||
const ServiceNameOptions = "core/options"
|
||||
|
||||
var (
|
||||
// ErrOptions говорит о возникновении ошибки в сервисе работы с настройками.
|
||||
ErrOptions = errors.New("options core service")
|
||||
// ErrOptionsIsInvalid говорит о неверной имплементации сервиса работы с настройками.
|
||||
ErrOptionsIsInvalid = errors.New("options service implementation is invalid")
|
||||
)
|
||||
|
||||
// Options это интерфейс для сервиса работы с настройками.
|
||||
type Options interface {
|
||||
// RegisterOptionsWidget регистрирует виджет настроек, а также необходимые дополнительные параметры.
|
||||
RegisterOptionsWidget(widgetData *dto.OptionPane) error
|
||||
// ShowOptionsDialog показывает диалог с настройками. Используется только главным окном!
|
||||
ShowOptionsDialog()
|
||||
}
|
82
client/internal/services/core/options/dialog.go
Normal file
82
client/internal/services/core/options/dialog.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func (o *options) closeOptionsDialog(save bool) {
|
||||
slog.Debug("Closing options dialog.", "save", save)
|
||||
|
||||
if !save {
|
||||
return
|
||||
}
|
||||
|
||||
for _, widget := range o.widgets {
|
||||
if widget.SaveHandler == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := widget.SaveHandler(); err != nil {
|
||||
slog.Error("Failed to save options for module!", "module", widget.Name, "error", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *options) optionsListCreateItem() fyne.CanvasObject {
|
||||
return widget.NewLabel("")
|
||||
}
|
||||
|
||||
func (o *options) optionsListLength() int {
|
||||
return len(o.widgetsItems)
|
||||
}
|
||||
|
||||
func (o *options) optionsListUpdateItem(itemID widget.ListItemID, obj fyne.CanvasObject) {
|
||||
label, _ := obj.(*widget.Label)
|
||||
|
||||
label.SetText(o.widgets[o.widgetsItems[itemID]].Name)
|
||||
}
|
||||
|
||||
func (o *options) ShowOptionsDialog() {
|
||||
optionsWidgetScroll := container.NewScroll(widget.NewLabel(lang.L("options_dialog.select_section")))
|
||||
|
||||
optionsList := widget.NewList(o.optionsListLength, o.optionsListCreateItem, o.optionsListUpdateItem)
|
||||
optionsList.OnSelected = func(listID widget.ListItemID) {
|
||||
// listID должно быть всегда меньше длины, так как является индексом.
|
||||
// Эта проверка отрабатывает, если настроек по какой-то причине нет.
|
||||
if listID >= len(o.widgetsItems) {
|
||||
return
|
||||
}
|
||||
|
||||
optionsWidget := o.widgets[o.widgetsItems[listID]].Widget
|
||||
|
||||
if optionsWidget != nil {
|
||||
optionsWidgetScroll.Content = optionsWidget
|
||||
} else {
|
||||
optionsWidgetScroll.Content = widget.NewLabel(lang.L("options_dialog.no_settings"))
|
||||
}
|
||||
|
||||
optionsWidgetScroll.Refresh()
|
||||
}
|
||||
|
||||
optionsListSplitter := container.NewHSplit(optionsList, optionsWidgetScroll)
|
||||
//nolint:mnd
|
||||
optionsListSplitter.SetOffset(0.25)
|
||||
|
||||
optionsDlg := dialog.NewCustomConfirm(
|
||||
"Options",
|
||||
"Save",
|
||||
"Cancel",
|
||||
optionsListSplitter,
|
||||
o.closeOptionsDialog,
|
||||
o.mainWindow.MainWindow(),
|
||||
)
|
||||
optionsDlg.Resize(o.mainWindow.MainWindow().Canvas().Size())
|
||||
|
||||
optionsDlg.Show()
|
||||
}
|
14
client/internal/services/core/options/dto/option_pane.go
Normal file
14
client/internal/services/core/options/dto/option_pane.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package dto
|
||||
|
||||
import "fyne.io/fyne/v2"
|
||||
|
||||
// OptionPane is a DTO for registering options pane.
|
||||
type OptionPane struct {
|
||||
Widget fyne.CanvasObject
|
||||
SaveHandler OptionSaveHandler
|
||||
Name string
|
||||
}
|
||||
|
||||
// OptionSaveHandler is a signature for functions responsible for saving options, resides in service that registers own
|
||||
// options pane.
|
||||
type OptionSaveHandler func() error
|
17
client/internal/services/core/options/migrations.go
Normal file
17
client/internal/services/core/options/migrations.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
//go:embed migrations
|
||||
var migrations embed.FS
|
||||
|
||||
func (o *options) registerMigrations() error {
|
||||
if err := o.db.RegisterMigrations("core/options", migrations); err != nil {
|
||||
return fmt.Errorf("register migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS options (
|
||||
key VARCHAR(1024) NOT NULL UNIQUE,
|
||||
value VARCHAR(8192)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS options;
|
23
client/internal/services/core/options/models/option_pane.go
Normal file
23
client/internal/services/core/options/models/option_pane.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bunker/client/internal/services/core/options/dto"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
)
|
||||
|
||||
// OptionPane это описание одной панели настроек в окне настроек.
|
||||
type OptionPane struct {
|
||||
Widget fyne.CanvasObject
|
||||
SaveHandler func() error
|
||||
Name string
|
||||
}
|
||||
|
||||
// OptionPaneFromDTO конвертирует параметры панели настроек из DTO во внутреннее представление.
|
||||
func OptionPaneFromDTO(dtoData *dto.OptionPane) *OptionPane {
|
||||
return &OptionPane{
|
||||
Widget: dtoData.Widget,
|
||||
SaveHandler: dtoData.SaveHandler,
|
||||
Name: dtoData.Name,
|
||||
}
|
||||
}
|
88
client/internal/services/core/options/options.go
Normal file
88
client/internal/services/core/options/options.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bunker/client/internal/application"
|
||||
"bunker/client/internal/services/core"
|
||||
"bunker/client/internal/services/core/options/models"
|
||||
)
|
||||
|
||||
var _ = core.Options(&options{})
|
||||
|
||||
type options struct {
|
||||
app *application.Application
|
||||
db core.Database
|
||||
mainWindow core.MainWindow
|
||||
|
||||
widgets map[string]*models.OptionPane
|
||||
widgetsItems []string // для рисования списка Fyne.
|
||||
}
|
||||
|
||||
// Initialize инициализирует сервис.
|
||||
func Initialize(app *application.Application) error {
|
||||
opts := &options{
|
||||
app: app,
|
||||
}
|
||||
|
||||
if err := app.RegisterService(opts); err != nil {
|
||||
return fmt.Errorf("%w: %w", core.ErrOptions, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *options) Configure() error {
|
||||
if err := o.registerMigrations(); err != nil {
|
||||
return fmt.Errorf("configure: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *options) ConnectDependencies() error {
|
||||
databaseRaw := o.app.Service(core.ServiceNameDatabase)
|
||||
if databaseRaw == nil {
|
||||
return fmt.Errorf("connect dependencies: get database service: %w", application.ErrServiceNotFound)
|
||||
}
|
||||
|
||||
database, valid := databaseRaw.(core.Database)
|
||||
if !valid {
|
||||
return fmt.Errorf("connect dependencies: type assert database service: %w", core.ErrDatabaseIsInvalid)
|
||||
}
|
||||
|
||||
o.db = database
|
||||
|
||||
mainWindowRaw := o.app.Service(core.ServiceNameMainWindow)
|
||||
if mainWindowRaw == nil {
|
||||
return fmt.Errorf("connect dependencies: get main window: %w", application.ErrServiceNotFound)
|
||||
}
|
||||
|
||||
mainWindow, valid := mainWindowRaw.(core.MainWindow)
|
||||
if !valid {
|
||||
return fmt.Errorf("connect dependencies: type assert main window: %w", core.ErrMainWindowIsInvalid)
|
||||
}
|
||||
|
||||
o.mainWindow = mainWindow
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *options) Initialize() error {
|
||||
o.widgets = make(map[string]*models.OptionPane)
|
||||
o.widgetsItems = make([]string, 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *options) Name() string {
|
||||
return core.ServiceNameOptions
|
||||
}
|
||||
|
||||
func (o *options) LaunchStartupTasks() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *options) Shutdown() error {
|
||||
return nil
|
||||
}
|
28
client/internal/services/core/options/widgets.go
Normal file
28
client/internal/services/core/options/widgets.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"bunker/client/internal/services/core"
|
||||
"bunker/client/internal/services/core/options/dto"
|
||||
"bunker/client/internal/services/core/options/models"
|
||||
)
|
||||
|
||||
var errWidgetPaneAlreadyRegistered = errors.New("widget pane already registered")
|
||||
|
||||
func (o *options) RegisterOptionsWidget(widgetData *dto.OptionPane) error {
|
||||
if _, found := o.widgets[widgetData.Name]; found {
|
||||
return fmt.Errorf(
|
||||
"%w: RegisterOptionsWidget: '%s': %w",
|
||||
core.ErrOptions,
|
||||
widgetData.Name,
|
||||
errWidgetPaneAlreadyRegistered,
|
||||
)
|
||||
}
|
||||
|
||||
o.widgets[widgetData.Name] = models.OptionPaneFromDTO(widgetData)
|
||||
o.widgetsItems = append(o.widgetsItems, widgetData.Name)
|
||||
|
||||
return nil
|
||||
}
|
18
client/internal/services/core/translations.go
Normal file
18
client/internal/services/core/translations.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ServiceNameTranslations это название для сервиса работы с переводами.
|
||||
const ServiceNameTranslations = "core/translations"
|
||||
|
||||
var (
|
||||
// ErrTranslations говорит о возникновении ошибки в сервисе работы с настройками.
|
||||
ErrTranslations = errors.New("translations core service")
|
||||
// ErrTranslationsIsInvalid говорит о неверной имплементации сервиса работы с переводами.
|
||||
ErrTranslationsIsInvalid = errors.New("translations service implementation is invalid")
|
||||
)
|
||||
|
||||
// Translations это интерфейс для сервиса работы с переводами.
|
||||
type Translations interface{}
|
@@ -0,0 +1,74 @@
|
||||
package translations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"bunker/client/internal/langfiles"
|
||||
)
|
||||
|
||||
func (t *translations) initializeSysInfoHandler() error {
|
||||
if err := t.mainWindow.RegisterAboutWindowSysInfoHandler(
|
||||
"translations_languages_and_strings_count",
|
||||
t.sysInfoHandlerTranslationsLanguagesAndStringsCount,
|
||||
); err != nil {
|
||||
return fmt.Errorf("register translations_languages_and_strings_count handler: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *translations) sysInfoHandlerTranslationsLanguagesAndStringsCount() string {
|
||||
langString := "- Error occurred while getting translations languages and strings counts. See console log for details."
|
||||
|
||||
// Эту всю информацию надо получать напрямую из файлов, пакет Fyne не предоставляет нужную информацию.
|
||||
languages := make([]string, 0)
|
||||
translationsStringsCount := 0
|
||||
|
||||
entries, err := langfiles.LangFiles.ReadDir("files")
|
||||
if err != nil {
|
||||
slog.Error("Failed to read translations filesystem entries.", "error", err.Error())
|
||||
|
||||
return langString
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(entry.Name(), ".json") {
|
||||
fileData, err := langfiles.LangFiles.ReadFile(filepath.Join("files", entry.Name()))
|
||||
if err != nil {
|
||||
slog.Error("Failed to read translation file!", "file", entry.Name(), "error", err.Error())
|
||||
|
||||
return langString
|
||||
}
|
||||
|
||||
data := make(map[string]string)
|
||||
|
||||
if err := json.Unmarshal(fileData, &data); err != nil {
|
||||
slog.Error("Failed to unmarshal translation file!", "file", entry.Name(), "error", err.Error())
|
||||
|
||||
return langString
|
||||
}
|
||||
|
||||
_, fileName := filepath.Split(entry.Name())
|
||||
langName := strings.Split(fileName, ".")[0]
|
||||
|
||||
languages = append(languages, langName)
|
||||
translationsStringsCount += len(data)
|
||||
}
|
||||
}
|
||||
|
||||
langString = fmt.Sprintf(
|
||||
"### Translations\n\n- Languages: %d\n- Translations strings (in total): %d\n",
|
||||
len(languages),
|
||||
translationsStringsCount,
|
||||
)
|
||||
|
||||
return langString
|
||||
}
|
81
client/internal/services/core/translations/translations.go
Normal file
81
client/internal/services/core/translations/translations.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package translations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"bunker/client/internal/application"
|
||||
"bunker/client/internal/langfiles"
|
||||
"bunker/client/internal/services/core"
|
||||
|
||||
"fyne.io/fyne/v2/lang"
|
||||
)
|
||||
|
||||
var _ = core.Translations(&translations{})
|
||||
|
||||
type translations struct {
|
||||
app *application.Application
|
||||
mainWindow core.MainWindow
|
||||
}
|
||||
|
||||
// Initialize инициализирует сервис.
|
||||
func Initialize(app *application.Application) error {
|
||||
transl := &translations{
|
||||
app: app,
|
||||
}
|
||||
|
||||
if err := app.RegisterService(transl); err != nil {
|
||||
return fmt.Errorf("%w: %w", core.ErrOptions, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *translations) Configure() error {
|
||||
if err := t.initializeSysInfoHandler(); err != nil {
|
||||
return fmt.Errorf("configure: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *translations) ConnectDependencies() error {
|
||||
mainWindowRaw := t.app.Service(core.ServiceNameMainWindow)
|
||||
if mainWindowRaw == nil {
|
||||
return fmt.Errorf("connect dependencies: get main window: %w", application.ErrServiceNotFound)
|
||||
}
|
||||
|
||||
mainWindow, valid := mainWindowRaw.(core.MainWindow)
|
||||
if !valid {
|
||||
return fmt.Errorf("connect dependencies: type assert main window: %w", core.ErrMainWindowIsInvalid)
|
||||
}
|
||||
|
||||
t.mainWindow = mainWindow
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *translations) Initialize() error {
|
||||
langFromEnv, _ := os.LookupEnv("LANG")
|
||||
|
||||
slog.Info("Current system locale.", "locale", lang.SystemLocale().String(), "LANG", langFromEnv)
|
||||
|
||||
if err := lang.AddTranslationsFS(langfiles.LangFiles, "files"); err != nil {
|
||||
return fmt.Errorf("%w: load translations: %w", core.ErrTranslations, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *translations) Name() string {
|
||||
return core.ServiceNameTranslations
|
||||
}
|
||||
|
||||
func (t *translations) LaunchStartupTasks() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *translations) Shutdown() error {
|
||||
return nil
|
||||
}
|
Reference in New Issue
Block a user