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 | ||||
| } | ||||
							
								
								
									
										23
									
								
								server/internal/services/core/httpserver.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								server/internal/services/core/httpserver.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| package core | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"net/http" | ||||
| ) | ||||
|  | ||||
| // 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. | ||||
| type HTTPServer interface { | ||||
| 	// RegisterHandler registers HTTP handler. | ||||
| 	RegisterHandler(method, path string, handler http.HandlerFunc) | ||||
| 	// RegisterMiddleware registers HTTP server middlewares. | ||||
| 	RegisterMiddleware(middleware HTTPMiddlewareFunc) | ||||
| } | ||||
|  | ||||
| // HTTPMiddlewareFunc is a function that acts as middleware for HTTP requests. | ||||
| type HTTPMiddlewareFunc func(fn http.HandlerFunc) http.HandlerFunc | ||||
| @@ -0,0 +1,8 @@ | ||||
| package httpserver | ||||
|  | ||||
| import "net/http" | ||||
|  | ||||
| func (h *httpServer) defaultHandler(w http.ResponseWriter, _ *http.Request) { | ||||
| 	w.WriteHeader(http.StatusNotFound) | ||||
| 	_, _ = w.Write([]byte("Unknown path.")) | ||||
| } | ||||
							
								
								
									
										98
									
								
								server/internal/services/core/httpserver/httpserver.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								server/internal/services/core/httpserver/httpserver.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| 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 | ||||
| 	httpMux *http.ServeMux | ||||
|  | ||||
| 	middlewares []core.HTTPMiddlewareFunc | ||||
| } | ||||
|  | ||||
| // 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...") | ||||
|  | ||||
| 	h.middlewares = make([]core.HTTPMiddlewareFunc, 0) | ||||
|  | ||||
| 	h.RegisterMiddleware(h.requestLoggingMiddleware) | ||||
|  | ||||
| 	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 | ||||
| } | ||||
							
								
								
									
										24
									
								
								server/internal/services/core/httpserver/request_logger.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								server/internal/services/core/httpserver/request_logger.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| package httpserver | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| func (h *httpServer) requestLoggingMiddleware(fn http.HandlerFunc) http.HandlerFunc { | ||||
| 	return func(w http.ResponseWriter, r *http.Request) { | ||||
| 		startTime := time.Now() | ||||
|  | ||||
| 		fn(w, r) | ||||
|  | ||||
| 		h.logger.Info( | ||||
| 			"HTTP request.", | ||||
| 			"remote_addr", r.RemoteAddr, | ||||
| 			"user_agent", r.UserAgent(), | ||||
| 			"host", r.Host, | ||||
| 			"path", fmt.Sprintf("%s %s", r.Method, r.RequestURI), | ||||
| 			"duration", time.Since(startTime), | ||||
| 		) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										104
									
								
								server/internal/services/core/httpserver/server.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								server/internal/services/core/httpserver/server.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| package httpserver | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"time" | ||||
|  | ||||
| 	"bunker/server/internal/services/core" | ||||
|  | ||||
| 	"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) | ||||
| 	} | ||||
|  | ||||
| 	h.httpMux = new(http.ServeMux) | ||||
|  | ||||
| 	// Default catch-all handler. | ||||
| 	h.RegisterHandler("", "/", h.defaultHandler) | ||||
| 	h.RegisterHandler(http.MethodGet, "/api/v1/socket", h.handleWebsocketRequest) | ||||
|  | ||||
| 	h.httpSrv = &http.Server{ | ||||
| 		Addr:              httpSrvAddr, | ||||
| 		Handler:           h.httpMux, | ||||
| 		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) RegisterHandler(method, path string, handler http.HandlerFunc) { | ||||
| 	h.httpMux.HandleFunc(fmt.Sprintf("%s %s", method, path), func(w http.ResponseWriter, r *http.Request) { | ||||
| 		for i := len(h.middlewares) - 1; i >= 0; i-- { | ||||
| 			handler = h.middlewares[i](handler) | ||||
| 		} | ||||
|  | ||||
| 		handler(w, r) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (h *httpServer) RegisterMiddleware(middleware core.HTTPMiddlewareFunc) { | ||||
| 	h.middlewares = append(h.middlewares, middleware) | ||||
| } | ||||
|  | ||||
| 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 | ||||
| } | ||||
							
								
								
									
										14
									
								
								server/internal/services/core/options.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								server/internal/services/core/options.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| 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. | ||||
| 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 | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user