Basic client and server apps. #41

Open
pztrn wants to merge 32 commits from 2-client-basics into feature/1-basics
103 changed files with 5075 additions and 10 deletions

View File

@ -7,20 +7,33 @@ on:
jobs:
Linting:
runs-on: "ubuntu-22.04"
container:
volumes:
- "bunker-go-cache:/home/container"
steps:
- name: "checkout source"
uses: actions/checkout@v4
- name: "lint"
uses: docker://code.pztrn.name/containers/go-toolbox:v5
uses: docker://code.pztrn.name/containers/go-toolbox:v8
with:
command: "/usr/local/bin/task lint"
Tests:
runs-on: "ubuntu-22.04"
steps:
- name: "checkout source"
uses: actions/checkout@v4
- name: "tests"
uses: docker://code.pztrn.name/containers/go-toolbox:v5
with:
command: "/usr/local/bin/task test"
# Tests:
# runs-on: "ubuntu-22.04"
# env:
# GOCACHE: /home/container/go/buildcache
# GOMODCACHE: /home/container/go/pkg
# steps:
# - name: "checkout source"
# uses: actions/checkout@v4
# - name: "cache go dependencies"
# uses: actions/cache@v4
# with:
# path: |
# /home/container/go
# /home/container/golangci-lint
# key: bunker-go-cache
# - name: "tests"
# uses: docker://code.pztrn.name/containers/go-toolbox:v8
# with:
# command: "/usr/local/bin/task test"

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
*DS_Store*
fyne-cross
_build
*.apk
*.app
dist
.task

93
.golangci.yml Normal file
View File

@ -0,0 +1,93 @@
---
version: "2"
linters:
default: all
disable:
- containedctx
- depguard
- exhaustruct
- gochecknoglobals
- interfacebloat
- ireturn
- mnd
- testpackage
- tparallel
- unused
- varnamelen
- noinlineerr
- wsl
settings:
cyclop:
max-complexity: 30
package-average: 30
forbidigo:
forbid:
- pattern: ^(fmt\.Print(|f|ln)|print|println)$
- pattern: ^time\.Now\(\)($|\.F|\.A|\.B|\.L|\.UTC\(\)\.I|,|\))(# Calls of time\.Now() without \.UTC() is prohibited\.)?
funlen:
lines: 200
statements: 60
ignore-comments: true
gocyclo:
min-complexity: 20
govet:
enable-all: true
funcorder:
constructor: true
struct-method: false
alphabetical: true
lll:
line-length: 120
nestif:
min-complexity: 20
tagliatelle:
case:
rules:
json: snake
yaml: camel
use-field-name: true
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
exclusions:
generated: lax
rules:
- linters:
- gosec
path: .+_test\.go
- linters:
- godox
text: TODO
- linters:
- govet
text: declaration of "err" shadows
- path: (.+)\.go$
text: ST1000
- path: (.+)\.go$
text: package-comments
- linters:
- cyclop
path: (.+)_test\.go
paths:
- third_party$
- builtin$
- examples$
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofmt
- gofumpt
settings:
gofumpt:
module-path: "bunker"
extra-rules: true
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

26
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
// Используйте IntelliSense, чтобы узнать о возможных атрибутах.
// Наведите указатель мыши, чтобы просмотреть описания существующих атрибутов.
// Для получения дополнительной информации посетите: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "bunkerd docker debug",
"type": "go",
"debugAdapter": "dlv-dap",
"request": "attach",
"mode": "remote",
"port": 54401,
"host": "127.0.0.1",
"stopOnEntry": true,
"substitutePath": [
{
"from": "${workspaceFolder}",
"to": "/bunkerd/"
}
],
"preLaunchTask": "Run bunkerd debug build",
"postDebugTask": "Stop bunkerd debug build"
}
]
}

31
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,31 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run bunkerd debug build",
"type": "shell",
"command": "task server:localdev:bunkerd:down; task server:localdev:bunkerd:up-debug",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
// Do not open terminal every time debug is launched.
"reveal": "silent"
}
},
{
"label": "Stop bunkerd debug build",
"type": "shell",
"command": "task server:localdev:bunkerd:down-debug",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
// Do not open terminal every time debug is launched.
"reveal": "silent"
}
}
]
}

7
FyneApp.toml Normal file
View File

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

BIN
Icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

45
Taskfile.yml Normal file
View File

@ -0,0 +1,45 @@
---
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.Version={{.VERSION}}' -X 'bunker/commons.Branch={{.BRANCH}}' -X 'bunker/commons.Commit={{.COMMIT}}' -X 'bunker/commons.Build={{.BUILD}}' -X 'bunker/commons.BuildDate={{.BUILD_DATE}}'"
env:
GOFLAGS: "-trimpath"
includes:
client: ./client
server: ./server
tasks:
cleanup:
desc: "Cleanup _build directory."
cmds:
- task: server:cmd:bunkerd:cleanup
ensure-builddir:
internal: true
cmds:
- mkdir -p _build
lint:
desc: "Lints whole workspace."
cmds:
- golangci-lint run
test:
desc: "Test whole workspace"
cmds:
- go test -test.v ./...

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,114 @@
---
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-dist:
desc: "Builds windows/amd64, linux/amd64, darwin/arm64, android/arm64 distribution archives."
cmds:
- task: build-dist-android-arm64
- task: build-dist-darwin
- task: build-dist-linux-amd64
- task: build-dist-windows-amd64
build-dist-android-arm64:
desc: "Build android/arm64 APK file."
dir: ./client/cmd/client
cmds:
- task: ensure-dist-dir
#- fyne-cross android -app-version="{{ .CLIENT_VERSION }}" -app-build="{{ .BUILD }}" -arch=arm64 -pull client/cmd/client
- fyne package --release --target=android/arm64 --app-id name.pztrn.bunker.client -icon ../../../Icon.png --name "pztrn's Bunker" --app-version "{{ .CLIENT_VERSION }}" --app-build "{{ .BUILD }}" {{ .MetadataParams }}
- mv pztrn_s_Bunker.apk "../../../dist/pztrn's Bunker-{{ .CLIENT_VERSION }}-{{ .BUILD }}-android-arm64.apk"
build-dist-darwin:
desc: "Build darwin (macOS) version."
dir: ./client/cmd/client
cmds:
- task: ensure-dist-dir
#- fyne-cross darwin -app-version="{{ .CLIENT_VERSION }}" -app-build="{{ .BUILD }}" -arch=arm64 -category=6007 -pull {{ .MetadataParams }} client/cmd/client
- fyne package --release --target=darwin --app-id name.pztrn.bunker.client -executable name.pztrn.bunker.client -icon ../../../Icon.png --name "pztrn's Bunker" --app-version "{{ .CLIENT_VERSION }}" --app-build "{{ .BUILD }}" {{ .MetadataParams }}
- tar -czf "pztrn's Bunker-{{ .CLIENT_VERSION }}-{{ .BUILD }}-darwin-arm64.tar.gz" *.app
- mv *.tar.gz ../../../dist/
build-dist-ios:
desc: "Build iOS version."
dir: ./client/cmd/client
cmds:
- task: ensure-dist-dir
- fyne package --release --target=ios --app-id name.pztrn.bunker.client -icon ../../../Icon.png --name "pztrn's Bunker" --app-version "{{ .CLIENT_VERSION }}" --app-build "{{ .BUILD }}" {{ .MetadataParams }}
build-dist-linux-amd64:
desc: "Build linux/amd64 using fyne-cross"
cmds:
- task: ensure-dist-dir
- fyne-cross linux -app-version="{{ .CLIENT_VERSION }}" -app-build="{{ .BUILD }}" -arch=amd64 -pull {{ .MetadataParams }} client/cmd/client
- mv "fyne-cross/dist/linux-amd64/pztrn's Bunker.tar.xz" "dist/pztrn's Bunker-{{ .CLIENT_VERSION }}-{{ .BUILD }}-linux-amd64.tar.xz"
build-dist-windows-amd64:
desc: "Build windows/amd64 using fyne-cross"
cmds:
- task: ensure-dist-dir
- fyne-cross windows -app-version="{{ .CLIENT_VERSION }}" -app-build="{{ .BUILD }}" -arch=amd64 -pull {{ .MetadataParams }} client/cmd/client
- mv "fyne-cross/dist/windows-amd64/pztrn's Bunker.exe.zip" "dist/pztrn's Bunker-{{ .CLIENT_VERSION }}-{{ .BUILD }}-windows-amd64.zip"
cleanup:
desc: "Cleanup build environment."
cmds:
- rm _build/{{ .ClientBinary }}
ignore_error: true
ensure-dist-dir:
desc: "Ensures dist directory in repo root exists."
internal: true
cmds:
- mkdir dist || exit 0
run:
desc: "Launch client."
cmds:
- task: build
- _build/{{ .ClientBinary }}
run-debug:
desc: "Launch client in debug mode."
cmds:
- task: build-debug
- _build/{{ .ClientBinary }}

