Basic client and server apps. #41
@ -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
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
*DS_Store*
|
||||
fyne-cross
|
||||
_build
|
||||
*.apk
|
||||
*.app
|
||||
dist
|
||||
.task
|
93
.golangci.yml
Normal file
93
.golangci.yml
Normal 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
26
.vscode/launch.json
vendored
Normal 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
31
.vscode/tasks.json
vendored
Normal 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
7
FyneApp.toml
Normal 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
|
45
Taskfile.yml
Normal file
45
Taskfile.yml
Normal 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
6
client/Taskfile.yml
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
client: ./cmd/client
|
||||
translations_checker: ./cmd/translations_checker
|
114
client/cmd/client/Taskfile.yml
Normal file
114
client/cmd/client/Taskfile.yml
Normal 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
78
client/cmd/client/main.go
Normal 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)
|
||||
}
|
26
client/cmd/translations_checker/Taskfile.yml
Normal file
26
client/cmd/translations_checker/Taskfile.yml
Normal file
@ -0,0 +1,26 @@
|
||||
---
|
||||
version: "3"
|
||||
|
||||
vars:
|
||||
TCClientBinary: name.pztrn.bunker.app.translations_checker{{exeExt}}
|
||||
|
||||
tasks:
|
||||
build:
|
||||
desc: "Build translations checker."
|
||||
dir: ./client/cmd/translations_checker
|
||||
cmds:
|
||||
- task: ::ensure-builddir
|
||||
- task: cleanup
|
||||
- go build -o ../../../.build/{{ .TCClientBinary }} main.go
|
||||
|
||||
cleanup:
|
||||
desc: "Cleanup build environment for translations checker."
|
||||
cmds:
|
||||
- rm .build/{{ .TCClientBinary }}
|
||||
ignore_error: true
|
||||
|
||||
run:
|
||||
desc: "Launch translations checker."
|
||||
cmds:
|
||||
- task: build
|
||||
- .build/{{ .TCClientBinary }} -translations-path client/internal/langfiles/files
|
129
client/cmd/translations_checker/main.go
Normal file
129
client/cmd/translations_checker/main.go
Normal 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")
|
||||
}
|
||||
}
|
223
client/internal/application/application.go
Normal file
223
client/internal/application/application.go
Normal 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
|
||||
}
|
10
client/internal/application/fyne.go
Normal file
10
client/internal/application/fyne.go
Normal 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
|
||||
}
|
20
client/internal/application/logger.go
Normal file
20
client/internal/application/logger.go
Normal 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...)
|
||||
}
|
80
client/internal/application/service.go
Normal file
80
client/internal/application/service.go
Normal file
@ -0,0 +1,80 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrServiceAlreadyRegistered returns if trying to register a service with name already taken by other service.
|
||||
ErrServiceAlreadyRegistered = errors.New("service with such name already registered")
|
||||
// ErrServiceNotFound returns if trying to gather service with unknown name.
|
||||
ErrServiceNotFound = errors.New("service with such name wasn't found")
|
||||
)
|
||||
|
||||
// Service is an interface every service should conform to. Specific services will have own interface for
|
||||
// cross-service interation.
|
||||
type Service interface {
|
||||
// Configure configures service. Called after ConnectDependencies and before LaunchStartupTasks.
|
||||
Configure() error
|
||||
// ConnectDependencies gets 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
|
||||
}
|
13
client/internal/helpers/platform.go
Normal file
13
client/internal/helpers/platform.go
Normal 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
|
||||
}
|
||||
}
|
13
client/internal/helpers/sqlx.go
Normal file
13
client/internal/helpers/sqlx.go
Normal file
@ -0,0 +1,13 @@
|
||||
package helpers
|
||||
|
||||
import "strings"
|
||||
|
||||
// IsConstraintFailed возвращает true если ограничение по индексу было нарушено.
|
||||
func IsConstraintFailed(err error) bool {
|
||||
return strings.Contains(err.Error(), "constraint failed")
|
||||
}
|
||||
|
||||
// IsEmptySliceForIn возвращает true если sqlx вернул ошибку подготовки слайла параметров в sqlx.In.
|
||||
func IsEmptySliceForIn(err error) bool {
|
||||
return strings.Contains(err.Error(), "empty slice passed to 'in' query")
|
||||
}
|
30
client/internal/langfiles/files/en.json
Normal file
30
client/internal/langfiles/files/en.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"about_dialog.about_tab": "About",
|
||||
"about_dialog.about_tab.copyright": "Copyright © {{ .Years }}, Stanislav Nikitin (a.k.a. pztrn). All rights reserved.",
|
||||
"about_dialog.about_tab.summary": "pztrn's Bunker is a self-hosted family or company intranet.",
|
||||
"about_dialog.licenses_tab": "Licenses",
|
||||
"about_dialog.licenses_tab.dependency.fyne": "Cross platform GUI toolkit in Go inspired by Material Design.",
|
||||
"about_dialog.licenses_tab.dependency.goose": "A database migration tool.",
|
||||
"about_dialog.licenses_tab.dependency.gopsutil": "psutil for golang. Used for getting information about Bunker's process and machine it launched on.",
|
||||
"about_dialog.licenses_tab.dependency.modernc_sqlite": "CGo-free port of SQLite/SQLite3.\n\nSQLite is an in-process implementation of a self-contained, serverless, zero-configuration, transactional SQL database engine.",
|
||||
"about_dialog.licenses_tab.dependency.sqlx": "General purpose extensions to golang's database/sql.",
|
||||
"about_dialog.licenses_tab.summary": "This software uses these third-party libraries:",
|
||||
"about_dialog.close_button": "Close",
|
||||
"about_dialog.sysinfo_tab": "System info",
|
||||
"about_dialog.sysinfo_tab.copy_to_clipboard.notification.content": "Copied to clipboard!",
|
||||
"about_dialog.sysinfo_tab.copy_to_clipboard.notification.title": "System information data",
|
||||
"about_dialog.sysinfo_tab.summary": "This tab contains system information that will be needed for bug reporting and features requests.",
|
||||
"about_dialog.title": "About pztrn's Bunker",
|
||||
"about_dialog.version": "Version",
|
||||
"main_menu.about": "?",
|
||||
"main_menu.about.about_bunker_menu_item": "About pztrn's Bunker...",
|
||||
"main_menu.file": "File",
|
||||
"main_menu.file.exit_menu_item": "Exit",
|
||||
"main_menu.file.options_menu_item": "Options",
|
||||
"options_dialog.no_settings": "No settings found for selected section.",
|
||||
"options_dialog.select_section": "Select section on left to see settings.",
|
||||
"search.placeholder": "Enter text to search and press ENTER.",
|
||||
"window.lorem_ipsum.tab_name": "Welcome!",
|
||||
"window.lorem_ipsum.text": "Welcome to pztrn's Bunker!\n\nUnfortunately, something went wrong and while application was successfully initialized and started you see this message.\n\nPlease, restart application using terminal and send logs to developer!",
|
||||
"window.title": "pztrn's Bunker"
|
||||
}
|
30
client/internal/langfiles/files/ru.json
Normal file
30
client/internal/langfiles/files/ru.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"about_dialog.about_tab": "О программе",
|
||||
"about_dialog.about_tab.copyright": "Copyright © {{ .Years }}, Stanislav Nikitin (a.k.a. pztrn). Все права защищены.",
|
||||
"about_dialog.about_tab.summary": "pztrn's Bunker это интранет для вашей семьи или компании.",
|
||||
"about_dialog.licenses_tab": "Лицензии",
|
||||
"about_dialog.licenses_tab.dependency.fyne": "Cross platform GUI toolkit in Go inspired by Material Design.",
|
||||
"about_dialog.licenses_tab.dependency.goose": "A database migration tool.",
|
||||
"about_dialog.licenses_tab.dependency.gopsutil": "psutil for golang. Used for getting information about Bunker's process and machine it launched on.",
|
||||
"about_dialog.licenses_tab.dependency.modernc_sqlite": "CGo-free port of SQLite/SQLite3.\n\nSQLite is an in-process implementation of a self-contained, serverless, zero-configuration, transactional SQL database engine.",
|
||||
"about_dialog.licenses_tab.dependency.sqlx": "General purpose extensions to golang's database/sql.",
|
||||
"about_dialog.licenses_tab.summary": "Это приложение использует следующие сторонние библиотеки:",
|
||||
"about_dialog.close_button": "Закрыть",
|
||||
"about_dialog.sysinfo_tab": "Информация о системе",
|
||||
"about_dialog.sysinfo_tab.copy_to_clipboard.notification.content": "Скопировано в буфер обмена!",
|
||||
"about_dialog.sysinfo_tab.copy_to_clipboard.notification.title": "Информация о системе",
|
||||
"about_dialog.sysinfo_tab.summary": "Эта вкладка содержит информацию, которую необходимо предоставить разработчикам при сообщении об ошибке или при запросе новой функциональности.",
|
||||
"about_dialog.title": "О pztrn's Bunker",
|
||||
"about_dialog.version": "Версия",
|
||||
"main_menu.about": "?",
|
||||
"main_menu.about.about_bunker_menu_item": "О pztrn's Bunker...",
|
||||
"main_menu.file": "Файл",
|
||||
"main_menu.file.exit_menu_item": "Выход",
|
||||
"main_menu.file.options_menu_item": "Настройки",
|
||||
"options_dialog.no_settings": "Для выбранной секции настройки отсутствуют.",
|
||||
"options_dialog.select_section": "Выберите секцию слева для отображения настроек.",
|
||||
"search.placeholder": "Введите текст для поиска и нажмите ENTER.",
|
||||
"window.lorem_ipsum.tab_name": "Добро пожаловать!",
|
||||
"window.lorem_ipsum.text": "Welcome to pztrn's Bunker!\n\nUnfortunately, something went wrong and while application was successfully initialized and started you see this message.\n\nPlease, restart application using terminal and send logs to developer!",
|
||||
"window.title": "pztrn's Bunker"
|
||||
}
|
8
client/internal/langfiles/langfiles.go
Normal file
8
client/internal/langfiles/langfiles.go
Normal file
@ -0,0 +1,8 @@
|
||||
package langfiles
|
||||
|
||||
import "embed"
|
||||
|
||||
// LangFiles это встроенная ФС, содержащая переводы приложения.
|
||||
//
|
||||
//go:embed files
|
||||
var LangFiles embed.FS
|
44
client/internal/services/core/database.go
Normal file
44
client/internal/services/core/database.go
Normal file
@ -0,0 +1,44 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// ServiceNameDatabase is a name for database service.
|
||||
const ServiceNameDatabase = "core/database"
|
||||
|
||||
var (
|
||||
// ErrDatabase indicates that error appeared somewhere in database service.
|
||||
ErrDatabase = errors.New("database service")
|
||||
// ErrDatabaseIsInvalid indicates that database service implementation is invalid.
|
||||
ErrDatabaseIsInvalid = errors.New("database service implementation is invalid")
|
||||
)
|
||||
|
||||
// Database is an interface for database service.
|
||||
type Database interface {
|
||||
// Exec is a proxy for ExecContext from sqlx.
|
||||
Exec(ctx context.Context, query string, params ...interface{}) error
|
||||
// Get is a proxy for GetContext from sqlx.
|
||||
Get(ctx context.Context, target interface{}, query string, params ...interface{}) error
|
||||
// NamedExec is a proxy for NamedExecContext from sqlx.
|
||||
NamedExec(ctx context.Context, query string, param interface{}) error
|
||||
// RegisterMigrations registers migrations for applying from other services. Migrations should reside
|
||||
// in "migrations" directory in passed filesystem.
|
||||
RegisterMigrations(moduleName string, fs fs.FS) error
|
||||
// Select is a proxy for SelectContext from sqlx.
|
||||
Select(ctx context.Context, target interface{}, query string, params ...interface{}) error
|
||||
// Transaction is a wrapper for transactions processing which wraps sqlx's transactions.
|
||||
Transaction(ctx context.Context) (DatabaseTransaction, error)
|
||||
}
|
||||
|
||||
// DatabaseTransaction is an interface for database transactions controllers implementations.
|
||||
type DatabaseTransaction interface {
|
||||
Apply(steps ...TransactionFunc) error
|
||||
}
|
||||
|
||||
// TransactionFunc is a function that is used in transactions to mangle with data.
|
||||
type TransactionFunc func(*sqlx.Tx) error
|
118
client/internal/services/core/database/database.go
Normal file
118
client/internal/services/core/database/database.go
Normal 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
|
||||
}
|
11
client/internal/services/core/database/dbpath.go
Normal file
11
client/internal/services/core/database/dbpath.go
Normal 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)
|
||||
}
|
82
client/internal/services/core/database/migrations.go
Normal file
82
client/internal/services/core/database/migrations.go
Normal 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
|
||||
}
|
36
client/internal/services/core/database/options.go
Normal file
36
client/internal/services/core/database/options.go
Normal file
@ -0,0 +1,36 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bunker/client/internal/services/core/options/dto"
|
||||
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func (d *database) initializeOptions() error {
|
||||
databaseMaxIdleConnsEntry := widget.NewEntry()
|
||||
databaseMaxIdleConns := widget.NewFormItem("Maximum idle connections", databaseMaxIdleConnsEntry)
|
||||
|
||||
databaseMaxOpenedConnsEntry := widget.NewEntry()
|
||||
databaseMaxOpenedConns := widget.NewFormItem("Maximum opened connections", databaseMaxOpenedConnsEntry)
|
||||
|
||||
optionsWidgetForm := widget.NewForm(
|
||||
databaseMaxIdleConns,
|
||||
databaseMaxOpenedConns,
|
||||
)
|
||||
|
||||
if err := d.options.RegisterOptionsWidget(&dto.OptionPane{
|
||||
Widget: optionsWidgetForm,
|
||||
SaveHandler: d.saveOptions,
|
||||
Name: "Database",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register options widget: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *database) saveOptions() error {
|
||||
return nil
|
||||
}
|
69
client/internal/services/core/database/queries.go
Normal file
69
client/internal/services/core/database/queries.go
Normal 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
|
||||
}
|
51
client/internal/services/core/database/sys_info_handler.go
Normal file
51
client/internal/services/core/database/sys_info_handler.go
Normal file
@ -0,0 +1,51 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func (d *database) initializeSysInfoHandler() error {
|
||||
if err := d.mainWindow.RegisterAboutWindowSysInfoHandler(
|
||||
"database_data",
|
||||
d.sysInfoHandlerDatabaseVersion,
|
||||
); err != nil {
|
||||
return fmt.Errorf("register database_data handler: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *database) sysInfoHandlerDatabaseVersion() string {
|
||||
var dbFileSize, sizeDivisionsCount int64
|
||||
|
||||
var databaseSizeString string
|
||||
|
||||
fileInfo, err := os.Stat(d.dbPath)
|
||||
if err == nil {
|
||||
dbFileSize = fileInfo.Size()
|
||||
}
|
||||
|
||||
for {
|
||||
//nolint:mnd
|
||||
if dbFileSize > 1024 {
|
||||
dbFileSize /= 1024
|
||||
sizeDivisionsCount++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
switch sizeDivisionsCount {
|
||||
case 1:
|
||||
databaseSizeString = fmt.Sprintf("- Database file size: %d KB", dbFileSize)
|
||||
case 2: //nolint:mnd
|
||||
databaseSizeString = fmt.Sprintf("- Database file size: %d MB", dbFileSize)
|
||||
default:
|
||||
databaseSizeString = fmt.Sprintf("- Database file size: %d GB", dbFileSize)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("### Database\n\n- Database version: %d\n%s\n", d.version, databaseSizeString)
|
||||
}
|
81
client/internal/services/core/database/transaction.go
Normal file
81
client/internal/services/core/database/transaction.go
Normal 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
|
||||
}
|
40
client/internal/services/core/mainwindow.go
Normal file
40
client/internal/services/core/mainwindow.go
Normal 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
|
54
client/internal/services/core/mainwindow/about_dialog.go
Normal file
54
client/internal/services/core/mainwindow/about_dialog.go
Normal file
@ -0,0 +1,54 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
const (
|
||||
minRowsVisibleInSysInfo = 10
|
||||
startYear = 2025
|
||||
)
|
||||
|
||||
func (m *mainWindow) showAboutDialog() {
|
||||
appNameLbl := canvas.NewText(
|
||||
"pztrn's Bunker",
|
||||
m.app.Fyne().Settings().Theme().Color(
|
||||
theme.ColorNameForeground,
|
||||
m.app.Fyne().Settings().ThemeVariant(),
|
||||
),
|
||||
)
|
||||
appNameLbl.TextSize = 16
|
||||
appNameLbl.TextStyle = fyne.TextStyle{Bold: true}
|
||||
appNameLbl.Alignment = fyne.TextAlignCenter
|
||||
|
||||
appVersionLbl := canvas.NewText(
|
||||
lang.L("about_dialog.version")+" "+m.app.Fyne().Metadata().Custom["Version"],
|
||||
m.app.Fyne().Settings().Theme().Color(
|
||||
theme.ColorNameForeground,
|
||||
m.app.Fyne().Settings().ThemeVariant(),
|
||||
),
|
||||
)
|
||||
appVersionLbl.TextSize = 16
|
||||
appVersionLbl.TextStyle = fyne.TextStyle{Bold: true}
|
||||
appVersionLbl.Alignment = fyne.TextAlignCenter
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
m.generateAboutTab(),
|
||||
m.generateLicensesTab(),
|
||||
m.generateSysInfoTab(),
|
||||
)
|
||||
|
||||
vbox := container.NewVBox(
|
||||
appNameLbl,
|
||||
appVersionLbl,
|
||||
tabs,
|
||||
)
|
||||
|
||||
dlg := dialog.NewCustom(lang.L("about_dialog.title"), lang.L("about_dialog.close_button"), vbox, m.window)
|
||||
dlg.Show()
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func (m *mainWindow) generateAboutTab() *container.TabItem {
|
||||
copyrightYears := strconv.Itoa(startYear)
|
||||
if time.Now().Year() > startYear {
|
||||
copyrightYears = strconv.Itoa(startYear) + " - " + strconv.Itoa(time.Now().Year())
|
||||
}
|
||||
|
||||
aboutLabel := widget.NewLabel(
|
||||
lang.L("about_dialog.about_tab.summary") +
|
||||
"\n\n" +
|
||||
lang.L("about_dialog.about_tab.copyright", map[string]any{"Years": copyrightYears}),
|
||||
)
|
||||
aboutLabel.Wrapping = fyne.TextWrapWord
|
||||
|
||||
aboutVBox := container.NewVBox(aboutLabel)
|
||||
aboutScroll := container.NewVScroll(aboutVBox)
|
||||
|
||||
aboutScroll.SetMinSize(fyne.NewSize(
|
||||
m.window.Content().Size().Width-m.window.Content().Size().Width/3,
|
||||
m.window.Content().Size().Height-m.window.Content().Size().Height/2.5,
|
||||
))
|
||||
|
||||
return container.NewTabItem(lang.L("about_dialog.about_tab"), aboutScroll)
|
||||
}
|
@ -0,0 +1,293 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func (m *mainWindow) generateLicensesTab() *container.TabItem {
|
||||
licensesLabel := widget.NewLabel(lang.L("about_dialog.licenses_tab.summary"))
|
||||
licensesLabel.Wrapping = fyne.TextWrapWord
|
||||
|
||||
accordion := widget.NewAccordion(
|
||||
m.generateFyneAccordionItem(),
|
||||
m.generateGooseAccordionItem(),
|
||||
m.generateGopsutilAccordionItem(),
|
||||
m.generateModerncSqliteAccordionItem(),
|
||||
m.generateSqlxAccrodionItem(),
|
||||
)
|
||||
|
||||
licensesVBox := container.NewVBox(licensesLabel, accordion)
|
||||
licensesScroll := container.NewVScroll(licensesVBox)
|
||||
|
||||
return container.NewTabItem(lang.L("about_dialog.licenses_tab"), licensesScroll)
|
||||
}
|
||||
|
||||
func (m *mainWindow) generateFyneAccordionItem() *widget.AccordionItem {
|
||||
summary := widget.NewLabel(lang.L("about_dialog.licenses_tab.dependency.fyne"))
|
||||
summary.Wrapping = fyne.TextWrapWord
|
||||
|
||||
license := widget.NewLabel(`BSD 3-Clause License
|
||||
|
||||
Copyright (C) 2018 Fyne.io developers (see AUTHORS)
|
||||
All rights reserved.
|
||||
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Fyne.io nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.`)
|
||||
license.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
license.Wrapping = fyne.TextWrapWord
|
||||
|
||||
url, _ := url.Parse("https://github.com/fyne-io/fyne")
|
||||
authorsURL, _ := url.Parse("https://raw.githubusercontent.com/fyne-io/fyne/refs/heads/master/AUTHORS")
|
||||
|
||||
return widget.NewAccordionItem("Fyne v2.6.1", container.NewVBox(
|
||||
summary,
|
||||
container.NewHBox(
|
||||
widget.NewHyperlink("Source", url),
|
||||
widget.NewLabel("|"),
|
||||
widget.NewHyperlink("Authors", authorsURL),
|
||||
),
|
||||
license,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *mainWindow) generateGooseAccordionItem() *widget.AccordionItem {
|
||||
summary := widget.NewLabel(lang.L("about_dialog.licenses_tab.dependency.goose"))
|
||||
summary.Wrapping = fyne.TextWrapWord
|
||||
|
||||
license := widget.NewLabel(`MIT License
|
||||
|
||||
Original work Copyright (c) 2012 Liam Staskawicz
|
||||
Modified work Copyright (c) 2016 Vojtech Vitek
|
||||
Modified work Copyright (c) 2021 Michael Fridman, Vojtech Vitek
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`)
|
||||
license.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
license.Wrapping = fyne.TextWrapWord
|
||||
|
||||
url, _ := url.Parse("https://github.com/pressly/goose")
|
||||
authorsURL, _ := url.Parse("https://github.com/pressly/goose/graphs/contributors")
|
||||
|
||||
return widget.NewAccordionItem("goose v3.24.3", container.NewVBox(
|
||||
summary,
|
||||
container.NewHBox(
|
||||
widget.NewHyperlink("Source", url),
|
||||
widget.NewLabel("|"),
|
||||
widget.NewHyperlink("Authors", authorsURL),
|
||||
),
|
||||
license,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *mainWindow) generateGopsutilAccordionItem() *widget.AccordionItem {
|
||||
summary := widget.NewLabel(lang.L("about_dialog.licenses_tab.dependency.gopsutil"))
|
||||
summary.Wrapping = fyne.TextWrapWord
|
||||
|
||||
license := widget.NewLabel(`gopsutil is distributed under BSD license reproduced below.
|
||||
|
||||
Copyright (c) 2014, WAKAYAMA Shirou
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
* Neither the name of the gopsutil authors nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
-------
|
||||
internal/common/binary.go in the gopsutil is copied and modified from golang/encoding/binary.go.
|
||||
|
||||
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.`)
|
||||
license.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
license.Wrapping = fyne.TextWrapWord
|
||||
|
||||
url, _ := url.Parse("https://github.com/shirou/gopsutil")
|
||||
authorsURL, _ := url.Parse("https://github.com/shirou/gopsutil/graphs/contributors")
|
||||
|
||||
return widget.NewAccordionItem("gopsutil v3.24.5", container.NewVBox(
|
||||
summary,
|
||||
container.NewHBox(
|
||||
widget.NewHyperlink("Source", url),
|
||||
widget.NewLabel("|"),
|
||||
widget.NewHyperlink("Authors", authorsURL),
|
||||
),
|
||||
license,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *mainWindow) generateModerncSqliteAccordionItem() *widget.AccordionItem {
|
||||
summary := widget.NewLabel(lang.L("about_dialog.licenses_tab.dependency.modernc_sqlite"))
|
||||
summary.Wrapping = fyne.TextWrapWord
|
||||
|
||||
license := widget.NewLabel(`Copyright (c) 2017 The Sqlite Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
`)
|
||||
license.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
license.Wrapping = fyne.TextWrapWord
|
||||
|
||||
url, _ := url.Parse("https://gitlab.com/cznic/sqlite")
|
||||
authorsURL, _ := url.Parse("https://gitlab.com/cznic/sqlite/-/raw/master/AUTHORS")
|
||||
|
||||
return widget.NewAccordionItem("modernc/sqlite v1.37.1", container.NewVBox(
|
||||
summary,
|
||||
container.NewHBox(
|
||||
widget.NewHyperlink("Source", url),
|
||||
widget.NewLabel("|"),
|
||||
widget.NewHyperlink("Authors", authorsURL),
|
||||
),
|
||||
license,
|
||||
))
|
||||
}
|
||||
|
||||
func (m *mainWindow) generateSqlxAccrodionItem() *widget.AccordionItem {
|
||||
summary := widget.NewLabel(lang.L("about_dialog.licenses_tab.dependency.sqlx"))
|
||||
summary.Wrapping = fyne.TextWrapWord
|
||||
|
||||
license := widget.NewLabel(`Copyright (c) 2013, Jason Moiron
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.`)
|
||||
license.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
license.Wrapping = fyne.TextWrapWord
|
||||
|
||||
url, _ := url.Parse("https://github.com/jmoiron/sqlx")
|
||||
authorsURL, _ := url.Parse("https://github.com/jmoiron/sqlx/graphs/contributors")
|
||||
|
||||
return widget.NewAccordionItem("sqlx v1.4.0", container.NewVBox(
|
||||
summary,
|
||||
container.NewHBox(
|
||||
widget.NewHyperlink("Source", url),
|
||||
widget.NewLabel("|"),
|
||||
widget.NewHyperlink("Authors", authorsURL),
|
||||
),
|
||||
license,
|
||||
))
|
||||
}
|
@ -0,0 +1,221 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
|
||||
"bunker/client/internal/services/core"
|
||||
"bunker/client/internal/services/core/mainwindow/models"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
)
|
||||
|
||||
var errSysInfoHandlerAlreadyRegistered = errors.New("handler already registered")
|
||||
|
||||
func (m *mainWindow) generateSysInfoTab() *container.TabItem {
|
||||
lbl := widget.NewLabel(lang.L("about_dialog.sysinfo_tab.summary"))
|
||||
lbl.Wrapping = fyne.TextWrapWord
|
||||
|
||||
buildInfo, _ := debug.ReadBuildInfo()
|
||||
|
||||
var builtForOS, builtForArch string
|
||||
|
||||
for _, bi := range buildInfo.Settings {
|
||||
switch bi.Key {
|
||||
case "GOARCH":
|
||||
builtForArch = bi.Value
|
||||
case "GOOS":
|
||||
builtForOS = bi.Value
|
||||
}
|
||||
}
|
||||
|
||||
commons, _ := host.Info()
|
||||
|
||||
cpuInfo, _ := cpu.Info()
|
||||
|
||||
memInfo, _ := mem.VirtualMemory()
|
||||
|
||||
memoryTotal := memInfo.Total
|
||||
memoryTotalDivCount := 0
|
||||
|
||||
var memoryTotalString string
|
||||
|
||||
for {
|
||||
//nolint:mnd
|
||||
if memoryTotal > 1024 {
|
||||
memoryTotal /= 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
|
||||
}
|
20
client/internal/services/core/mainwindow/dto/tab.go
Normal file
20
client/internal/services/core/mainwindow/dto/tab.go
Normal 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
|
||||
}
|
117
client/internal/services/core/mainwindow/mainwindow.go
Normal file
117
client/internal/services/core/mainwindow/mainwindow.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
33
client/internal/services/core/mainwindow/mainwindow_menu.go
Normal file
33
client/internal/services/core/mainwindow/mainwindow_menu.go
Normal 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)
|
||||
}
|
@ -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.")
|
||||
}
|
@ -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())
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package mainwindow
|
||||
|
||||
func (m *mainWindow) initializeSwitcherMobile() {
|
||||
m.logger.Debug("Initializing mobile switcher...")
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package models
|
||||
|
||||
// SysInfoHandler содержит в себе информацию об обработчике для Markdown системной информации.
|
||||
type SysInfoHandler struct {
|
||||
Handler func() string
|
||||
Name string
|
||||
}
|
18
client/internal/services/core/mainwindow/models/tab.go
Normal file
18
client/internal/services/core/mainwindow/models/tab.go
Normal 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
|
||||
}
|
8
client/internal/services/core/mainwindow/tabs.go
Normal file
8
client/internal/services/core/mainwindow/tabs.go
Normal file
@ -0,0 +1,8 @@
|
||||
package mainwindow
|
||||
|
||||
import (
|
||||
"bunker/client/internal/services/core/mainwindow/dto"
|
||||
)
|
||||
|
||||
func (m *mainWindow) AddTab(_ *dto.Tab) {
|
||||
}
|
21
client/internal/services/core/options.go
Normal file
21
client/internal/services/core/options.go
Normal 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()
|
||||
}
|
80
client/internal/services/core/options/dialog.go
Normal file
80
client/internal/services/core/options/dialog.go
Normal 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()
|
||||
}
|
14
client/internal/services/core/options/dto/option_pane.go
Normal file
14
client/internal/services/core/options/dto/option_pane.go
Normal file
@ -0,0 +1,14 @@
|
||||
package dto
|
||||
|
||||
import "fyne.io/fyne/v2"
|
||||
|
||||
// OptionPane is a DTO for registering options pane.
|
||||
type OptionPane struct {
|
||||
Widget fyne.CanvasObject
|
||||
SaveHandler OptionSaveHandler
|
||||
Name string
|
||||
}
|
||||
|
||||
// OptionSaveHandler is a signature for functions responsible for saving options, resides in service that registers own
|
||||
// options pane.
|
||||
type OptionSaveHandler func() error
|
17
client/internal/services/core/options/migrations.go
Normal file
17
client/internal/services/core/options/migrations.go
Normal file
@ -0,0 +1,17 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
//go:embed migrations
|
||||
var migrations embed.FS
|
||||
|
||||
func (o *options) registerMigrations() error {
|
||||
if err := o.db.RegisterMigrations("core/options", migrations); err != nil {
|
||||
return fmt.Errorf("register migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS options (
|
||||
key VARCHAR(1024) NOT NULL UNIQUE,
|
||||
value VARCHAR(8192)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS options;
|
23
client/internal/services/core/options/models/option_pane.go
Normal file
23
client/internal/services/core/options/models/option_pane.go
Normal file
@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bunker/client/internal/services/core/options/dto"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
)
|
||||
|
||||
// OptionPane это описание одной панели настроек в окне настроек.
|
||||
type OptionPane struct {
|
||||
Widget fyne.CanvasObject
|
||||
SaveHandler func() error
|
||||
Name string
|
||||
}
|
||||
|
||||
// OptionPaneFromDTO конвертирует параметры панели настроек из DTO во внутреннее представление.
|
||||
func OptionPaneFromDTO(dtoData *dto.OptionPane) *OptionPane {
|
||||
return &OptionPane{
|
||||
Widget: dtoData.Widget,
|
||||
SaveHandler: dtoData.SaveHandler,
|
||||
Name: dtoData.Name,
|
||||
}
|
||||
}
|
99
client/internal/services/core/options/options.go
Normal file
99
client/internal/services/core/options/options.go
Normal 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
|
||||
}
|
27
client/internal/services/core/options/widgets.go
Normal file
27
client/internal/services/core/options/widgets.go
Normal 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
|
||||
}
|
14
client/internal/services/core/translations.go
Normal file
14
client/internal/services/core/translations.go
Normal 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{}
|
@ -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
|
||||
}
|
91
client/internal/services/core/translations/translations.go
Normal file
91
client/internal/services/core/translations/translations.go
Normal 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
|
||||
}
|
4
client/internal/services/features/accounts.go
Normal file
4
client/internal/services/features/accounts.go
Normal file
@ -0,0 +1,4 @@
|
||||
package features
|
||||
|
||||
// ServiceNameAccounts is a name for accounts service.
|
||||
const ServiceNameAccounts = "features/accounts"
|
93
client/internal/services/features/accounts/accounts.go
Normal file
93
client/internal/services/features/accounts/accounts.go
Normal 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
|
||||
}
|
53
client/internal/services/features/accounts/login_dialog.go
Normal file
53
client/internal/services/features/accounts/login_dialog.go
Normal 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()
|
||||
}
|
4
client/internal/services/features/tasks.go
Normal file
4
client/internal/services/features/tasks.go
Normal file
@ -0,0 +1,4 @@
|
||||
package features
|
||||
|
||||
// ServiceNameTasks is a name for tasks service.
|
||||
const ServiceNameTasks = "features/tasks"
|
85
client/internal/services/features/tasks/tasks.go
Normal file
85
client/internal/services/features/tasks/tasks.go
Normal 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
|
||||
}
|
21
client/internal/widgets/toolbar_label.go
Normal file
21
client/internal/widgets/toolbar_label.go
Normal 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
|
||||
}
|
23
client/internal/widgets/toolbar_progressbar.go
Normal file
23
client/internal/widgets/toolbar_progressbar.go
Normal 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
8
commons/appids.go
Normal 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
6
commons/exit_codes.go
Normal file
@ -0,0 +1,6 @@
|
||||
package commons
|
||||
|
||||
const (
|
||||
// ExitCodeAppStopFailed указывает на ошибку остановки работы приложения.
|
||||
ExitCodeAppStopFailed = 2
|
||||
)
|
28
commons/goose_logger.go
Normal file
28
commons/goose_logger.go
Normal 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
7
commons/ids.go
Normal 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
14
commons/version.go
Normal 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
72
go.mod
Normal 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
189
go.sum
Normal 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
33
scripts/version_generator.sh
Executable 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
16
server/Dockerfile
Normal 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
18
server/Dockerfile.debug
Normal 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
6
server/Taskfile.yml
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
localdev: ./localdevzone
|
||||
cmd: ./cmd
|
5
server/cmd/Taskfile.yml
Normal file
5
server/cmd/Taskfile.yml
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
bunkerd: ./bunkerd
|
44
server/cmd/bunkerd/Taskfile.yml
Normal file
44
server/cmd/bunkerd/Taskfile.yml
Normal 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}}
|
63
server/cmd/bunkerd/main.go
Normal file
63
server/cmd/bunkerd/main.go
Normal 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)
|
||||
}
|
196
server/internal/application/application.go
Normal file
196
server/internal/application/application.go
Normal 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
|
||||
}
|
45
server/internal/application/logger.go
Normal file
45
server/internal/application/logger.go
Normal 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...)
|
||||
}
|
26
server/internal/application/server.go
Normal file
26
server/internal/application/server.go
Normal 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{}{}
|
||||
}()
|
||||
}
|
80
server/internal/application/service.go
Normal file
80
server/internal/application/service.go
Normal file
@ -0,0 +1,80 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrServiceAlreadyRegistered returns if trying to register a service with name already taken by other service.
|
||||
ErrServiceAlreadyRegistered = errors.New("service with such name already registered")
|
||||
// ErrServiceNotFound returns if trying to gather service with unknown name.
|
||||
ErrServiceNotFound = errors.New("service with such name wasn't found")
|
||||
)
|
||||
|
||||
// Service is an interface every service should conform to. Specific services will have own interface for
|
||||
// cross-service interation.
|
||||
type Service interface {
|
||||
// Configure configures service. Called after ConnectDependencies and before LaunchStartupTasks.
|
||||
Configure() error
|
||||
// ConnectDependencies gets 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
|
||||
}
|
44
server/internal/services/core/database.go
Normal file
44
server/internal/services/core/database.go
Normal file
@ -0,0 +1,44 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// ServiceNameDatabase is a name for database service.
|
||||
const ServiceNameDatabase = "core/database"
|
||||
|
||||
var (
|
||||
// ErrDatabase indicates that error appeared somewhere in database service.
|
||||
ErrDatabase = errors.New("database service")
|
||||
// ErrDatabaseIsInvalid indicates that database service implementation is invalid.
|
||||
ErrDatabaseIsInvalid = errors.New("database service implementation is invalid")
|
||||
)
|
||||
|
||||
// Database is an interface for database service.
|
||||
type Database interface {
|
||||
// Exec is a proxy for ExecContext from sqlx.
|
||||
Exec(ctx context.Context, query string, params ...interface{}) error
|
||||
// Get is a proxy for GetContext from sqlx.
|
||||
Get(ctx context.Context, target interface{}, query string, params ...interface{}) error
|
||||
// NamedExec is a proxy for NamedExecContext from sqlx.
|
||||
NamedExec(ctx context.Context, query string, param interface{}) error
|
||||
// RegisterMigrations registers migrations for applying from other services. Migrations should reside
|
||||
// in "migrations" directory in passed filesystem.
|
||||
RegisterMigrations(moduleName string, fs fs.FS) error
|
||||
// Select is a proxy for SelectContext from sqlx.
|
||||
Select(ctx context.Context, target interface{}, query string, params ...interface{}) error
|
||||
// Transaction is a wrapper for transactions processing which wraps sqlx's transactions.
|
||||
Transaction(ctx context.Context) (DatabaseTransaction, error)
|
||||
}
|
||||
|
||||
// DatabaseTransaction is an interface for database transactions controllers implementations.
|
||||
type DatabaseTransaction interface {
|
||||
Apply(steps ...TransactionFunc) error
|
||||
}
|
||||
|
||||
// TransactionFunc is a function that is used in transactions to mangle with data.
|
||||
type TransactionFunc func(*sqlx.Tx) error
|
83
server/internal/services/core/database/connection.go
Normal file
83
server/internal/services/core/database/connection.go
Normal 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
|
||||
}
|
73
server/internal/services/core/database/database.go
Normal file
73
server/internal/services/core/database/database.go
Normal 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
|
||||
}
|
82
server/internal/services/core/database/migrations.go
Normal file
82
server/internal/services/core/database/migrations.go
Normal 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
|
||||
}
|
69
server/internal/services/core/database/queries.go
Normal file
69
server/internal/services/core/database/queries.go
Normal 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
|
||||
}
|
81
server/internal/services/core/database/transaction.go
Normal file
81
server/internal/services/core/database/transaction.go
Normal 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
|
||||
}
|
16
server/internal/services/core/httpserver.go
Normal file
16
server/internal/services/core/httpserver.go
Normal 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{}
|
91
server/internal/services/core/httpserver/httpserver.go
Normal file
91
server/internal/services/core/httpserver/httpserver.go
Normal 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
|
||||
}
|
85
server/internal/services/core/httpserver/server.go
Normal file
85
server/internal/services/core/httpserver/server.go
Normal 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
|
||||
}
|
16
server/internal/services/core/options.go
Normal file
16
server/internal/services/core/options.go
Normal 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{}
|
17
server/internal/services/core/options/migrations.go
Normal file
17
server/internal/services/core/options/migrations.go
Normal file
@ -0,0 +1,17 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
//go:embed migrations
|
||||
var migrations embed.FS
|
||||
|
||||
func (o *options) registerMigrations() error {
|
||||
if err := o.db.RegisterMigrations("core/options", migrations); err != nil {
|
||||
return fmt.Errorf("register migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,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;
|
79
server/internal/services/core/options/options.go
Normal file
79
server/internal/services/core/options/options.go
Normal 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
|
||||
}
|
23
server/localdevzone/Taskfile.yml
Normal file
23
server/localdevzone/Taskfile.yml
Normal 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
|
91
server/localdevzone/bunkerd/Taskfile.yml
Normal file
91
server/localdevzone/bunkerd/Taskfile.yml
Normal 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
|
27
server/localdevzone/bunkerd/docker-compose.debug.yaml
Normal file
27
server/localdevzone/bunkerd/docker-compose.debug.yaml
Normal 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
|
26
server/localdevzone/bunkerd/docker-compose.yaml
Normal file
26
server/localdevzone/bunkerd/docker-compose.yaml
Normal 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
|
15
server/localdevzone/common/Taskfile.yml
Normal file
15
server/localdevzone/common/Taskfile.yml
Normal 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
Loading…
x
Reference in New Issue
Block a user