The very basic client app, not adapted for mobiles.
Some checks failed
Linting and tests / Linting (push) Failing after 5s
Linting and tests / Tests (push) Failing after 4s

This commit is contained in:
Stanislav Nikitin 2025-09-10 19:34:49 +05:00
parent b65b8a9e72
commit e3b9c9ae40
Signed by: pztrn
GPG Key ID: 1E944A0F0568B550
50 changed files with 2816 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*DS_Store*
fyne-cross
.build

6
FyneApp.toml Normal file
View File

@ -0,0 +1,6 @@
Website = "https://bunker.pztrn.name"
[Details]
Icon = "Icon.png"
Name = "pztrn's Bunker"
ID = "name.pztrn.bunker.client"

BIN
Icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

29
Taskfile.yml Normal file
View File

@ -0,0 +1,29 @@
---
version: "3"
vars:
VERSION:
sh: scripts/version_generator.sh
CLIENT_VERSION:
sh: scripts/version_generator.sh client
BRANCH:
sh: git rev-parse --abbrev-ref HEAD
COMMIT:
sh: git rev-parse --short HEAD
BUILD:
sh: git rev-list --count HEAD
BUILD_DATE:
sh: TZ=UTC date +'%Y-%m-%d %H:%M:%S %Z'
BASIC_LDFLAGS: "-X 'bunker/commons/constants.Version={{.VERSION}}' -X 'bunker/commons/constants.Branch={{.BRANCH}}' -X 'bunker/commons/constants.Commit={{.COMMIT}}' -X 'bunker/commons/constants.Build={{.BUILD}}' -X 'bunker/commons/constants.BuildDate={{.BUILD_DATE}}'"
env:
GOFLAGS: "-trimpath"
includes:
client: ./client
tasks:
ensure-builddir:
internal: true
cmds:
- mkdir -p .build

6
client/Taskfile.yml Normal file
View File

@ -0,0 +1,6 @@
---
version: "3"
includes:
client: ./cmd/client
translations_checker: ./cmd/translations_checker

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

View 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

View 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")
}
}

View 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
}

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

View 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
}

View 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")
}

View 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"
}

View 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"
}

View File

@ -0,0 +1,8 @@
package langfiles
import "embed"
// LangFiles это встроенная ФС, содержащая переводы приложения.
//
//go:embed files
var LangFiles embed.FS

View 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

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

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

View 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
}

View 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

View 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()
}

View File

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

View File

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

View File

@ -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
}

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

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

View File

@ -0,0 +1,7 @@
package models
// SysInfoHandler содержит в себе информацию об обработчике для Markdown системной информации.
type SysInfoHandler struct {
Handler func() string
Name string
}

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

View 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()
}

View 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()
}

View 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

View 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
}

View File

@ -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;

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

View 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
}

View 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
}

View 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{}

View File

@ -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
}

View 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
}

8
commons/appids.go Normal file
View File

@ -0,0 +1,8 @@
package commons
var (
// ClientAppID это идентификатор для клиента.
ClientAppID = "name.pztrn.bunker.client"
// ServerAppID это идентификатор для сервера.
ServerAppID = "name.pztrn.bunker.server"
)

6
commons/exit_codes.go Normal file
View File

@ -0,0 +1,6 @@
package commons
const (
// ExitCodeAppStopFailed указывает на ошибку остановки работы приложения.
ExitCodeAppStopFailed = 2
)

14
commons/version.go Normal file
View File

@ -0,0 +1,14 @@
package commons
var (
// Branch это ветка, из которой собрано приложение.
Branch string
// Build это порядковый номер сборки.
Build string
// BuildDate это таймштамп сборки приложения.
BuildDate string
// Commit это коммит, из которого собрано приложение.
Commit string
// Version это версия, из которой собрано приложение.
Version string
)

66
go.mod Normal file
View File

@ -0,0 +1,66 @@
module bunker
go 1.24.0
require (
fyne.io/fyne/v2 v2.6.3
github.com/jmoiron/sqlx v1.4.0
github.com/pressly/goose/v3 v3.25.0
github.com/shirou/gopsutil/v3 v3.24.5
modernc.org/sqlite v1.38.2
)
require (
fyne.io/systray v1.11.0 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.0 // indirect
github.com/fyne-io/glfw-js v0.3.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.1.0 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rymdport/portal v0.4.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.11.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

170
go.sum Normal file
View File

@ -0,0 +1,170 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
fyne.io/fyne/v2 v2.6.3 h1:cvtM2KHeRuH+WhtHiA63z5wJVBkQ9+Ay0UMl9PxFHyA=
fyne.io/fyne/v2 v2.6.3/go.mod h1:NGSurpRElVoI1G3h+ab2df3O5KLGh1CGbsMMcX0bPIs=
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

33
scripts/version_generator.sh Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Generates version based on git tag.
LATEST_TAG=$(git tag | tail -n 1)
# Check latest tag commit. If it is equal to current - use that tag as version.
if [ "${LATEST_TAG}" != "" ]; then
LATEST_TAG_COMMIT=$(git rev-list -n 1 $LATEST_TAG)
LATEST_COMMIT=$(git rev-list -n 1 HEAD)
if [ "${LATEST_TAG_COMMIT}" == "${LATEST_COMMIT}" ]; then
echo "${LATEST_TAG}"
exit 0
fi
else
# No tags in repo. Assuming latest tag is "v0.0.0".
LATEST_TAG="0.0.0"
fi
# If we're here, then latest tag commit is not latest commit on current branch.
# We should increase second number and add "-dev" postfix.
IFS=. read MAJOR MINOR PATCH <<<"${LATEST_TAG}"
MINOR=$(($MINOR + 1))
case $1 in
client)
echo "${MAJOR}.${MINOR}.${PATCH}"
;;
*)
echo "v${MAJOR}.${MINOR}.${PATCH}-dev"
;;
esac