78
client/cmd/client/main.go Normal file
View File

@ -0,0 +1,78 @@
package main
import (
"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/client/internal/services/features/accounts"
"bunker/client/internal/services/features/tasks"
"bunker/commons"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func main() {
app := application.New()
lgr := app.NewLogger("module", "main")
lgr.Info(
"Starting Bunker client...",
"version", app.Fyne().Metadata().Custom["Version"],
"build_no", app.Fyne().Metadata().Custom["Build"],
"commit", app.Fyne().Metadata().Custom["Commit"],
"branch", app.Fyne().Metadata().Custom["Branch"],
)
checkError(translations.Initialize(app))
checkError(database.Initialize(app))
checkError(options.Initialize(app))
checkError(mainwindow.Initialize(app))
checkError(accounts.Initialize(app))
checkError(tasks.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,129 @@
//nolint:gosec
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,223 @@
package application
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"time"
"bunker/commons"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
)
var (
errApplication = errors.New("application")
errNoMainWindow = errors.New("no main window service registered")
)
// Application is a lifecycle controlling structure for application.
type Application struct {
ctx context.Context
cancelFunc context.CancelFunc
fyneApp fyne.App
baseLogger *slog.Logger
appLogger *slog.Logger
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
}
a.appLogger.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
}
a.appLogger.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
}
a.appLogger.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
}
a.appLogger.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(a.ctx, 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.ctx, a.cancelFunc = context.WithCancel(context.Background())
a.initializeLogger()
a.fyneApp = app.NewWithID(commons.ClientAppID)
a.services = make([]Service, 0)
}
func (a *Application) launchStartupTasks() error {
for _, service := range a.services {
if strings.Contains(service.Name(), "mainwindow") {
continue
}
if !strings.Contains(service.Name(), "core/") {
continue
}
a.appLogger.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
}
a.appLogger.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 {
a.appLogger.Info("Stopping pztrn's Bunker...")
for _, service := range a.services {
if !strings.Contains(service.Name(), "features/") {
continue
}
a.appLogger.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
}
a.appLogger.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 starts application.
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,10 @@
package application
import (
"fyne.io/fyne/v2"
)
// Fyne returns Fyne instance.
func (a *Application) Fyne() fyne.App {
return a.fyneApp
}

View File

@ -0,0 +1,20 @@
package application
import (
"log/slog"
"os"
)
func (a *Application) initializeLogger() {
a.baseLogger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelDebug,
}))
a.appLogger = a.baseLogger.With("module", "application")
}
// NewLogger creates new sub-instance of base logger and adds some additional data to it for persistent output.
func (a *Application) NewLogger(withs ...interface{}) *slog.Logger {
return a.baseLogger.With(withs...)
}

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 necessary 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 "runtime"
// IsMobile returns true if current platform related to mobile devices (phones, tablets).
func IsMobile() bool {
switch runtime.GOOS {
case "android", "ios":
return true
default:
return false
}
}

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,118 @@
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
logger *slog.Logger
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.ErrDatabase, err)
}
return nil
}
func (d *database) Configure() error {
d.configureDBPath()
db, err := sqlx.Open("sqlite", d.dbPath)
if err != nil {
return fmt.Errorf("configure: open database: %w", err)
}
d.db = db
d.logger.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.ErrDatabaseIsInvalid)
}
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.logger = d.app.NewLogger("service", core.ServiceNameDatabase)
d.logger.Info("Initializing...")
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,11 @@
package database
import (
"path/filepath"
)
func (d *database) configureDBPath() {
d.dbPath = filepath.Join(d.app.Fyne().Storage().RootURI().Path(), "database.sqlite3")
d.logger.Info("Database path configured.", "path", d.dbPath)
}

View File

