package appui import ( "flag" "fmt" "os" "os/exec" "runtime" "strings" "gioui.org/app" "gioui.org/op" "gioui.org/unit" "gioui.org/x/explorer" "git.julianfamily.org/keepassgo/internal/api" "git.julianfamily.org/keepassgo/internal/apiapproval" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/appui/platform" "git.julianfamily.org/keepassgo/internal/browserbridge" "git.julianfamily.org/keepassgo/internal/grpcaddr" "git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/vault" ) func Main() { mode := flag.String("mode", "", "window mode: desktop or phone") stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets") grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable") flag.Parse() resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS)) resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "") resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS)) width := unit.Dp(1180) height := unit.Dp(760) if strings.EqualFold(resolvedMode, "phone") { width = unit.Dp(412) height = unit.Dp(915) } go func() { w := new(app.Window) options := []app.Option{app.Title(productName)} if shouldUsePreviewWindowSize(resolvedMode, runtime.GOOS) { options = append(options, app.Size(width, height)) } w.Option(options...) if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil { panic(err) } if !strings.EqualFold(runtime.GOOS, "android") { os.Exit(0) } }() app.Main() } func defaultGRPCAddr(goos string) string { return grpcaddr.Default(goos) } func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { ensureBrowserNativeHosts() var ops op.Ops manager := &session.Manager{} ui := newUIWithSession(mode, manager, paths) ui.fileExplorer = explorer.NewExplorer(w) ui.invalidate = w.Invalidate ui.clipboardWriter = platform.NewClipboardWriter(runtime.GOOS, w.Invalidate) host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty }) if err != nil { ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err) } else if host != nil { ui.apiHost = host ui.auditLog = host.Server().AuditLog() ui.state.AuditLog = ui.auditLog ui.grpcAddress = host.Address() ui.state.Approvals = &uiApprovalManager{server: host.Server()} host.Server().SetChangeNotifier(func() { ui.state.Dirty = true ui.invalidate() }) host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate) defer func() { _ = host.Stop() }() } for { e := w.Event() ui.fileExplorer.ListenEvents(e) switch e := e.(type) { case app.DestroyEvent: return e.Err case app.FrameEvent: gtx := app.NewContext(&ops, e) ui.processBackgroundActions() ui.layout(gtx) platform.ProcessClipboardWrites(gtx, ui.clipboardWriter) e.Frame(gtx.Ops) } } } func ensureBrowserNativeHosts() { if runtime.GOOS != "linux" { return } appBinaryPath, err := os.Executable() if err != nil { return } if err := browserbridge.EnsureNativeHostManifests(appBinaryPath); err != nil { platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo browser native host registration failed: %v", err)) } } type uiApprovalManager struct { server *api.Server } func (m *uiApprovalManager) Pending() []apiapproval.Request { if m == nil || m.server == nil { return nil } return m.server.ApprovalBroker().Pending() } func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { if m == nil || m.server == nil { return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured") } return m.server.ResolveApproval(id, outcome) } type uiSession struct { model vault.Model locked bool } func (s *uiSession) HasVault() bool { return len(s.model.Entries) > 0 || len(s.model.Templates) > 0 || len(s.model.RecycleBin) > 0 || len(s.model.Groups) > 0 || s.locked } func (s *uiSession) IsLocked() bool { return s.locked } func (s *uiSession) IsRemote() bool { return false } func (s *uiSession) Current() (vault.Model, error) { if s.locked { return vault.Model{}, session.ErrLocked } return s.model, nil } func (s *uiSession) Replace(model vault.Model) { s.model = model } func (s *uiSession) Lock() error { s.locked = true return nil } func (s *uiSession) Unlock(vault.MasterKey) error { if !s.locked { return nil } s.locked = false return nil } func pickExistingFile() (string, error) { if path, err := runFilePicker("kdialog", "--getopenfilename", "--title", "Choose KeePass file"); err == nil { return path, nil } if path, err := runFilePicker("zenity", "--file-selection", "--title=Choose KeePass file"); err == nil { return path, nil } return "", fmt.Errorf("no supported file picker found; install kdialog or zenity") } func runFilePicker(name string, args ...string) (string, error) { if _, err := exec.LookPath(name); err != nil { return "", err } cmd := exec.Command(name, args...) output, err := cmd.Output() if err != nil { return "", err } return parsePickedFilePath(output) } func parsePickedFilePath(output []byte) (string, error) { lines := strings.Split(strings.ReplaceAll(string(output), "\r\n", "\n"), "\n") for i := len(lines) - 1; i >= 0; i-- { line := strings.TrimSpace(lines[i]) if line == "" { continue } if strings.HasPrefix(line, "/") || strings.HasPrefix(line, "~/") { return line, nil } } return "", fmt.Errorf("file picker did not return a path") }