@ -0,0 +1,82 @@
package database
import (
"errors"
"fmt"
"io/fs"
"log/slog"
"sort"
"strings"
"bunker/client/internal/services/core"
"bunker/commons"
"github.com/pressly/goose/v3"
)
var errMigrationsAlreadyRegistered = errors.New("migrations already registered")
func (d *database) applyMigrations() error {
d.logger.Info("Migrating database...")
modules := make([]string, 0)
for module := range d.migrations {
modules = append(modules, module)
}
sort.Strings(modules)
_ = goose.SetDialect(string(goose.DialectSQLite3))
gooseLogger := commons.NewGooseLogger(d.logger)
goose.SetLogger(gooseLogger)
for _, module := range modules {
d.logger.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
d.logger.Info(
"Database for module migrated to latest version",
"module", module,
"module_db_version", moduleDBVersion,
"db_version", d.version,
)
}
d.logger.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,69 @@
package database
import (
"context"
"fmt"
"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)
}
d.logger.Debug("Executing query.", "query", query, "params", fmt.Sprintf("%+v", params))
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)
}
d.logger.Debug("Getting single data from database with query.", "query", query, "params", fmt.Sprintf("%+v", params))
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)
}
d.logger.Debug("Executing named query.", "query", query, "params", fmt.Sprintf("%+v", param))
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)
}
d.logger.Debug("Selecting from database with query.", "query", query, "params", fmt.Sprintf("%+v", params))
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,81 @@
package database
import (
"context"
"fmt"
"log/slog"
"bunker/client/internal/services/core"
"github.com/jmoiron/sqlx"
)
type transaction struct {
transaction *sqlx.Tx
logger *slog.Logger
}
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,
logger: d.logger.With("module", "transactioner"),
}
return txHandler, nil
}
func (t *transaction) Apply(steps ...core.TransactionFunc) error {
for stepNumber, stepFunc := range steps {
if err := stepFunc(t.transaction); err != nil {
t.logger.Error(
"Error occurred.",
"step", stepNumber,
"error", err.Error(),
"module", "core/database",
"subsystem", "transaction",
)
if rollbackErr := t.transaction.Rollback(); rollbackErr != nil {
t.logger.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 {
t.logger.Error(
"Transaction commit failed.",
"error", err.Error(),
"module", "core/database",
"subsystem", "transaction",
)
if rollbackErr := t.transaction.Rollback(); rollbackErr != nil {
t.logger.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,40 @@
package core
import (
"errors"
"bunker/client/internal/services/core/mainwindow/dto"
"fyne.io/fyne/v2"
)
// 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 *dto.Tab)
// 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
// SetStatusProgressBarCurrentValue sets current value for progressbar in status bar.
SetStatusProgressBarCurrentValue(current float64)
// SetStatusProgressBarMaxValue sets maximum value for progressbar in status bar.
SetStatusProgressBarMaxValue(current float64)
// SetStatus sets text in status bar. If non-empty text is passed - then progress bar is also shown, and hidden
// if passed text is empty.
SetStatus(status string)
}
// 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 /= 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 /= 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,20 @@
package dto
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
)
// Tab is a DTO of main window's tab that is responsible for showing content.
type Tab struct {
// Name is a name for tab. Won't render by default on desktop, only on mouse hover, but will be rendered on mobiles.
Name string
// Sidebar is a sidebar widget. On desktop in will be shown on left side of window, on mobiles as separate window.
Sidebar fyne.CanvasObject
// Widget is a widget shown in window.
Widget fyne.CanvasObject
// Icon is an icon to show on tab.
Icon canvas.Image
// BadgeCount is a number to show on tab, like unread messages, incompleted tasks, etc.
BadgeCount uint16
}

View File

@ -0,0 +1,117 @@
package mainwindow
import (
"fmt"
"log/slog"
"os"
"bunker/client/internal/application"
"bunker/client/internal/helpers"
"bunker/client/internal/services/core"
"bunker/client/internal/services/core/mainwindow/models"
"bunker/commons"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/widget"
)
var _ = core.MainWindow(&mainWindow{})
type mainWindow struct {
app *application.Application
logger *slog.Logger
window fyne.Window
options core.Options
tabsWidget *fyne.Container
statusBarProgress *widget.ProgressBar
statusBarStatus *widget.Label
sysInfoHandlers map[string]*models.SysInfoHandler
tabs []*models.Tab
}
// Initialize initializes service.
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.logger = m.app.NewLogger("service", core.ServiceNameMainWindow)
m.logger.Info("Initializing...")
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(1100, 800))
var mainWindowCanvas fyne.CanvasObject
if helpers.IsMobile() {
mainWindowCanvas = m.initializeMainWindowMobile()
} else {
mainWindowCanvas = m.initializeMainWindowDesktop()
}
m.window.SetContent(mainWindowCanvas)
m.window.SetCloseIntercept(m.stopApp)
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,23 @@
package mainwindow
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func (m *mainWindow) initializeMainWindowDesktop() fyne.CanvasObject {
switcherButton := m.initializeSwitcherDesktop()
appNameLabel := widget.NewLabel("Bunker " + m.app.Fyne().Metadata().Custom["Version"])
sidebarHeader := container.NewVBox(container.NewHBox(switcherButton, appNameLabel), widget.NewSeparator())
sideBar := container.NewBorder(sidebarHeader, nil, nil, nil)
splitter := container.NewHSplit(sideBar, container.NewVBox(widget.NewLabel("widget data"), widget.NewSeparator()))
splitter.SetOffset(0.2)
statusBar := m.initializeDesktopStatusBar()
mainWidget := container.NewBorder(nil, statusBar, nil, nil, splitter)
return mainWidget
}

View File

@ -0,0 +1,42 @@
package mainwindow
import (
"bunker/client/internal/widgets"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
)
func (m *mainWindow) initializeDesktopStatusBar() fyne.CanvasObject {
m.statusBarStatus = widget.NewLabel("Ready.")
m.statusBarProgress = widget.NewProgressBar()
m.statusBarProgress.Hide()
statusBarForToolbar := widgets.NewToolbarProgressBarWithBar(m.statusBarProgress)
statusBar := widget.NewToolbar()
statusBar.Append(widgets.NewToolbarLabelWithLabel(m.statusBarStatus))
statusBar.Append(statusBarForToolbar)
statusBar.Append(widget.NewToolbarSpacer())
return statusBar
}
func (m *mainWindow) SetStatusProgressBarCurrentValue(current float64) {
m.statusBarProgress.SetValue(current)
}
func (m *mainWindow) SetStatusProgressBarMaxValue(maxValue float64) {
m.statusBarProgress.Max = maxValue
}
func (m *mainWindow) SetStatus(status string) {
m.statusBarStatus.SetText(status)
if status == "" {
m.statusBarStatus.SetText("Ready.")
m.statusBarProgress.Hide()
} else {
m.statusBarProgress.Show()
}
}

View File

@ -0,0 +1,33 @@
package mainwindow
import (
"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() {
m.logger.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,10 @@
package mainwindow
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
)
func (m *mainWindow) initializeMainWindowMobile() fyne.CanvasObject {
return widget.NewLabel("Mobile interface not yet implemented.")
}

View File

@ -0,0 +1,26 @@
package mainwindow
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
func (m *mainWindow) initializeSwitcherDesktop() fyne.CanvasObject {
m.logger.Debug("Initializing desktop switcher...")
btn := widget.NewButtonWithIcon(
"",
m.app.Fyne().Settings().Theme().Icon(theme.IconNameMenu),
m.desktopSwitcherButtonTapped,
)
return btn
}
func (m *mainWindow) desktopSwitcherButtonTapped() {
m.logger.Debug("Showing desktop switcher...")
popup := widget.NewPopUp(widget.NewLabel("All hail switcher!"), m.window.Canvas())
popup.ShowAtRelativePosition(fyne.NewPos(0, 0), m.window.Content())
}

View File

@ -0,0 +1,5 @@
package mainwindow
func (m *mainWindow) initializeSwitcherMobile() {
m.logger.Debug("Initializing mobile switcher...")
}

View File

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

View File

@ -0,0 +1,18 @@
package models
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
)
// Tab is an internal representation of main window's tab that is responsible for showing content.
type Tab struct {
// Name is a name for tab. Won't render by default on desktop, only on mouse hover, but will be rendered on mobiles.
Name string
// Widget is a widget shown in window.
Widget fyne.CanvasObject
// Icon is an icon to show on tab.
Icon canvas.Image
// BadgeCount is a number to show on tab, like unread messages, incompleted tasks, etc.
BadgeCount uint16
}

View File

@ -0,0 +1,8 @@
package mainwindow
import (
"bunker/client/internal/services/core/mainwindow/dto"
)
func (m *mainWindow) AddTab(_ *dto.Tab) {
}

View File

@ -0,0 +1,21 @@
package core
import (
"errors"
"bunker/client/internal/services/core/options/dto"
)
// ServiceNameOptions is a name for options service which controls options dialog and options storage.
const ServiceNameOptions = "core/options"
// ErrOptionsIsInvalid appears when options service implementation is invalid.
var ErrOptionsIsInvalid = errors.New("options service implementation is invalid")
// Options is an interface for options service.
type Options interface {
// RegisterOptionsWidget registers options widget for options dialog.
RegisterOptionsWidget(widgetData *dto.OptionPane) error
// ShowOptionsDialog shows options dialog.
ShowOptionsDialog()
}

View File

@ -0,0 +1,80 @@
package options
import (
"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) {
o.logger.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 {
o.logger.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,99 @@
package options
import (
"errors"
"fmt"
"log/slog"
"bunker/client/internal/application"
"bunker/client/internal/services/core"
"bunker/client/internal/services/core/options/models"
)
var (
_ = core.Options(&options{})
errOptions = errors.New("options core service")
)
type options struct {
app *application.Application
logger *slog.Logger
db core.Database
mainWindow core.MainWindow
widgets map[string]*models.OptionPane
widgetsItems []string // for Fyne's list widget.
}
// Initialize initializes service.
func Initialize(app *application.Application) error {
opts := &options{
app: app,
}
if err := app.RegisterService(opts); err != nil {
return fmt.Errorf("%w: %w", 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.logger = o.app.NewLogger("service", core.ServiceNameOptions)
o.logger.Info("Initializing...")
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,27 @@
package options
import (
"errors"
"fmt"
"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",
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,14 @@
package core
import (
"errors"
)
// ServiceNameTranslations is a name for translations service.
const ServiceNameTranslations = "core/translations"
// ErrTranslationsIsInvalid appears when translations service implementation is invalid.
var ErrTranslationsIsInvalid = errors.New("translations service implementation is invalid")
// Translations is an interface for translations service.
type Translations interface{}

View File

@ -0,0 +1,73 @@
package translations
import (
"encoding/json"
"fmt"
"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 {
t.logger.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 {
t.logger.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 {
t.logger.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,91 @@
package translations
import (
"errors"
"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{})
errTranslations = errors.New("translations core service")
)
type translations struct {
app *application.Application
logger *slog.Logger
mainWindow core.MainWindow
}
// Initialize initializes service.
func Initialize(app *application.Application) error {
transl := &translations{
app: app,
}
if err := app.RegisterService(transl); err != nil {
return fmt.Errorf("%w: %w", errTranslations, 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 {
t.logger = t.app.NewLogger("service", core.ServiceNameTranslations)
t.logger.Info("Initializing...")
langFromEnv, _ := os.LookupEnv("LANG")
t.logger.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", 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
}

View File

@ -0,0 +1,4 @@
package features
// ServiceNameAccounts is a name for accounts service.
const ServiceNameAccounts = "features/accounts"

View File

@ -0,0 +1,93 @@
package accounts
import (
"errors"
"fmt"
"log/slog"
"bunker/client/internal/application"
"bunker/client/internal/services/core"
"bunker/client/internal/services/features"
"fyne.io/fyne/v2/widget"
)
var errAccounts = errors.New("accounts feature service")
type accounts struct {
app *application.Application
logger *slog.Logger
db core.Database
mainWindow core.MainWindow
loginDialogInstanceAddressEntry *widget.Entry
loginDialogUsernameEntry *widget.Entry
loginDialogPasswordEntry *widget.Entry
}
// Initialize initializes service.
func Initialize(app *application.Application) error {
accts := &accounts{
app: app,
}
if err := app.RegisterService(accts); err != nil {
return fmt.Errorf("%w: %w", errAccounts, err)
}
return nil
}
func (a *accounts) Configure() error {
return nil
}
func (a *accounts) ConnectDependencies() error {
databaseRaw := a.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)
}
a.db = database
mainWindowRaw := a.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)
}
a.mainWindow = mainWindow
return nil
}
func (a *accounts) Initialize() error {
a.logger = a.app.NewLogger("service", features.ServiceNameTasks)
a.logger.Info("Initializing...")
return nil
}
func (a *accounts) Name() string {
return features.ServiceNameTasks
}
func (a *accounts) LaunchStartupTasks() error {
a.loginDialogShow()
return nil
}
func (a *accounts) Shutdown() error {
return nil
}

View File

@ -0,0 +1,53 @@
package accounts
import (
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
func (a *accounts) loginDialogLogin() {
a.logger.Info(
"Trying to log in...",
"instance", a.loginDialogInstanceAddressEntry.Text,
"username", a.loginDialogUsernameEntry.Text,
"password", a.loginDialogPasswordEntry.Text,
)
}
func (a *accounts) loginDialogShow() {
if a.loginDialogInstanceAddressEntry == nil {
a.loginDialogInstanceAddressEntry = widget.NewEntry()
a.loginDialogInstanceAddressEntry.SetText("http://localhost:53400")
}
if a.loginDialogUsernameEntry == nil {
a.loginDialogUsernameEntry = widget.NewEntry()
a.loginDialogUsernameEntry.SetPlaceHolder("username")
}
if a.loginDialogPasswordEntry == nil {
a.loginDialogPasswordEntry = widget.NewEntry()
} else {
a.loginDialogPasswordEntry.SetText("")
}
loginForm := widget.NewForm(
widget.NewFormItem("Instance address:", a.loginDialogInstanceAddressEntry),
widget.NewFormItem("Login:", a.loginDialogUsernameEntry),
widget.NewFormItem("Password:", a.loginDialogPasswordEntry),
)
loginButton := widget.NewButton("Log in", a.loginDialogLogin)
loginDialogContent := container.NewBorder(nil, loginButton, nil, nil, loginForm)
dialog := dialog.NewCustomWithoutButtons(
"Login to Bunker instance",
loginDialogContent,
a.mainWindow.MainWindow(),
)
dialog.Resize(dialog.MinSize().AddWidthHeight(200, 0))
dialog.Show()
}

View File

@ -0,0 +1,4 @@
package features
// ServiceNameTasks is a name for tasks service.
const ServiceNameTasks = "features/tasks"

View File

@ -0,0 +1,85 @@
package tasks
import (
"errors"
"fmt"
"log/slog"
"bunker/client/internal/application"
"bunker/client/internal/services/core"
"bunker/client/internal/services/features"
)
var errTasks = errors.New("tasks feature service")
type tasks struct {
app *application.Application
logger *slog.Logger
db core.Database
mainWindow core.MainWindow
}
// Initialize initializes service.
func Initialize(app *application.Application) error {
tsks := &tasks{
app: app,
}
if err := app.RegisterService(tsks); err != nil {
return fmt.Errorf("%w: %w", errTasks, err)
}
return nil
}
func (t *tasks) Configure() error {
return nil
}
func (t *tasks) ConnectDependencies() error {
databaseRaw := t.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)
}
t.db = database
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 *tasks) Initialize() error {
t.logger = t.app.NewLogger("service", features.ServiceNameTasks)
t.logger.Info("Initializing...")
return nil
}
func (t *tasks) Name() string {
return features.ServiceNameAccounts
}
func (t *tasks) LaunchStartupTasks() error {
return nil
}
func (t *tasks) Shutdown() error {
return nil
}

View File

@ -0,0 +1,21 @@
package widgets
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
)
// ToolbarLabel is a label widget for toolbar.
type ToolbarLabel struct {
*widget.Label
}
// NewToolbarLabelWithLabel creates new toolbar label with passed label as base widget.
func NewToolbarLabelWithLabel(label *widget.Label) widget.ToolbarItem {
return &ToolbarLabel{label}
}
// ToolbarObject returns toolbar item.
func (tl *ToolbarLabel) ToolbarObject() fyne.CanvasObject {
return tl.Label
}

View File

@ -0,0 +1,23 @@
package widgets
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
)
// ToolbarProgressBar is a progressbar widget for toolbar.
type ToolbarProgressBar struct {
*widget.ProgressBar
}
// NewToolbarProgressBarWithBar creates new progressbar for toolbar with provided progressbar.
func NewToolbarProgressBarWithBar(bar *widget.ProgressBar) *ToolbarProgressBar {
return &ToolbarProgressBar{
bar,
}
}
// ToolbarObject returns toolbar item.
func (tl *ToolbarProgressBar) ToolbarObject() fyne.CanvasObject {
return tl
}

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
)

28
commons/goose_logger.go Normal file
View File

@ -0,0 +1,28 @@
package commons
import (
"fmt"
"log/slog"
)
// GooseLogger is a proxy struct that wraps Bunker logging for goose database migrator.
type GooseLogger struct {
logger *slog.Logger
}
// NewGooseLogger creates proxy structure for goose database migrator logging.
func NewGooseLogger(logger *slog.Logger) *GooseLogger {
return &GooseLogger{
logger: logger.With("module", "goose"),
}
}
// Fatalf is a proxy for goose logging.
func (gl *GooseLogger) Fatalf(format string, v ...interface{}) {
gl.logger.Error(fmt.Sprintf(format, v...))
}
// Printf is a proxy for goose logging.
func (gl *GooseLogger) Printf(format string, v ...interface{}) {
gl.logger.Info(fmt.Sprintf(format, v...))
}

7
commons/ids.go Normal file
View File

@ -0,0 +1,7 @@
// File "ids.go" stores all static identificators that is used across Bunker server.
package commons
const (
// SystemUserID is an UUID for "System" user. With this ID server's settings are stored in database.
SystemUserID = "00000001-0000-0000-0000-000000000000"
)

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
)

72
go.mod Normal file
View File

@ -0,0 +1,72 @@
module bunker
go 1.24.0
require (
fyne.io/fyne/v2 v2.6.3
github.com/coder/websocket v1.8.14
github.com/jackc/pgx/v5 v5.7.6
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/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // 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/crypto v0.42.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.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.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
)

189
go.sum Normal file
View File

@ -0,0 +1,189 @@
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/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
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/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
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/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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
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.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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

16
server/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM code.pztrn.name/containers/go-toolbox:v8 AS build
WORKDIR /bunkerd
COPY . /bunkerd
RUN --mount=type=cache,target="/home/container/go" task server:cmd:bunkerd:build --force
FROM debian:13-slim
RUN apt-get update && \
apt-get install -y ca-certificates iputils-ping coreutils && \
rm -rf /var/lib/apt/* /var/cache/apt/*
COPY --from=build /bunkerd/_build/bunkerd /bunkerd
COPY --from=build /usr/local/bin/dlv /dlv
ENTRYPOINT ["/bunkerd"]

18
server/Dockerfile.debug Normal file
View File

@ -0,0 +1,18 @@
FROM code.pztrn.name/containers/go-toolbox:v8 AS build
WORKDIR /bunkerd
COPY . /bunkerd
RUN --mount=type=cache,target="/home/container/go" GOFLAGS="" task server:cmd:bunkerd:build-debug --force
FROM debian:13-slim
RUN apt-get update && \
apt-get install -y ca-certificates iputils-ping coreutils curl net-tools && \
rm -rf /var/lib/apt/* /var/cache/apt/*
COPY --from=build /bunkerd/_build/bunkerd /bunkerd
COPY --from=build /usr/local/bin/dlv /dlv
HEALTHCHECK --interval=1s --timeout=10s --start-period=1s --retries=3 CMD netstat -an | grep 4000 > /dev/null; if [ 0 != $? ]; then exit 1; fi;
ENTRYPOINT ["/dlv", "--listen=:4000", "--headless=true", "--log=true", "--accept-multiclient", "--api-version=2", "exec", "/bunkerd"]

6
server/Taskfile.yml Normal file
View File

@ -0,0 +1,6 @@
---
version: "3"
includes:
localdev: ./localdevzone
cmd: ./cmd

5
server/cmd/Taskfile.yml Normal file
View File

@ -0,0 +1,5 @@
---
version: "3"
includes:
bunkerd: ./bunkerd

View File

@ -0,0 +1,44 @@
---
version: "3"
tasks:
build:
desc: "Builds bunkerd binary."
cmds:
- task: :::ensure-builddir
- task: cleanup
- go build -ldflags="{{ .BASIC_LDFLAGS }}" -o _build/bunkerd{{exeExt}} ./server/cmd/bunkerd/main.go
sources:
- ./Taskfile.yml
- ./go.mod
- ./commons/*
- ./server/**/*.go
- ./server/Taskfile.yml
- ./server/**/Taskfile.yml
- ./server/entrypoint.sh
generates:
- ./_build/bunkerd{{exeExt}}
method: timestamp
build-debug:
desc: "Builds bunkerd binary."
cmds:
- task: :::ensure-builddir
- task: cleanup
- go build -ldflags="{{ .BASIC_LDFLAGS }}" --gcflags "all=-N -l" -o _build/bunkerd{{exeExt}} ./server/cmd/bunkerd/main.go
sources:
- ./Taskfile.yml
- ./go.mod
- ./commons/*
- ./server/**/*.go
- ./server/Taskfile.yml
- ./server/**/Taskfile.yml
- ./server/entrypoint.sh
generates:
- ./_build/bunkerd{{exeExt}}
method: timestamp
cleanup:
desc: "Deletes bunkerd binary from local build cache."
cmds:
- rm -f _build/bunkerd{{exeExt}}

View File

@ -0,0 +1,63 @@
package main
import (
"log/slog"
"os"
"bunker/commons"
"bunker/server/internal/application"
"bunker/server/internal/services/core/database"
"bunker/server/internal/services/core/httpserver"
"bunker/server/internal/services/core/options"
)
func main() {
app := application.New()
lgr := app.NewLogger("module", "main")
lgr.Info(
"Starting bunkerd...",
"version", commons.Version,
"build_no", commons.Build,
"buint_on", commons.BuildDate,
"commit", commons.Commit,
"branch", commons.Branch,
)
if err := app.Start(); err != nil {
lgr.Error("Failed to start bunkerd!", "error", err.Error())
os.Exit(1)
}
checkError(lgr, database.Initialize(app))
checkError(lgr, options.Initialize(app))
checkError(lgr, httpserver.Initialize(app))
if err := app.Start(); err != nil {
lgr.Error("Failed to start bunkerd", "error", err.Error())
os.Exit(2)
}
lgr.Info("bunkerd started.")
<-app.ShutdownChan()
lgr.Info("Shutting down bunkerd...")
if err := app.Shutdown(); err != nil {
lgr.Error("Failed to shutdown bunkerd!", "error", err.Error())
os.Exit(1)
}
}
func checkError(logger *slog.Logger, err error) {
if err == nil {
return
}
logger.Error("Failed to initialize bunkerd.", "error", err.Error())
os.Exit(2)
}

View File

@ -0,0 +1,196 @@
package application
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"time"
)
var errApplication = errors.New("application")
// Application is a lifecycle controlling structure for application.
type Application struct {
shutdownChan chan struct{}
ctx context.Context
cancelFunc context.CancelFunc
baseLogger *slog.Logger
appLogger *slog.Logger
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
}
a.appLogger.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
}
a.appLogger.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
}
a.appLogger.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
}
a.appLogger.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(a.ctx, 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.ctx, a.cancelFunc = context.WithCancel(context.Background())
a.initializeLogger()
a.services = make([]Service, 0)
}
func (a *Application) launchStartupTasks() error {
for _, service := range a.services {
if strings.Contains(service.Name(), "mainwindow") {
continue
}
if !strings.Contains(service.Name(), "core/") {
continue
}
a.appLogger.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
}
a.appLogger.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)
}
}
return nil
}
// Shutdown stops application.
func (a *Application) Shutdown() error {
a.appLogger.Info("Stopping pztrn's Bunker...")
for _, service := range a.services {
if !strings.Contains(service.Name(), "features/") {
continue
}
a.appLogger.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
}
a.appLogger.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 starts application.
// Server application will start a goroutine that monitors SIGTERM and sends empty struct to channel.
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.startServer()
return nil
}

View File

@ -0,0 +1,45 @@
package application
import (
"log/slog"
"os"
"strings"
)
const (
defaultLogLevel slog.Level = slog.LevelInfo
logLevelEnvVar = "BUNKERD_LOG_LEVEL"
)
func (a *Application) initializeLogger() {
logLevel := defaultLogLevel
logLevelAsString, found := os.LookupEnv(logLevelEnvVar)
if found {
switch strings.ToLower(logLevelAsString) {
case "debug":
logLevel = slog.LevelDebug
case "info":
logLevel = slog.LevelInfo
case "warn":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
}
}
slog.Warn("Setting log level.", "level", logLevel.String())
a.baseLogger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: logLevel,
}))
a.appLogger = a.baseLogger.With("module", "application")
}
// NewLogger creates new sub-instance of base logger and adds some additional data to it for persistent output.
func (a *Application) NewLogger(withs ...interface{}) *slog.Logger {
return a.baseLogger.With(withs...)
}

View File

@ -0,0 +1,26 @@
package application
import (
"os"
"os/signal"
"syscall"
)
// ShutdownChan returns shutdown channel for main function.
func (a *Application) ShutdownChan() chan struct{} {
return a.shutdownChan
}
func (a *Application) startServer() {
a.shutdownChan = make(chan struct{})
go func() {
listener := make(chan os.Signal, 1)
signal.Notify(listener, syscall.SIGTERM, os.Interrupt)
<-listener
a.shutdownChan <- struct{}{}
}()
}

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 necessary 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,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,83 @@
package database
import (
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"github.com/jmoiron/sqlx"
// postgresql driver.
_ "github.com/jackc/pgx/v5/stdlib"
)
const (
databaseDSNEnvVar = "BUNKERD_DATABASE_DSN"
databaseMaxIdleConnsEnvVar = "BUNKERD_DATABASE_MAX_IDLE_CONNS"
databaseMaxOpenedConnsEnvVar = "BUNKERD_DATABASE_MAX_OPENED_CONNS"
)
var (
errDSNInvalid = errors.New("BUNKERD_DATABASE_DSN environment variable is empty or invalid")
errNoMaxIdleConns = errors.New("no BUNKERD_DATABASE_MAX_IDLE_CONNS defined")
errNoMaxOpenedConns = errors.New("no BUNKERD_DATABASE_MAX_OPENED_CONNS defined")
errPostgresOnlySupported = errors.New("only PostgreSQL database is currently supported")
)
func (d *database) initializeConnection() error {
// Getting database DSN from environment as well as other required settings.
dsn, found := os.LookupEnv(databaseDSNEnvVar)
if !found {
return fmt.Errorf("initialize connection: getting database DSN: %w", errDSNInvalid)
}
maxOpenedConnsRaw, found := os.LookupEnv(databaseMaxOpenedConnsEnvVar)
if !found {
return fmt.Errorf("initialize connection: getting maximum number of opened conections: %w", errNoMaxOpenedConns)
}
maxOpenedConns, err := strconv.ParseInt(maxOpenedConnsRaw, 10, 64)
if err != nil {
return fmt.Errorf("initialize connection: parsing maximum number of opened conections: %w", err)
}
maxIdleConnsRaw, found := os.LookupEnv(databaseMaxIdleConnsEnvVar)
if !found {
return fmt.Errorf("initialize connection: getting maximum number of idle conections: %w", errNoMaxIdleConns)
}
maxIdleConns, err := strconv.ParseInt(maxIdleConnsRaw, 10, 64)
if err != nil {
return fmt.Errorf("initialize connection: parsing maximum number of opened conections: %w", err)
}
// While database/sql (and sqlx) supports all possible DSN formats, we will force user to use DSN in form
// "proto://user:passowrd@host:port/dbname" as it is easier to parse.
if _, err := url.Parse(dsn); err != nil {
return fmt.Errorf("initialize connection: validate DSN: %w", err)
}
// Currently we're support only postgresql, but this may change in future.
if !strings.HasPrefix(dsn, "postgres://") {
return fmt.Errorf("initialize connection: validate DSN: %w", errPostgresOnlySupported)
}
proto := strings.Split(dsn, ":")[0]
if proto == "postgres" {
proto = "pgx"
}
db, err := sqlx.Open(proto, dsn)
if err != nil {
return fmt.Errorf("initialize connection: open database: %w", err)
}
d.db = db
d.db.SetMaxOpenConns(int(maxOpenedConns))
d.db.SetMaxIdleConns(int(maxIdleConns))
return nil
}

View File

@ -0,0 +1,73 @@
package database
import (
"fmt"
"io/fs"
"log/slog"
"bunker/server/internal/application"
"bunker/server/internal/services/core"
"github.com/jmoiron/sqlx"
)
var _ = core.Database(&database{})
type database struct {
app *application.Application
db *sqlx.DB
logger *slog.Logger
migrations map[string]fs.FS
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.ErrDatabase, err)
}
return nil
}
func (d *database) Configure() error {
if err := d.initializeConnection(); err != nil {
return fmt.Errorf("configure: %w", err)
}
return nil
}
func (d *database) ConnectDependencies() error {
return nil
}
func (d *database) Initialize() error {
d.logger = d.app.NewLogger("service", core.ServiceNameDatabase)
d.logger.Info("Initializing...")
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,82 @@
package database
import (
"errors"
"fmt"
"io/fs"
"log/slog"
"sort"
"strings"
"bunker/commons"
"bunker/server/internal/services/core"
"github.com/pressly/goose/v3"
)
var errMigrationsAlreadyRegistered = errors.New("migrations already registered")
func (d *database) applyMigrations() error {
d.logger.Info("Migrating database...")
modules := make([]string, 0)
for module := range d.migrations {
modules = append(modules, module)
}
sort.Strings(modules)
_ = goose.SetDialect(string(goose.DialectPostgres))
gooseLogger := commons.NewGooseLogger(d.logger)
goose.SetLogger(gooseLogger)
for _, module := range modules {
d.logger.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
d.logger.Info(
"Database for module migrated to latest version",
"module", module,
"module_db_version", moduleDBVersion,
"db_version", d.version,
)
}
d.logger.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,69 @@
package database
import (
"context"
"fmt"
"strings"
"bunker/server/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)
}
d.logger.Debug("Executing query.", "query", query, "params", fmt.Sprintf("%+v", params))
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)
}
d.logger.Debug("Getting single data from database with query.", "query", query, "params", fmt.Sprintf("%+v", params))
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)
}
d.logger.Debug("Executing named query.", "query", query, "params", fmt.Sprintf("%+v", param))
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)
}
d.logger.Debug("Selecting from database with query.", "query", query, "params", fmt.Sprintf("%+v", params))
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,81 @@
package database
import (
"context"
"fmt"
"log/slog"
"bunker/server/internal/services/core"
"github.com/jmoiron/sqlx"
)
type transaction struct {
transaction *sqlx.Tx
logger *slog.Logger
}
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,
logger: d.logger.With("module", "transactioner"),
}
return txHandler, nil
}
func (t *transaction) Apply(steps ...core.TransactionFunc) error {
for stepNumber, stepFunc := range steps {
if err := stepFunc(t.transaction); err != nil {
t.logger.Error(
"Error occurred.",
"step", stepNumber,
"error", err.Error(),
"module", "core/database",
"subsystem", "transaction",
)
if rollbackErr := t.transaction.Rollback(); rollbackErr != nil {
t.logger.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 {
t.logger.Error(
"Transaction commit failed.",
"error", err.Error(),
"module", "core/database",
"subsystem", "transaction",
)
if rollbackErr := t.transaction.Rollback(); rollbackErr != nil {
t.logger.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,16 @@
package core
import (
"errors"
)
// ServiceNameHTTPServer is a name for HTTP server service.
const ServiceNameHTTPServer = "core/http_server"
// ErrHTTPServerIsInvalid appears when HTTP server service implementation is invalid.
var ErrHTTPServerIsInvalid = errors.New("HTTP server service implementation is invalid")
// HTTPServer is an interface for HTTP server service.
//
//nolint:iface
type HTTPServer interface{}

View File

@ -0,0 +1,91 @@
package httpserver
import (
"errors"
"fmt"
"log/slog"
"net/http"
"bunker/server/internal/application"
"bunker/server/internal/services/core"
)
var (
_ = core.HTTPServer(&httpServer{})
errHTTPServer = errors.New("HTTP server core service")
)
type httpServer struct {
app *application.Application
logger *slog.Logger
db core.Database
httpSrv *http.Server
}
// Initialize initializes service.
func Initialize(app *application.Application) error {
httpSrv := &httpServer{
app: app,
}
if err := app.RegisterService(httpSrv); err != nil {
return fmt.Errorf("%w: %w", errHTTPServer, err)
}
return nil
}
func (h *httpServer) Configure() error {
h.logger.Debug("Configuring service...")
if err := h.configureHTTPServer(); err != nil {
return fmt.Errorf("configure: %w", err)
}
return nil
}
func (h *httpServer) ConnectDependencies() error {
databaseRaw := h.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)
}
h.db = database
return nil
}
func (h *httpServer) Initialize() error {
h.logger = h.app.NewLogger("service", core.ServiceNameHTTPServer)
h.logger.Info("Initializing...")
return nil
}
func (h *httpServer) Name() string {
return core.ServiceNameHTTPServer
}
func (h *httpServer) LaunchStartupTasks() error {
h.logger.Debug("Launching startup tasks...")
go h.startHTTPServer()
return nil
}
func (h *httpServer) Shutdown() error {
if err := h.stopHTTPServer(); err != nil {
return fmt.Errorf("%w: Shutdown: %w", errHTTPServer, err)
}
return nil
}

View File

@ -0,0 +1,85 @@
package httpserver
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"time"
"github.com/coder/websocket"
)
const httpServerAddrEnvVar = "BUNKERD_HTTP_ADDRESS"
var (
errHTTPServerAddrInvalid = errors.New("BUNKERD_HTTP_ADDRESS environment variable contains invalid address to " +
"listen, should be 'host:port'")
errHTTPServerAddrNotFound = errors.New("BUNKERD_HTTP_ADDRESS environment variable empty")
)
func (h *httpServer) configureHTTPServer() error {
httpSrvAddr, found := os.LookupEnv(httpServerAddrEnvVar)
if !found {
return fmt.Errorf("configure HTTP server: get address from environment variable: %w", errHTTPServerAddrNotFound)
}
host, port, err := net.SplitHostPort(httpSrvAddr)
if err != nil {
return fmt.Errorf("configure HTTP server: validate HTTP server address: %w", err)
}
if httpSrvAddr != host+":"+port {
return fmt.Errorf("configure HTTP server: validate HTTP server address: %w", errHTTPServerAddrInvalid)
}
mux := new(http.ServeMux)
mux.HandleFunc("GET /api/v1/socket", h.handleWebsocketRequest)
h.httpSrv = &http.Server{
Addr: httpSrvAddr,
Handler: mux,
ReadHeaderTimeout: time.Second * 3,
}
return nil
}
func (h *httpServer) handleWebsocketRequest(w http.ResponseWriter, r *http.Request) {
wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OnPingReceived: func(_ context.Context, _ []byte) bool {
return true
},
})
if err != nil {
h.logger.Error("Failed to accept WS connection!", "error", err.Error())
return
}
defer func() {
if err := wsConn.CloseNow(); err != nil {
h.logger.Warn("Failed to close WS connection in defer!", "error", err.Error())
}
}()
}
func (h *httpServer) startHTTPServer() {
h.logger.Info("Starting listening for HTTP requests.", "address", h.httpSrv.Addr)
if err := h.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
h.logger.Warn("Error when listening to ", "error", err.Error())
}
}
func (h *httpServer) stopHTTPServer() error {
h.logger.Info("Stopping HTTP server...")
if err := h.httpSrv.Shutdown(h.app.ContextWithTimeout(time.Second * 3)); err != nil {
return fmt.Errorf("stopping HTTP server: %w", err)
}
return nil
}

View File

@ -0,0 +1,16 @@
package core
import (
"errors"
)
// ServiceNameOptions is a name for options service which controls options storage.
const ServiceNameOptions = "core/options"
// ErrOptionsIsInvalid appears when options service implementation is invalid.
var ErrOptionsIsInvalid = errors.New("options service implementation is invalid")
// Options is an interface for options service.
//
//nolint:iface
type Options interface{}

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,12 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS options (
id UUID NOT NULL PRIMARY KEY,
user_id UUID NOT NULL,
key VARCHAR(1024) NOT NULL,
value VARCHAR(8192)
);
CREATE UNIQUE INDEX IF NOT EXISTS options_user_id_key_idx ON options(user_id, key);
-- +goose Down
DROP TABLE IF EXISTS options;

View File

@ -0,0 +1,79 @@
package options
import (
"errors"
"fmt"
"log/slog"
"bunker/server/internal/application"
"bunker/server/internal/services/core"
)
var (
_ = core.Options(&options{})
errOptions = errors.New("options core service")
)
type options struct {
app *application.Application
logger *slog.Logger
db core.Database
}
// Initialize initializes service.
func Initialize(app *application.Application) error {
opts := &options{
app: app,
}
if err := app.RegisterService(opts); err != nil {
return fmt.Errorf("%w: %w", 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
return nil
}
func (o *options) Initialize() error {
o.logger = o.app.NewLogger("service", core.ServiceNameOptions)
o.logger.Info("Initializing...")
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,23 @@
---
version: "3"
includes:
bunkerd: ./bunkerd
common: ./common
postgresql: ./postgresql
tasks:
down:
desc: "Removes development environment."
cmds:
- task: bunkerd:down
- task: bunkerd:down-debug
- task: postgresql:down
- task: common:network-down
up:
desc: "Creates development environment."
cmds:
- task: common:network-up
- task: postgresql:up
- task: bunkerd:up

View File

@ -0,0 +1,91 @@
---
version: "3"
tasks:
build:
desc: "Builds bunkerd's container."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd -f docker-compose.yaml build
build-debug:
desc: "Builds bunkerd's debug build container."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd-debug -f docker-compose.debug.yaml build
down:
desc: "Deletes all bunkerd's data (down)."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd -f docker-compose.yaml down --volumes
down-debug:
desc: "Deletes all bunkerd's debug build data (down)."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd-debug -f docker-compose.debug.yaml down --volumes
logs:
desc: "Show bunkerd logs."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd -f docker-compose.yaml logs -f
logs-debug:
desc: "Show bunkerd debug build logs."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd-debug -f docker-compose.debug.yaml logs -f
restart:
desc: "Restart bunkerd."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd -f docker-compose.yaml restart
restart-debug:
desc: "Restart bunkerd debug build."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd-debug -f docker-compose.debug.yaml restart
start:
desc: "Start bunkerd."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd -f docker-compose.yaml start
start-debug:
desc: "Start bunkerd debug build."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd -f docker-compose.yaml start
stop:
desc: "Stop bunkerd without deleting it's data."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd -f docker-compose.yaml stop
stop-debug:
desc: "Stop bunkerd debug build without deleting it's data."
dir: "./server/localdevzone/bunkerd"
cmds:
- docker compose -p bunkerd-debug -f docker-compose.debug.yaml stop
up:
desc: "Start bunkerd (up -d)."
dir: "./server/localdevzone/bunkerd"
cmds:
- task: :common:network-up
- task: build
- docker compose -p bunkerd -f docker-compose.yaml up -d
up-debug:
desc: "Start bunkerd debug build (up -d)."
dir: "./server/localdevzone/bunkerd"
cmds:
- task: :common:network-up
- task: build-debug
- docker compose -p bunkerd-debug -f docker-compose.debug.yaml up -d --wait

View File

@ -0,0 +1,27 @@
---
services:
bunkerd-debug:
container_name: "bunkerd-debug"
build:
context: ../../../
dockerfile: server/Dockerfile.debug
ports:
- "54400:54400"
- "54401:4000"
volumes:
- "./data:/data"
networks:
bunkerd:
ipv4_address: 247.247.0.3
environment:
BUNKERD_DATABASE_MAX_IDLE_CONNS: 5
BUNKERD_DATABASE_MAX_OPENED_CONNS: 20
BUNKERD_DATABASE_DSN: "postgres://postgres:postgres@247.247.0.4:5432/bunkerd?sslmode=disable"
BUNKERD_HTTP_ADDRESS: "0.0.0.0:53400"
BUNKERD_LOG_LEVEL: "debug"
cap_add:
- SYS_PTRACE
networks:
bunkerd:
external: true

View File

@ -0,0 +1,26 @@
---
services:
bunkerd:
container_name: "bunkerd"
build:
context: ../../../
dockerfile: server/Dockerfile
ports:
- "53400:53400"
volumes:
- "./data:/data"
networks:
bunkerd:
ipv4_address: 247.247.0.2
environment:
BUNKERD_DATABASE_MAX_IDLE_CONNS: 5
BUNKERD_DATABASE_MAX_OPENED_CONNS: 20
BUNKERD_DATABASE_DSN: "postgres://postgres:postgres@247.247.0.4:5432/bunkerd?sslmode=disable"
BUNKERD_HTTP_ADDRESS: "0.0.0.0:53400"
BUNKERD_LOG_LEVEL: "debug"
cap_add:
- SYS_PTRACE
networks:
bunkerd:
external: true

View File

@ -0,0 +1,15 @@
---
version: "3"
tasks:
network-down:
desc: "Deletes Docker network for development."
dir: "./server/localdevzone/common"
cmds:
- docker compose -p bunkerd-network -f network.yaml down
network-up:
desc: "Creates Docker network for development"
dir: "./server/localdevzone/common"
cmds:
- docker compose -p bunkerd-network -f network.yaml up -d

Some files were not shown because too many files have changed in this diff Show More