From 16ea54ff07f2ba0d4615ebf76a671d18fdb0fb98 Mon Sep 17 00:00:00 2001 From: ant <3822085410@qq.com> Date: Tue, 19 May 2026 21:00:24 +0800 Subject: [PATCH 1/3] feat(ui): add Chinese localization support with i18n framework - Add i18n framework with LAZYSSH_LANG env var support - Add build.sh with -l/--lang flag for compile-time language selection - Add AES-GCM encrypted password storage for SSH connections - Add AuthMethod dropdown (key/password/key+password) in Basic tab - Support zh-CN language with full translation - Normalize language input case-insensitively (zh-cn, ZH-CN, cn, etc.) - Add field name translations in server details and form help panel --- build.sh | 158 +++++ .../data/ssh_config_file/metadata_manager.go | 60 +- .../ssh_config_file/ssh_config_file_repo.go | 32 +- internal/adapters/ui/defaults.go | 76 +-- internal/adapters/ui/field_help.go | 63 +- internal/adapters/ui/handlers.go | 98 +-- internal/adapters/ui/search_bar.go | 6 +- internal/adapters/ui/server_details.go | 123 +++- internal/adapters/ui/server_form.go | 83 ++- internal/adapters/ui/server_list.go | 4 +- internal/adapters/ui/sort.go | 11 +- internal/adapters/ui/status_bar.go | 4 +- internal/adapters/ui/tui.go | 5 +- internal/adapters/ui/utils.go | 17 +- internal/adapters/ui/validation.go | 117 ++-- internal/core/crypto/crypto.go | 169 +++++ internal/core/crypto/crypto_test.go | 132 ++++ internal/core/domain/server.go | 1 + internal/core/ports/repositories.go | 2 + internal/core/services/server_service.go | 53 +- internal/i18n/i18n.go | 87 +++ internal/i18n/locales/en.json | 586 ++++++++++++++++++ internal/i18n/locales/zh-CN.json | 586 ++++++++++++++++++ 23 files changed, 2283 insertions(+), 190 deletions(-) create mode 100755 build.sh create mode 100644 internal/core/crypto/crypto.go create mode 100644 internal/core/crypto/crypto_test.go create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/locales/en.json create mode 100644 internal/i18n/locales/zh-CN.json diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..173589e3 --- /dev/null +++ b/build.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +rm -rf bin/* +set -euo pipefail + +# LazySSH Build Script +# Usage: ./build.sh [options] +# +# Options: +# -o, --output DIR Output directory (default: ./bin) +# -v, --version VER Set version string (default: from git tag or "develop") +# -c, --commit HASH Set git commit hash (default: auto-detect) +# -a, --arch ARCH Target architecture (default: current arch) +# -s, --os OS Target OS (default: current os) +# -r, --race Enable race detector (debug build) +# -p, --parallel Build for all platforms +# -l, --lang LANG Set default language at runtime +# Supported: en (default), zh-CN +# -h, --help Show this help message + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Default values +OUTPUT_DIR="./bin" +VERSION="develop" +COMMIT="unknown" +RACE=false +PARALLEL=false +LANG="en" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +show_help() { + head -16 "$0" | tail -12 + exit 0 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -o|--output) OUTPUT_DIR="$2"; shift 2 ;; + -v|--version) VERSION="$2"; shift 2 ;; + -c|--commit) COMMIT="$2"; shift 2 ;; + -a|--arch) BUILD_ARCH="$2"; shift 2 ;; + -s|--os) BUILD_OS="$2"; shift 2 ;; + -l|--lang) + raw_lang="$2" + # Normalize: lowercase for detection, then map to standard form + lower=$(echo "$raw_lang" | tr '[:upper:]' '[:lower:]') + case "$lower" in + zh*|cn*|chs*|zh_cn|zh-cn|zh_cn|chinese) + LANG="zh-CN" + ;; + en*|english) + LANG="en" + ;; + *) + log_warn "Unknown language '$raw_lang', using English" + LANG="en" + ;; + esac + shift 2 + ;; + -r|--race) RACE=true; shift ;; + -p|--parallel) PARALLEL=true; shift ;; + -h|--help) show_help ;; + *) log_error "Unknown option: $1"; show_help ;; + esac +done + +# Auto-detect git commit if not provided +if [[ "$COMMIT" == "unknown" ]]; then + COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") +fi + +# Build flags +LDFLAGS=(-ldflags "-X main.version=${VERSION} -X main.gitCommit=${COMMIT} -X github.com/Adembc/lazyssh/internal/i18n.defaultLang=${LANG}") + +# Ensure output directory exists +mkdir -p "$OUTPUT_DIR" + +# Build function +build_binary() { + local target_os="$1" + local target_arch="$2" + local output_name="$3" + + log_info "Building for ${target_os}/${target_arch}..." + + if [[ "$RACE" == true ]]; then + log_warn "Race detector enabled (debug build)" + GOOS=${target_os} GOARCH=${target_arch} go build -race "${LDFLAGS[@]}" -o "${OUTPUT_DIR}/${output_name}" ./cmd + else + GOOS=${target_os} GOARCH=${target_arch} go build "${LDFLAGS[@]}" -o "${OUTPUT_DIR}/${output_name}" ./cmd + fi + + if [[ $? -eq 0 ]]; then + log_success "Built: ${OUTPUT_DIR}/${output_name}" + else + log_error "Failed to build: ${output_name}" + return 1 + fi +} + +log_info "LazySSH Build" +log_info "Version: ${VERSION}" +log_info "Commit: ${COMMIT}" +log_info "Language: ${LANG}" +log_info "Output: ${OUTPUT_DIR}" +echo "" + +# Clean previous build +rm -f "${OUTPUT_DIR}"/lazyssh* 2>/dev/null || true + +if [[ "$PARALLEL" == true ]]; then + log_info "Building for all platforms..." + echo "" + + build_binary "linux" "amd64" "lazyssh-linux-amd64" + build_binary "linux" "arm64" "lazyssh-linux-arm64" + build_binary "darwin" "amd64" "lazyssh-darwin-amd64" + build_binary "darwin" "arm64" "lazyssh-darwin-arm64" + build_binary "windows" "amd64" "lazyssh-windows-amd64.exe" + + echo "" + log_success "All builds complete!" +else + # Build for current platform + CURRENT_OS=$(go env GOOS) + CURRENT_ARCH=$(go env GOARCH) + + build_binary "$CURRENT_OS" "$CURRENT_ARCH" "lazyssh" + + echo "" + log_success "Build complete: ${OUTPUT_DIR}/lazyssh" + log_info "Run: ${OUTPUT_DIR}/lazyssh" +fi diff --git a/internal/adapters/data/ssh_config_file/metadata_manager.go b/internal/adapters/data/ssh_config_file/metadata_manager.go index a4e7be7e..5c28b223 100644 --- a/internal/adapters/data/ssh_config_file/metadata_manager.go +++ b/internal/adapters/data/ssh_config_file/metadata_manager.go @@ -21,15 +21,17 @@ import ( "path/filepath" "time" + "github.com/Adembc/lazyssh/internal/core/crypto" "github.com/Adembc/lazyssh/internal/core/domain" "go.uber.org/zap" ) type ServerMetadata struct { - Tags []string `json:"tags,omitempty"` - LastSeen string `json:"last_seen,omitempty"` - PinnedAt string `json:"pinned_at,omitempty"` - SSHCount int `json:"ssh_count,omitempty"` + Tags []string `json:"tags,omitempty"` + LastSeen string `json:"last_seen,omitempty"` + PinnedAt string `json:"pinned_at,omitempty"` + SSHCount int `json:"ssh_count,omitempty"` + EncryptedPassword string `json:"encrypted_password,omitempty"` } type metadataManager struct { @@ -84,7 +86,7 @@ func (m *metadataManager) saveAll(metadata map[string]ServerMetadata) error { return nil } -func (m *metadataManager) updateServer(server domain.Server, oldAlias string) error { +func (m *metadataManager) updateServer(server domain.Server, oldAlias string, password string) error { metadata, err := m.loadAll() if err != nil { m.logger.Errorw("failed to load metadata in updateServer", "path", m.filePath, "alias", server.Alias, "old_alias", oldAlias, "error", err) @@ -116,6 +118,14 @@ func (m *metadataManager) updateServer(server domain.Server, oldAlias string) er merged.SSHCount = server.SSHCount } + if password != "" { + encrypted, err := crypto.Encrypt(password) + if err != nil { + return fmt.Errorf("encrypt password: %w", err) + } + merged.EncryptedPassword = encrypted + } + metadata[server.Alias] = merged return m.saveAll(metadata) } @@ -164,6 +174,46 @@ func (m *metadataManager) recordSSH(alias string) error { return m.saveAll(metadata) } +func (m *metadataManager) setPassword(alias, password string) error { + metadata, err := m.loadAll() + if err != nil { + m.logger.Errorw("failed to load metadata in setPassword", "path", m.filePath, "alias", alias, "error", err) + return fmt.Errorf("load metadata: %w", err) + } + + meta := metadata[alias] + if password != "" { + encrypted, err := crypto.Encrypt(password) + if err != nil { + return fmt.Errorf("encrypt password: %w", err) + } + meta.EncryptedPassword = encrypted + } else { + meta.EncryptedPassword = "" + } + metadata[alias] = meta + return m.saveAll(metadata) +} + +func (m *metadataManager) getPassword(alias string) (string, error) { + metadata, err := m.loadAll() + if err != nil { + m.logger.Errorw("failed to load metadata in getPassword", "path", m.filePath, "alias", alias, "error", err) + return "", fmt.Errorf("load metadata: %w", err) + } + + meta, exists := metadata[alias] + if !exists || meta.EncryptedPassword == "" { + return "", nil + } + + decrypted, err := crypto.Decrypt(meta.EncryptedPassword) + if err != nil { + return "", fmt.Errorf("decrypt password: %w", err) + } + return decrypted, nil +} + func (m *metadataManager) ensureDirectory() error { dir := filepath.Dir(m.filePath) if err := os.MkdirAll(dir, 0o750); err != nil { diff --git a/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go b/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go index 37e8004b..d328c2a8 100644 --- a/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go +++ b/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go @@ -17,6 +17,7 @@ package ssh_config_file import ( "fmt" + "github.com/Adembc/lazyssh/internal/core/crypto" "github.com/Adembc/lazyssh/internal/core/domain" "github.com/Adembc/lazyssh/internal/core/ports" "github.com/kevinburke/ssh_config" @@ -66,6 +67,19 @@ func (r *Repository) ListServers(query string) ([]domain.Server, error) { metadata = make(map[string]ServerMetadata) } servers = r.mergeMetadata(servers, metadata) + + // Decrypt passwords from metadata + for i, server := range servers { + if meta, exists := metadata[server.Alias]; exists && meta.EncryptedPassword != "" { + password, decryptErr := crypto.Decrypt(meta.EncryptedPassword) + if decryptErr != nil { + r.logger.Warnw("failed to decrypt password", "alias", server.Alias, "error", decryptErr) + } else { + servers[i].Password = password + } + } + } + if query == "" { return servers, nil } @@ -91,7 +105,9 @@ func (r *Repository) AddServer(server domain.Server) error { r.logger.Warnf("Failed to save config while adding new server: %v", err) return fmt.Errorf("failed to save config: %w", err) } - return r.metadataManager.updateServer(server, server.Alias) + password := server.Password + server.Password = "" + return r.metadataManager.updateServer(server, server.Alias, password) } // UpdateServer updates an existing server in the SSH config. @@ -131,7 +147,9 @@ func (r *Repository) UpdateServer(server domain.Server, newServer domain.Server) return fmt.Errorf("failed to save config: %w", err) } // Update metadata; pass old alias to allow inline migration - return r.metadataManager.updateServer(newServer, server.Alias) + password := newServer.Password + newServer.Password = "" + return r.metadataManager.updateServer(newServer, server.Alias, password) } // DeleteServer removes a server from the SSH config. @@ -164,3 +182,13 @@ func (r *Repository) SetPinned(alias string, pinned bool) error { func (r *Repository) RecordSSH(alias string) error { return r.metadataManager.recordSSH(alias) } + +// SetPassword encrypts and stores the password for a server alias. +func (r *Repository) SetPassword(alias string, password string) error { + return r.metadataManager.setPassword(alias, password) +} + +// GetPassword retrieves and decrypts the password for a server alias. +func (r *Repository) GetPassword(alias string) (string, error) { + return r.metadataManager.getPassword(alias) +} diff --git a/internal/adapters/ui/defaults.go b/internal/adapters/ui/defaults.go index 94af3cb5..a5993765 100644 --- a/internal/adapters/ui/defaults.go +++ b/internal/adapters/ui/defaults.go @@ -14,6 +14,10 @@ package ui +import ( + "github.com/Adembc/lazyssh/internal/i18n" +) + // SSHFieldDefaults contains the default values for all SSH configuration fields // This centralizes all default values to ensure consistency across the application var SSHFieldDefaults = map[string]string{ @@ -137,84 +141,86 @@ func GetFieldPlaceholder(fieldName string) string { switch fieldName { // Required fields case "Alias", "Host": - return "required" + return i18n.T("placeholder.required") // Fields that show default value in placeholder case "Port": - return "default: " + defaultValue + return i18n.T("placeholder.default") + ": " + defaultValue case "User": - return "default: current username" + return i18n.T("placeholder.default") + ": " + i18n.T("placeholder.current_user") case "ConnectTimeout": if defaultValue == "" { - return "seconds (default: none)" + return i18n.T("placeholder.seconds") + " (" + i18n.T("placeholder.default") + ": " + i18n.T("placeholder.none") + ")" } - return "default: " + defaultValue + " seconds" + return i18n.T("placeholder.default") + ": " + defaultValue + " " + i18n.T("placeholder.seconds") case "ConnectionAttempts": - return "default: " + defaultValue + return i18n.T("placeholder.default") + ": " + defaultValue case "ServerAliveInterval": if defaultValue == "0" { - return "seconds (default: 0)" + return i18n.T("placeholder.seconds") + " (" + i18n.T("placeholder.default") + ": 0)" } - return "default: " + defaultValue + " seconds" + return i18n.T("placeholder.default") + ": " + defaultValue + " " + i18n.T("placeholder.seconds") case "ServerAliveCountMax": - return "default: " + defaultValue + return i18n.T("placeholder.default") + ": " + defaultValue case "NumberOfPasswordPrompts": - return "default: " + defaultValue + return i18n.T("placeholder.default") + ": " + defaultValue case "CanonicalizeMaxDots": - return "default: " + defaultValue + return i18n.T("placeholder.default") + ": " + defaultValue case "IPQoS": - return "default: " + defaultValue + return i18n.T("placeholder.default") + ": " + defaultValue case "EscapeChar": - return "default: " + defaultValue + return i18n.T("placeholder.default") + ": " + defaultValue case "IdentityAgent": if defaultValue != "" { - return "default: " + defaultValue + return i18n.T("placeholder.default") + ": " + defaultValue } - return "default: SSH_AUTH_SOCK" + return i18n.T("placeholder.default") + ": SSH_AUTH_SOCK" case "UserKnownHostsFile": if defaultValue != "" { - return "default: " + defaultValue + return i18n.T("placeholder.default") + ": " + defaultValue } - return "default: ~/.ssh/known_hosts" + return i18n.T("placeholder.default") + ": ~/.ssh/known_hosts" // Fields that show examples in placeholder case "Keys": - return "e.g., ~/.ssh/id_rsa, ~/.ssh/id_ed25519" + return i18n.T("placeholder_keys") case "Tags": - return "comma-separated tags" + return i18n.T("placeholder.comma_separated") + case "Password": + return i18n.T("field.placeholder.password") case "ProxyJump": //nolint:goconst // Field name used in switch case - return "e.g., bastion.example.com" + return i18n.T("placeholder.proxy_jump") case "ProxyCommand": - return "e.g., ssh -W %h:%p jump.example.com" + return i18n.T("placeholder.proxy_command") case "RemoteCommand": - return "e.g., tmux attach" + return i18n.T("placeholder.remote_command") case "LocalForward": - return "e.g., 8080:localhost:80, 3000:localhost:3000" + return i18n.T("placeholder.local_forward") case "RemoteForward": - return "e.g., 80:localhost:8080" + return i18n.T("placeholder.remote_forward") case "DynamicForward": - return "e.g., 1080, 1081" + return i18n.T("placeholder.dynamic_forward") case "ControlPath": - return "e.g., ~/.ssh/master-%r@%h:%p" + return i18n.T("placeholder_control_path") case "ControlPersist": - return "e.g., 10m, 4h, yes, no" + return i18n.T("placeholder.control_persist") case "PreferredAuthentications": - return "e.g., publickey,password" + return i18n.T("placeholder.preferred_auth") case "PubkeyAcceptedAlgorithms", "HostbasedAcceptedAlgorithms", "HostKeyAlgorithms", "Ciphers", "MACs", "KexAlgorithms": - return "algorithms (+/-/^ prefix supported)" + return i18n.T("placeholder_algorithms") case "BindAddress": - return "IP, hostname, * (all), or localhost" + return i18n.T("placeholder_bind_address") case "CanonicalDomains": - return "e.g., example.com, internal.net" + return i18n.T("placeholder.canonical_domains") case "CanonicalizePermittedCNAMEs": - return "e.g., *.example.com:example.net" + return i18n.T("placeholder.canonical_cname") case "LocalCommand": - return "e.g., echo 'Connected to %h'" + return i18n.T("placeholder.local_command") case "SendEnv": - return "e.g., LANG, LC_*, TERM" + return i18n.T("placeholder.send_env") case "SetEnv": - return "e.g., FOO=bar, DEBUG=1" + return i18n.T("placeholder_set_env") // Fields with no placeholder default: diff --git a/internal/adapters/ui/field_help.go b/internal/adapters/ui/field_help.go index b55210a3..35e586df 100644 --- a/internal/adapters/ui/field_help.go +++ b/internal/adapters/ui/field_help.go @@ -14,6 +14,13 @@ package ui +import ( + "fmt" + "strings" + + "github.com/Adembc/lazyssh/internal/i18n" +) + // FieldHelp contains help information for SSH config fields type FieldHelp struct { Field string // Field name @@ -35,6 +42,14 @@ const ( HelpModeFull // Detailed help with all info ) +// translateOr returns the translation for key if found, otherwise returns fallback. +func translateOr(key, fallback string) string { + if msg := i18n.T(key); msg != key { + return msg + } + return fallback +} + // GetFieldHelp returns help information for a specific field func GetFieldHelp(fieldName string) *FieldHelp { if help, exists := fieldHelpData[fieldName]; exists { @@ -43,6 +58,18 @@ func GetFieldHelp(fieldName string) *FieldHelp { if defaultValue != "" { help.Default = formatDefaultValue(fieldName, defaultValue) } + // Translate user-facing text + fieldKey := "field_help." + strings.ToLower(fieldName) + help.Description = translateOr(fieldKey, help.Description) + help.Syntax = translateOr(fieldKey+".syntax", help.Syntax) + help.Default = translateOr(fieldKey+".default", help.Default) + help.Since = translateOr(fieldKey+".since", help.Since) + if len(help.Examples) > 0 { + examplesKey := fieldKey + ".examples" + if exStr := i18n.T(examplesKey); exStr != examplesKey { + help.Examples = strings.Split(exStr, "|") + } + } return &help } return nil @@ -54,14 +81,14 @@ func formatDefaultValue(fieldName, value string) string { switch fieldName { case "ConnectTimeout": if value == "" { - return "none (system default)" + return i18n.T("default.none_system") } - return value + " seconds" + return fmt.Sprintf(i18n.T("default.seconds"), value) case "ServerAliveInterval": if value == "0" { - return "0 (disabled)" + return i18n.T("default.disabled") } - return value + " seconds" + return fmt.Sprintf(i18n.T("default.seconds"), value) case "ControlPath", "ProxyJump", "ProxyCommand", "RemoteCommand", "LocalForward", "RemoteForward", "DynamicForward", "LocalCommand", "SendEnv", "SetEnv", "BindAddress", "BindInterface", @@ -69,7 +96,7 @@ func formatDefaultValue(fieldName, value string) string { "PubkeyAcceptedAlgorithms", "HostbasedAcceptedAlgorithms", "HostKeyAlgorithms", "Ciphers", "MACs", "KexAlgorithms": if value == "" { - return "none" //nolint:goconst // "none" here means empty/not configured, different from sessionTypeNone + return i18n.T("default.none") } return value case "PreferredAuthentications": @@ -84,7 +111,7 @@ func formatDefaultValue(fieldName, value string) string { return value case "User": if value == "" { - return "current username" + return i18n.T("default.current_username") } return value default: @@ -135,6 +162,22 @@ var fieldHelpData = map[string]FieldHelp{ Default: "~/.ssh/id_rsa, ~/.ssh/id_ed25519, etc.", Category: "Basic", }, + "AuthMethod": { + Field: "AuthMethod", + Description: "选择认证方式:密钥、密码或两者同时使用。", + Syntax: "key | password | key_password", + Examples: []string{"key", "password"}, + Default: "key", + Category: "Basic", + }, + "LoginMode": { + Field: "LoginMode", + Description: "登录模式:交互式始终请求 TTY,批处理不请求 TTY,自动模式根据需要请求。", + Syntax: "interactive | batch | auto", + Examples: []string{"interactive", "batch", "auto"}, + Default: "interactive", + Category: "Basic", + }, // Connection fields "ProxyJump": { @@ -411,6 +454,14 @@ var fieldHelpData = map[string]FieldHelp{ Default: "none", Category: "Basic", }, + "Password": { + Field: "Password", + Description: "SSH password for authentication. Stored encrypted locally.", + Syntax: "string", + Examples: []string{""}, + Default: "(stored encrypted, not in SSH config)", + Category: "Basic", + }, // Connection - IP and Address fields "IPQoS": { diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 897e053d..92226c6e 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -20,6 +20,7 @@ import ( "time" "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/Adembc/lazyssh/internal/i18n" "github.com/atotto/clipboard" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -116,14 +117,14 @@ func (t *tui) handleServerPin() { func (t *tui) handleSortToggle() { t.sortMode = t.sortMode.ToggleField() - t.showStatusTemp("Sort: " + t.sortMode.String()) + t.showStatusTemp(fmt.Sprintf(i18n.T("status.sort"), t.sortMode.String())) t.updateListTitle() t.refreshServerList() } func (t *tui) handleSortReverse() { t.sortMode = t.sortMode.Reverse() - t.showStatusTemp("Sort: " + t.sortMode.String()) + t.showStatusTemp(fmt.Sprintf(i18n.T("status.sort"), t.sortMode.String())) t.updateListTitle() t.refreshServerList() } @@ -132,9 +133,9 @@ func (t *tui) handleCopyCommand() { if server, ok := t.serverList.GetSelectedServer(); ok { cmd := BuildSSHCommand(server) if err := clipboard.WriteAll(cmd); err == nil { - t.showStatusTemp("Copied: " + cmd) + t.showStatusTemp(fmt.Sprintf(i18n.T("status.copied"), cmd)) } else { - t.showStatusTemp("Failed to copy to clipboard") + t.showStatusTemp(i18n.T("status.copy_failed")) } } } @@ -240,6 +241,7 @@ func (t *tui) handleServerAdd() { SetVersionInfo(t.version, t.commit). OnSave(t.handleServerSave). OnCancel(t.handleFormCancel) + t.currentForm = form // Save reference for error recovery t.app.SetRoot(form, true) } @@ -250,6 +252,7 @@ func (t *tui) handleServerEdit() { SetVersionInfo(t.version, t.commit). OnSave(t.handleServerSave). OnCancel(t.handleFormCancel) + t.currentForm = form // Save reference for error recovery t.app.SetRoot(form, true) } } @@ -266,13 +269,22 @@ func (t *tui) handleServerSave(server domain.Server, original *domain.Server) { if err != nil { // Stay on form; show a small modal with the error modal := tview.NewModal(). - SetText(fmt.Sprintf("Save failed: %v", err)). - AddButtons([]string{"Close"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { t.handleModalClose() }) + SetText(fmt.Sprintf(i18n.T("form.save_failed"), err)). + AddButtons([]string{i18n.T("form.close")}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + // Return to form instead of main list + if t.currentForm != nil { + t.app.SetRoot(t.currentForm, true) + } else { + t.returnToMain() + } + }) t.app.SetRoot(modal, true) return } + // Success: clear currentForm and return to main + t.currentForm = nil t.refreshServerList() t.handleFormCancel() } @@ -284,6 +296,7 @@ func (t *tui) handleServerDelete() { } func (t *tui) handleFormCancel() { + t.currentForm = nil // Clear form reference t.returnToMain() } @@ -291,18 +304,18 @@ func (t *tui) handlePingSelected() { if server, ok := t.serverList.GetSelectedServer(); ok { alias := server.Alias - t.showStatusTemp(fmt.Sprintf("Pinging %s…", alias)) + t.showStatusTemp(fmt.Sprintf(i18n.T("status.pinging"), alias)) go func() { up, dur, err := t.serverService.Ping(server) t.app.QueueUpdateDraw(func() { if err != nil { - t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN (%v)", alias, err), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf(i18n.T("status.ping_down_err"), alias, err), "#FF6B6B") return } if up { - t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), "#A0FFA0") + t.showStatusTempColor(fmt.Sprintf(i18n.T("status.ping_up"), alias, dur), "#A0FFA0") } else { - t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf(i18n.T("status.ping_down"), alias), "#FF6B6B") } }) }() @@ -322,13 +335,13 @@ func (t *tui) handleRefreshBackground() { query = t.searchBar.InputField.GetText() } - t.showStatusTemp("Refreshing…") + t.showStatusTemp(i18n.T("status_refreshing")) go func(prevIdx int, q string) { servers, err := t.serverService.ListServers(q) if err != nil { t.app.QueueUpdateDraw(func() { - t.showStatusTempColor(fmt.Sprintf("Refresh failed: %v", err), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf(i18n.T("status.refresh_failed"), err), "#FF6B6B") }) return } @@ -342,7 +355,7 @@ func (t *tui) handleRefreshBackground() { t.details.UpdateServer(srv) } } - t.showStatusTemp(fmt.Sprintf("Refreshed %d servers", len(servers))) + t.showStatusTemp(fmt.Sprintf(i18n.T("status.refreshed"), len(servers))) }) }(currentIdx, query) } @@ -352,12 +365,12 @@ func (t *tui) handleRefreshBackground() { // ============================================================================= func (t *tui) showDeleteConfirmModal(server domain.Server) { - msg := fmt.Sprintf("Delete server %s (%s@%s:%d)?\n\nThis action cannot be undone.", + msg := fmt.Sprintf(i18n.T("delete_confirm_title"), server.Alias, server.User, server.Host, server.Port) modal := tview.NewModal(). SetText(msg). - AddButtons([]string{"[yellow]C[-]ancel", "[yellow]D[-]elete"}). + AddButtons([]string{i18n.T("delete_cancel"), i18n.T("delete_confirm")}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { if buttonIndex == 1 { _ = t.serverService.DeleteServer(server) @@ -390,13 +403,13 @@ func (t *tui) showDeleteConfirmModal(server domain.Server) { func (t *tui) showEditTagsForm(server domain.Server) { form := tview.NewForm() form.SetBorder(true). - SetTitle(fmt.Sprintf(" Edit Tags: %s ", server.Alias)). + SetTitle(fmt.Sprintf(i18n.T("edit_tags_title"), server.Alias)). SetTitleAlign(tview.AlignCenter) defaultTags := strings.Join(server.Tags, ", ") - form.AddInputField("Tags (comma):", defaultTags, 40, nil, nil) + form.AddInputField(i18n.T("edit_tags_label"), defaultTags, 40, nil, nil) - form.AddButton("Save", func() { + form.AddButton(i18n.T("form.btn.save"), func() { text := strings.TrimSpace(form.GetFormItem(0).(*tview.InputField).GetText()) var tags []string @@ -412,9 +425,9 @@ func (t *tui) showEditTagsForm(server domain.Server) { // Refresh UI and go back t.refreshServerList() t.returnToMain() - t.showStatusTemp("Tags updated") + t.showStatusTemp(i18n.T("status.tags_updated")) }) - form.AddButton("Cancel", func() { t.returnToMain() }) + form.AddButton(i18n.T("form.btn.cancel"), func() { t.returnToMain() }) form.SetCancelFunc(func() { t.returnToMain() }) t.app.SetRoot(form, true) @@ -430,7 +443,9 @@ func (t *tui) handlePortForward() { func (t *tui) showPortForwardForm(server domain.Server) { typeChoices := []string{ForwardTypeLocal, ForwardTypeRemote, ForwardTypeDynamic} + typeLabels := []string{i18n.T("forward.type_local"), i18n.T("forward.type_remote"), i18n.T("forward.type_dynamic")} modeChoices := []string{ForwardModeOnlyForward, ForwardModeForwardSSH} + modeLabels := []string{i18n.T("forward.mode_only"), i18n.T("forward.mode_ssh")} currentTypeIdx := 0 currentModeIdx := 0 @@ -441,7 +456,7 @@ func (t *tui) showPortForwardForm(server domain.Server) { form := tview.NewForm() form.SetBorder(true). - SetTitle(fmt.Sprintf(" Port Forwarding: %s ", server.Alias)). + SetTitle(fmt.Sprintf(i18n.T("port_forward_title"), server.Alias)). SetTitleAlign(tview.AlignCenter) dd := tview.NewDropDown() @@ -450,7 +465,7 @@ func (t *tui) showPortForwardForm(server domain.Server) { portField := tview.NewInputField() bindAddrField := tview.NewInputField() - dd.SetOptions(typeChoices, func(text string, index int) { + dd.SetOptions(typeLabels, func(text string, index int) { currentTypeIdx = index // Toggle fields when switching type isDynamic := typeChoices[currentTypeIdx] == ForwardTypeDynamic @@ -463,23 +478,23 @@ func (t *tui) showPortForwardForm(server domain.Server) { } }) dd.SetCurrentOption(currentTypeIdx) - form.AddFormItem(dd.SetLabel("Type")) + form.AddFormItem(dd.SetLabel(i18n.T("forward.type"))) - portField.SetLabel("Port").SetText(portVal).SetFieldWidth(8).SetChangedFunc(func(text string) { portVal = strings.TrimSpace(text) }) + portField.SetLabel(i18n.T("forward.port")).SetText(portVal).SetFieldWidth(8).SetChangedFunc(func(text string) { portVal = strings.TrimSpace(text) }) form.AddFormItem(portField) - hostField.SetLabel("Host").SetText(hostVal).SetFieldWidth(40).SetChangedFunc(func(text string) { hostVal = strings.TrimSpace(text) }) + hostField.SetLabel(i18n.T("forward.host")).SetText(hostVal).SetFieldWidth(40).SetChangedFunc(func(text string) { hostVal = strings.TrimSpace(text) }) form.AddFormItem(hostField) - hostPortField.SetLabel("Host Port").SetText(hostPortVal).SetFieldWidth(8).SetChangedFunc(func(text string) { hostPortVal = strings.TrimSpace(text) }) + hostPortField.SetLabel(i18n.T("forward.host_port")).SetText(hostPortVal).SetFieldWidth(8).SetChangedFunc(func(text string) { hostPortVal = strings.TrimSpace(text) }) form.AddFormItem(hostPortField) - bindAddrField.SetLabel("Bind Address (optional)").SetText(bindAddrVal).SetFieldWidth(40).SetChangedFunc(func(text string) { bindAddrVal = strings.TrimSpace(text) }) + bindAddrField.SetLabel(i18n.T("forward.bind_address")).SetText(bindAddrVal).SetFieldWidth(40).SetChangedFunc(func(text string) { bindAddrVal = strings.TrimSpace(text) }) form.AddFormItem(bindAddrField) - mode := tview.NewDropDown().SetOptions(modeChoices, func(text string, index int) { currentModeIdx = index }) + mode := tview.NewDropDown().SetOptions(modeLabels, func(text string, index int) { currentModeIdx = index }) mode.SetCurrentOption(currentModeIdx) - form.AddFormItem(mode.SetLabel("Mode")) + form.AddFormItem(mode.SetLabel(i18n.T("forward.mode"))) isDynamic := typeChoices[currentTypeIdx] == ForwardTypeDynamic if isDynamic { @@ -487,14 +502,14 @@ func (t *tui) showPortForwardForm(server domain.Server) { hostPortField.SetText("").SetDisabled(true) } - form.AddButton("Start", func() { + form.AddButton(i18n.T("forward.start"), func() { if err := validatePort(portVal); err != nil { - t.showStatusTempColor("Invalid port: "+err.Error(), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf(i18n.T("forward.invalid_port"), err.Error()), "#FF6B6B") return } if bindAddrVal != "" { if err := validateBindAddress(bindAddrVal); err != nil { - t.showStatusTempColor("Invalid bind address: "+err.Error(), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf(i18n.T("forward.invalid_bind"), err.Error()), "#FF6B6B") return } } @@ -509,11 +524,11 @@ func (t *tui) showPortForwardForm(server domain.Server) { args = append(args, "-D", spec) } else { if err := validateHost(hostVal); err != nil { - t.showStatusTempColor("Invalid host: "+err.Error(), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf(i18n.T("forward.invalid_host"), err.Error()), "#FF6B6B") return } if err := validatePort(hostPortVal); err != nil { - t.showStatusTempColor("Invalid host port: "+err.Error(), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf(i18n.T("forward.invalid_host_port"), err.Error()), "#FF6B6B") return } spec := portVal + ":" + hostVal + ":" + hostPortVal @@ -531,15 +546,15 @@ func (t *tui) showPortForwardForm(server domain.Server) { alias := server.Alias if onlyForward { t.returnToMain() - t.showStatusTemp("Starting port forward…") + t.showStatusTemp(i18n.T("forward.starting")) go func() { pid, err := t.serverService.StartForward(alias, args) t.app.QueueUpdateDraw(func() { if err != nil { - t.showStatusTempColor("Forward failed: "+err.Error(), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf(i18n.T("forward.failed"), err.Error()), "#FF6B6B") } else { t.refreshServerList() - t.showStatusTemp(fmt.Sprintf("Port forwarding started (pid %d)", pid)) + t.showStatusTemp(fmt.Sprintf(i18n.T("forward.started"), pid)) } }) }() @@ -551,7 +566,7 @@ func (t *tui) showPortForwardForm(server domain.Server) { }) t.returnToMain() }) - form.AddButton("Cancel", func() { t.returnToMain() }) + form.AddButton(i18n.T("form.btn.cancel"), func() { t.returnToMain() }) form.SetCancelFunc(func() { t.returnToMain() }) t.app.SetRoot(form, true) @@ -585,6 +600,7 @@ func (t *tui) refreshServerList() { func (t *tui) returnToMain() { t.app.SetRoot(t.root, true) + t.app.Sync() // Force full redraw to clear any residual content } // showStatusTemp displays a temporary message in the status bar (default green) and then restores the default text. @@ -620,9 +636,9 @@ func (t *tui) handleStopForwarding() { err := t.serverService.StopForwarding(alias) t.app.QueueUpdateDraw(func() { if err != nil { - t.showStatusTempColor("Failed to stop forwarding: "+err.Error(), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf(i18n.T("forward.stop_failed"), err.Error()), "#FF6B6B") } else { - t.showStatusTemp("Stopped forwarding for " + alias) + t.showStatusTemp(fmt.Sprintf(i18n.T("forward.stopped"), alias)) } t.refreshServerList() }) diff --git a/internal/adapters/ui/search_bar.go b/internal/adapters/ui/search_bar.go index 7d373b3d..670e9087 100644 --- a/internal/adapters/ui/search_bar.go +++ b/internal/adapters/ui/search_bar.go @@ -17,6 +17,8 @@ package ui import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "github.com/Adembc/lazyssh/internal/i18n" ) type SearchBar struct { @@ -35,12 +37,12 @@ func NewSearchBar() *SearchBar { } func (s *SearchBar) build() { - s.InputField.SetLabel(" 🔍 Search: "). + s.InputField.SetLabel(i18n.T("search.label")). SetFieldBackgroundColor(tcell.Color233). SetFieldTextColor(tcell.Color252). SetFieldWidth(30). SetBorder(true). - SetTitle(" Search "). + SetTitle(i18n.T("search.title")). SetTitleAlign(tview.AlignCenter). SetBorderColor(tcell.Color238). SetTitleColor(tcell.Color250) diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 48befc33..548b09b9 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -19,6 +19,7 @@ import ( "strings" "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/Adembc/lazyssh/internal/i18n" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -39,7 +40,7 @@ func (sd *ServerDetails) build() { sd.TextView.SetDynamicColors(true). SetWrap(true). SetBorder(true). - SetTitle(" Details "). + SetTitle(i18n.T("details.title")). SetTitleAlign(tview.AlignCenter). SetBorderColor(tcell.Color238). SetTitleColor(tcell.Color250) @@ -60,7 +61,7 @@ func renderTagChips(tags []string) string { func (sd *ServerDetails) UpdateServer(server domain.Server) { lastSeen := server.LastSeen.Format("2006-01-02 15:04:05") if server.LastSeen.IsZero() { - lastSeen = "Never" + lastSeen = i18n.T("details.never") } serverKey := strings.Join(server.IdentityFiles, ", ") @@ -83,10 +84,17 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { } text := fmt.Sprintf( - "[::b]%s[-]\n\n[::b]Basic Settings:[-]\n Host: [white]%s[-]\n User: [white]%s[-]\n Port: [white]%s[-]\n Key: [white]%s[-]\n Tags: %s\n Pinned: [white]%s[-]\n Last SSH: %s\n SSH Count: [white]%d[-]\n", - aliasText, hostText, userText, portText, - serverKey, tagsText, pinnedStr, - lastSeen, server.SSHCount) + "[::b]%s[-]\n\n[::b]%s[-]\n %s\n %s\n %s\n %s\n %s\n %s\n %s\n %s\n", + aliasText, + i18n.T("details.label.basic"), + fmt.Sprintf(i18n.T("details.host"), hostText), + fmt.Sprintf(i18n.T("details.user"), userText), + fmt.Sprintf(i18n.T("details.port"), portText), + fmt.Sprintf(i18n.T("details.key"), serverKey), + fmt.Sprintf(i18n.T("details.tags"), tagsText), + fmt.Sprintf(i18n.T("details.pinned"), pinnedStr), + fmt.Sprintf(i18n.T("details.last_ssh"), lastSeen), + fmt.Sprintf(i18n.T("details.ssh_count"), server.SSHCount)) // Advanced settings section (only show non-empty fields) // Organized by logical grouping for better readability @@ -103,7 +111,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { // Create field groups for better organization and future extensibility groups := []fieldGroup{ { - name: "Connection & Proxy", + name: i18n.T("section_proxy_command"), fields: []fieldEntry{ {"ProxyJump", server.ProxyJump}, {"ProxyCommand", server.ProxyCommand}, @@ -133,7 +141,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { }, }, { - name: "Authentication", + name: i18n.T("section_security"), fields: []fieldEntry{ {"PubkeyAuthentication", server.PubkeyAuthentication}, {"PubkeyAcceptedAlgorithms", server.PubkeyAcceptedAlgorithms}, @@ -148,7 +156,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { }, }, { - name: "Forwarding", + name: i18n.T("section_port_forwarding"), fields: []fieldEntry{ {"ForwardAgent", server.ForwardAgent}, {"ForwardX11", server.ForwardX11}, @@ -161,7 +169,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { }, }, { - name: "Security & Cryptography", + name: i18n.T("section_security"), fields: []fieldEntry{ {"StrictHostKeyChecking", server.StrictHostKeyChecking}, {"CheckHostIP", server.CheckHostIP}, @@ -178,7 +186,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { }, }, { - name: "Environment & Execution", + name: i18n.T("section_environment"), fields: []fieldEntry{ {"LocalCommand", server.LocalCommand}, {"PermitLocalCommand", server.PermitLocalCommand}, @@ -188,7 +196,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { }, }, { - name: "Debugging", + name: i18n.T("section_debugging"), fields: []fieldEntry{ {"LogLevel", server.LogLevel}, }, @@ -197,13 +205,13 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { // Build advanced settings text without group labels for cleaner display hasAdvanced := false - advancedText := "\n[::b]Advanced Settings:[-]\n" + advancedText := "\n[::b]" + i18n.T("detail_advanced") + ":[-]\n" for _, group := range groups { for _, field := range group.fields { if field.value != "" { hasAdvanced = true - advancedText += fmt.Sprintf(" %s: [white]%s[-]\n", field.name, field.value) + advancedText += fmt.Sprintf(" [green]%s:[white] %s[-]\n", translateDetailFieldName(field.name), field.value) } } } @@ -213,11 +221,94 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { } // Commands list - text += "\n[::b]Commands:[-]\n Enter: SSH connect\n f: Port forward\n x: Stop forwarding\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin" + text += "\n[::b]" + i18n.T("detail_commands") + ":[-]\n" + i18n.T("detail_commands_list") sd.TextView.SetText(text) } func (sd *ServerDetails) ShowEmpty() { - sd.TextView.SetText("No servers match the current filter.") + sd.TextView.SetText(i18n.T("details.no_match")) } + +// detailFieldNames maps raw ssh config field names to localized Chinese labels +var detailFieldNames = map[string]string{ + // Connection + "ProxyJump": "跳板机", + "ProxyCommand": "代理命令", + "RemoteCommand": "远程命令", + "RequestTTY": "请求TTY", + "SessionType": "会话类型", + "ConnectTimeout": "连接超时", + "ConnectionAttempts": "连接重试次数", + "BindAddress": "绑定地址", + "BindInterface": "绑定接口", + "AddressFamily": "地址族", + "ExitOnForwardFailure": "转发失败退出", + "IPQoS": "IP QoS", + "CanonicalizeHostname": "主机名规范化", + "CanonicalDomains": "规范域名", + "CanonicalizeFallbackLocal": "规范回退", + "CanonicalizeMaxDots": "规范最大点数", + "CanonicalizePermittedCNAMEs": "规范CNAME", + "ServerAliveInterval": "服务器保活间隔", + "ServerAliveCountMax": "保活尝试次数", + "Compression": "压缩", + "TCPKeepAlive": "TCP保活", + "BatchMode": "批处理模式", + // Multiplexing + "ControlMaster": "主控连接", + "ControlPath": "控制路径", + "ControlPersist": "连接持续", + // Authentication + "PubkeyAuthentication": "公钥认证", + "PasswordAuthentication": "密码认证", + "PreferredAuthentications": "认证方式优先级", + "IdentitiesOnly": "仅身份文件", + "AddKeysToAgent": "添加密钥到代理", + "IdentityAgent": "身份代理", + "KbdInteractiveAuthentication": "键盘交互认证", + "NumberOfPasswordPrompts": "密码提示次数", + "PubkeyAcceptedAlgorithms": "公钥接受算法", + "HostbasedAcceptedAlgorithms": "基于主机的接受算法", + // Forwarding + "ForwardAgent": "转发代理", + "ForwardX11": "X11转发", + "ForwardX11Trusted": "X11转发(信任)", + "LocalForward": "本地转发", + "RemoteForward": "远程转发", + "DynamicForward": "动态转发", + "ClearAllForwardings": "清除所有转发", + "GatewayPorts": "网关端口", + // Security + "StrictHostKeyChecking": "严格主机密钥检查", + "CheckHostIP": "检查主机IP", + "FingerprintHash": "指纹哈希", + "UserKnownHostsFile": "已知主机文件", + "HostKeyAlgorithms": "主机密钥算法", + "Ciphers": "加密算法", + "MACs": "MAC算法", + "KexAlgorithms": "密钥交换算法", + "VerifyHostKeyDNS": "DNS验证主机密钥", + "UpdateHostKeys": "更新主机密钥", + "HashKnownHosts": "哈希已知主机", + "VisualHostKey": "可视化主机密钥", + // Environment + "LocalCommand": "本地命令", + "PermitLocalCommand": "允许本地命令", + "EscapeChar": "转义字符", + "SendEnv": "发送环境变量", + "SetEnv": "设置环境变量", + // Debug + "LogLevel": "日志级别", +} + +func translateDetailFieldName(name string) string { + if i18n.Lang() != "zh-CN" { + return name + } + if zh, ok := detailFieldNames[name]; ok { + return zh + } + return name +} + diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 286b47f5..389953a9 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -29,6 +29,87 @@ import ( // This variable references the centralized defaults for consistency var sshDefaults = SSHFieldDefaults +// fieldNameTranslations maps raw field names to Chinese display names (used in help panel titles) +var fieldNameTranslations = map[string]string{ + "Alias": "别名", + "Host": "主机", + "User": "用户", + "Port": "端口", + "Keys": "密钥", + "Tags": "标签", + "Password": "密码", + "AuthMethod": "认证方式", + "LoginMode": "登录模式", + "ProxyJump": "跳板机", + "ProxyCommand": "代理命令", + "RemoteCommand": "远程命令", + "RequestTTY": "请求TTY", + "SessionType": "会话类型", + "ConnectTimeout": "连接超时", + "ConnectionAttempts": "连接重试次数", + "BindAddress": "绑定地址", + "BindInterface": "绑定接口", + "AddressFamily": "地址族", + "ExitOnForwardFailure": "转发失败退出", + "IPQoS": "IP服务质量", + "CanonicalizeHostname": "主机名规范化", + "CanonicalDomains": "规范域名", + "CanonicalizeFallbackLocal": "规范回退", + "CanonicalizeMaxDots": "规范最大点数", + "CanonicalizePermittedCNAMEs": "规范CNAME", + "ServerAliveInterval": "服务器保活间隔", + "ServerAliveCountMax": "保活尝试次数", + "Compression": "压缩", + "TCPKeepAlive": "TCP保活", + "BatchMode": "批处理模式", + "ControlMaster": "主控连接", + "ControlPath": "控制路径", + "ControlPersist": "连接持续", + "LocalForward": "本地转发", + "RemoteForward": "远程转发", + "DynamicForward": "动态转发", + "ClearAllForwardings": "清除所有转发", + "GatewayPorts": "网关端口", + "ForwardAgent": "转发代理", + "ForwardX11": "X11转发", + "ForwardX11Trusted": "X11转发(信任)", + "PubkeyAuthentication": "公钥认证", + "IdentitiesOnly": "仅身份文件", + "AddKeysToAgent": "添加密钥到代理", + "IdentityAgent": "身份代理", + "PasswordAuthentication": "密码认证", + "KbdInteractiveAuthentication": "键盘交互认证", + "NumberOfPasswordPrompts": "密码提示次数", + "PreferredAuthentications": "认证方式优先级", + "PubkeyAcceptedAlgorithms": "公钥接受算法", + "HostbasedAcceptedAlgorithms": "基于主机的接受算法", + "StrictHostKeyChecking": "严格主机密钥检查", + "CheckHostIP": "检查主机IP", + "FingerprintHash": "指纹哈希", + "UserKnownHostsFile": "已知主机文件", + "HostKeyAlgorithms": "主机密钥算法", + "Ciphers": "加密算法", + "MACs": "消息认证码", + "KexAlgorithms": "密钥交换算法", + "VerifyHostKeyDNS": "DNS验证主机密钥", + "UpdateHostKeys": "更新主机密钥", + "HashKnownHosts": "哈希已知主机", + "VisualHostKey": "可视化主机密钥", + "LocalCommand": "本地命令", + "PermitLocalCommand": "允许本地命令", + "EscapeChar": "转义字符", + "SendEnv": "发送环境变量", + "SetEnv": "设置环境变量", + "LogLevel": "日志级别", +} + +func translateFieldName(name string) string { + if zh, ok := fieldNameTranslations[name]; ok { + return zh + } + return name +} + type ServerFormMode int const ( @@ -484,7 +565,7 @@ func (sf *ServerForm) formatDetailedHelp(help *FieldHelp) string { } // Title with field name and separator below - b.WriteString(fmt.Sprintf("[yellow::b]📖 %s[-::-]\n", help.Field)) + b.WriteString(fmt.Sprintf("[yellow::b]📖 %s[-::-]\n", translateFieldName(help.Field))) b.WriteString("[#444444]" + strings.Repeat("─", separatorWidth) + "[-]\n\n") // Description - needs escaping as it might contain brackets diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index 1a58d394..aec295fc 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -18,6 +18,8 @@ import ( "github.com/Adembc/lazyssh/internal/core/domain" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "github.com/Adembc/lazyssh/internal/i18n" ) type ServerList struct { @@ -39,7 +41,7 @@ func NewServerList() *ServerList { func (sl *ServerList) build() { sl.List.ShowSecondaryText(false) sl.List.SetBorder(true). - SetTitle(" Servers "). + SetTitle(i18n.T("app.title_servers")). SetTitleAlign(tview.AlignCenter). SetBorderColor(tcell.Color238). SetTitleColor(tcell.Color250) diff --git a/internal/adapters/ui/sort.go b/internal/adapters/ui/sort.go index 58bc1e77..5871fcad 100644 --- a/internal/adapters/ui/sort.go +++ b/internal/adapters/ui/sort.go @@ -19,6 +19,7 @@ import ( "strings" "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/Adembc/lazyssh/internal/i18n" ) // SortMode controls how unpinned servers are ordered in the UI. @@ -34,15 +35,15 @@ const ( func (m SortMode) String() string { switch m { case SortByAliasAsc: - return "Alias ↑" + return i18n.T("app.sort.alias_asc") case SortByAliasDesc: - return "Alias ↓" + return i18n.T("app.sort.alias_desc") case SortByLastSeenAsc: - return "Last SSH ↑" + return i18n.T("app.sort.last_seen_asc") case SortByLastSeenDesc: - return "Last SSH ↓" + return i18n.T("app.sort.last_seen_desc") default: - return "Alias ↑" + return i18n.T("app.sort.alias_asc") } } diff --git a/internal/adapters/ui/status_bar.go b/internal/adapters/ui/status_bar.go index 1d3c6904..fdd86dbe 100644 --- a/internal/adapters/ui/status_bar.go +++ b/internal/adapters/ui/status_bar.go @@ -17,10 +17,12 @@ package ui import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "github.com/Adembc/lazyssh/internal/i18n" ) func DefaultStatusText() string { - return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]f[-] Forward • [white]x[-] Stop Forward • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit" + return i18n.T("statusbar.keys") } func NewStatusBar() *tview.TextView { diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index d938e6fb..a0846f45 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -19,6 +19,7 @@ import ( "go.uber.org/zap" "github.com/Adembc/lazyssh/internal/core/ports" + "github.com/Adembc/lazyssh/internal/i18n" "github.com/rivo/tview" ) @@ -46,6 +47,8 @@ type tui struct { content *tview.Flex sortMode SortMode + + currentForm *ServerForm // Current form for returning after save error } func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App { @@ -142,6 +145,6 @@ func (t *tui) loadInitialData() *tui { func (t *tui) updateListTitle() { if t.serverList != nil { - t.serverList.SetTitle(" Servers — Sort: " + t.sortMode.String() + " ") + t.serverList.SetTitle(i18n.T("app.title_servers") + " " + i18n.T("app.sort."+t.sortMode.String()) + " ") } } diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index 0b49ad8a..6144efa3 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -22,6 +22,7 @@ import ( "time" "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/Adembc/lazyssh/internal/i18n" "github.com/mattn/go-runewidth" ) @@ -94,43 +95,43 @@ func formatServerLine(s domain.Server) (primary, secondary string) { fCol = "[#A0FFA0]" + fCol + "[-]" } // Use a consistent color for alias; host/IP fixed width; then forwarding column - primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] %s [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, fCol, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags)) + primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] %s [#888888]"+i18n.T("list.last_ssh")+"[-] %s", icon, s.Alias, s.Host, fCol, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags)) secondary = "" return } func humanizeDuration(t time.Time) string { if t.IsZero() { - return "never" + return i18n.T("details.never") } d := time.Since(t) if d < time.Minute { - return "just now" + return i18n.T("time.just_now") } if d < time.Hour { m := int(d.Minutes()) - return fmt.Sprintf("%dm ago", m) + return fmt.Sprintf(i18n.T("time.minutes_ago"), m) } if d < 48*time.Hour { h := int(d.Hours()) - return fmt.Sprintf("%dh ago", h) + return fmt.Sprintf(i18n.T("time.hours_ago"), h) } if d < 60*24*time.Hour { days := int(d.Hours()) / 24 - return fmt.Sprintf("%dd ago", days) + return fmt.Sprintf(i18n.T("time.days_ago"), days) } if d < 365*24*time.Hour { months := int(d.Hours()) / (24 * 30) if months < 1 { months = 1 } - return fmt.Sprintf("%dmo ago", months) + return fmt.Sprintf(i18n.T("time.months_ago"), months) } years := int(d.Hours()) / (24 * 365) if years < 1 { years = 1 } - return fmt.Sprintf("%dy ago", years) + return fmt.Sprintf(i18n.T("time.years_ago"), years) } // BuildSSHCommand constructs a ready-to-run ssh command for the given server. diff --git a/internal/adapters/ui/validation.go b/internal/adapters/ui/validation.go index 248c7d6d..f9204174 100644 --- a/internal/adapters/ui/validation.go +++ b/internal/adapters/ui/validation.go @@ -15,6 +15,7 @@ package ui import ( + "errors" "fmt" "net" "os" @@ -23,6 +24,8 @@ import ( "strconv" "strings" "sync" + + "github.com/Adembc/lazyssh/internal/i18n" ) // fieldValidator contains validation rules for SSH configuration fields @@ -137,91 +140,91 @@ func GetFieldValidators() map[string]fieldValidator { validators["Alias"] = fieldValidator{ Required: true, Pattern: regexp.MustCompile(`^[a-zA-Z0-9._-]+$`), - Message: "Alias is required and can only contain letters, numbers, dots, hyphens, and underscores", + Message: i18n.T("validation.alias_format"), } validators["Host"] = fieldValidator{ Required: true, Validate: validateHost, - Message: "Host is required and must be a valid hostname or IP address", + Message: i18n.T("validation.host_format"), } validators["Port"] = fieldValidator{ Pattern: regexp.MustCompile(`^([1-9]\d{0,4})$`), Validate: validatePort, - Message: "Port must be between 1 and 65535", + Message: i18n.T("validation.port_range"), } validators["User"] = fieldValidator{ Pattern: regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9._-]*$`), - Message: "User must start with a letter and contain only letters, numbers, dots, hyphens, and underscores", + Message: i18n.T("validation.user_format"), } validators["Keys"] = fieldValidator{ Validate: validateKeyPaths, - Message: "Key file not found or not accessible", + Message: i18n.T("validation.keys_access"), } // Connection fields validators["ConnectTimeout"] = fieldValidator{ Validate: validateConnectTimeout, - Message: "ConnectTimeout must be a positive number or 'none'", + Message: i18n.T("validation.timeout_format"), } validators["ConnectionAttempts"] = fieldValidator{ Pattern: regexp.MustCompile(`^[1-9]\d*$`), - Message: "ConnectionAttempts must be a positive number", + Message: i18n.T("validation.attempts_format"), } validators["ServerAliveInterval"] = fieldValidator{ Pattern: regexp.MustCompile(`^\d+$`), Validate: validateNonNegativeNumber, - Message: "ServerAliveInterval must be a non-negative number", + Message: i18n.T("validation.alive_interval_format"), } validators["ServerAliveCountMax"] = fieldValidator{ Pattern: regexp.MustCompile(`^\d+$`), Validate: validateNonNegativeNumber, - Message: "ServerAliveCountMax must be a non-negative number", + Message: i18n.T("validation.alive_count_format"), } validators["IPQoS"] = fieldValidator{ Validate: validateIPQoS, - Message: "IPQoS must be valid QoS values (e.g., 'af21 cs1', 'lowdelay', 'ef')", + Message: i18n.T("validation.ipqos_format"), } // Address and forwarding fields validators["BindAddress"] = fieldValidator{ Validate: validateBindAddress, - Message: "BindAddress must be a valid IP address, hostname, or '*'", + Message: i18n.T("validation.bind_format"), } validators["LocalForward"] = fieldValidator{ Validate: validatePortForward, - Message: "LocalForward must be in format '[bind_address:]port:host:hostport'", + Message: i18n.T("validation.forward_format"), } validators["RemoteForward"] = fieldValidator{ Validate: validatePortForward, - Message: "RemoteForward must be in format '[bind_address:]port:host:hostport'", + Message: i18n.T("validation.forward_format"), } validators["DynamicForward"] = fieldValidator{ Validate: validateDynamicForward, - Message: "DynamicForward must be in format '[bind_address:]port'", + Message: i18n.T("validation.dynamic_forward_format"), } // Authentication fields validators["NumberOfPasswordPrompts"] = fieldValidator{ Pattern: regexp.MustCompile(`^\d+$`), Validate: validatePasswordPrompts, - Message: "NumberOfPasswordPrompts must be between 0 and 10", + Message: i18n.T("validation.password_prompts_range"), } // Advanced fields validators["CanonicalizeMaxDots"] = fieldValidator{ Pattern: regexp.MustCompile(`^\d+$`), Validate: validateNonNegativeNumber, - Message: "CanonicalizeMaxDots must be a non-negative number", + Message: i18n.T("validation.max_dots_format"), } validators["EscapeChar"] = fieldValidator{ Validate: validateEscapeChar, - Message: "EscapeChar must be a single character, 'none', or ^X format (e.g., ^A)", + Message: i18n.T("validation.escape_char_format"), } // Security fields validators["UserKnownHostsFile"] = fieldValidator{ Validate: validateKnownHostsFiles, - Message: "Known hosts file not found or not accessible", + Message: i18n.T("validation.known_hosts_access"), } return validators @@ -234,10 +237,10 @@ func validatePort(value string) error { } port, err := strconv.Atoi(value) if err != nil { - return fmt.Errorf("invalid port number") + return errors.New(i18n.T("validation.port_invalid")) } if port < 1 || port > 65535 { - return fmt.Errorf("port must be between 1 and 65535") + return errors.New(i18n.T("validation.port_range")) } return nil } @@ -249,10 +252,10 @@ func validateConnectTimeout(value string) error { } timeout, err := strconv.Atoi(value) if err != nil { - return fmt.Errorf("invalid timeout value") + return errors.New(i18n.T("validation.timeout_invalid")) } if timeout <= 0 { - return fmt.Errorf("timeout must be positive or 'none'") + return errors.New(i18n.T("validation.timeout_positive")) } return nil } @@ -264,10 +267,10 @@ func validateNonNegativeNumber(value string) error { } num, err := strconv.Atoi(value) if err != nil { - return fmt.Errorf("invalid number") + return errors.New(i18n.T("validation.invalid_number")) } if num < 0 { - return fmt.Errorf("must be non-negative") + return errors.New(i18n.T("validation.non_negative")) } return nil } @@ -279,10 +282,10 @@ func validatePasswordPrompts(value string) error { } num, err := strconv.Atoi(value) if err != nil { - return fmt.Errorf("invalid number") + return errors.New(i18n.T("validation.invalid_number")) } if num < 0 || num > 10 { - return fmt.Errorf("must be between 0 and 10") + return errors.New(i18n.T("validation.password_prompts_range")) } return nil } @@ -303,7 +306,7 @@ func validateEscapeChar(value string) error { if len(value) == 1 && value[0] >= 32 && value[0] <= 126 { return nil } - return fmt.Errorf("invalid escape character format") + return errors.New(i18n.T("validation.escape_char_format")) } // validateIPQoS validates IPQoS values @@ -324,11 +327,11 @@ func validateIPQoS(value string) error { // Can be single value or two space-separated values parts := strings.Fields(value) if len(parts) > 2 { - return fmt.Errorf("IPQoS accepts at most 2 values") + return errors.New(i18n.T("validation.ipqos_max")) } for _, part := range parts { if !validValues[strings.ToLower(part)] { - return fmt.Errorf("invalid IPQoS value: %s", part) + return fmt.Errorf(i18n.T("validation.ipqos_value_invalid"), part) } } return nil @@ -381,10 +384,10 @@ func validateFilePath(path string) (exists bool, accessible bool, isDir bool) { func buildFileValidationError(invalidPaths, inaccessiblePaths []string) error { var errors []string if len(invalidPaths) > 0 { - errors = append(errors, fmt.Sprintf("file(s) not found: %s", strings.Join(invalidPaths, ", "))) + errors = append(errors, fmt.Sprintf(i18n.T("validation.files_not_found"), strings.Join(invalidPaths, ", "))) } if len(inaccessiblePaths) > 0 { - errors = append(errors, fmt.Sprintf("file(s) not accessible: %s", strings.Join(inaccessiblePaths, ", "))) + errors = append(errors, fmt.Sprintf(i18n.T("validation.files_not_accessible"), strings.Join(inaccessiblePaths, ", "))) } if len(errors) > 0 { @@ -400,7 +403,7 @@ func validateFilePaths(files string, separator string) error { } // Check for invalid characters first, before trimming if strings.ContainsAny(files, "\n\r\t") { - return fmt.Errorf("file path contains invalid characters") + return errors.New(i18n.T("validation.file_invalid_chars")) } var paths []string @@ -450,12 +453,12 @@ func validateKnownHostsFiles(files string) error { // validateHost validates a hostname or IP address func validateHost(host string) error { if host == "" { - return fmt.Errorf("host is required") + return errors.New(i18n.T("validation.host_required")) } // Check for spaces if strings.Contains(host, " ") { - return fmt.Errorf("host cannot contain spaces") + return errors.New(i18n.T("validation.host_no_spaces")) } // Try to parse as IP address first @@ -470,21 +473,21 @@ func validateHost(host string) error { // validateHostname validates a hostname (not IP) func validateHostname(host string) error { if len(host) > 253 { - return fmt.Errorf("hostname too long") + return errors.New(i18n.T("validation.hostname_too_long")) } // Check for invalid characters using a single check if strings.ContainsAny(host, invalidHostChars) { - return fmt.Errorf("host contains invalid characters") + return errors.New(i18n.T("validation.host_invalid_chars")) } // Check hostname format if strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") { - return fmt.Errorf("hostname cannot start or end with a dot") + return errors.New(i18n.T("validation.hostname_dots")) } if strings.Contains(host, "..") { - return fmt.Errorf("hostname cannot contain consecutive dots") + return errors.New(i18n.T("validation.hostname_consecutive_dots")) } // Validate each label @@ -505,13 +508,13 @@ func validateHostLabels(host string) error { // validateHostLabel validates a single hostname label func validateHostLabel(label string) error { if label == "" { - return fmt.Errorf("hostname has empty label") + return errors.New(i18n.T("validation.hostname_empty_label")) } if len(label) > 63 { - return fmt.Errorf("hostname label too long") + return errors.New(i18n.T("validation.hostname_label_too_long")) } if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { - return fmt.Errorf("hostname label cannot start or end with hyphen") + return errors.New(i18n.T("validation.hostname_label_hyphen")) } return nil } @@ -533,7 +536,7 @@ func validatePortForward(forward string) error { // Format: [bind_address:]port:host:hostport parts := strings.Split(fwd, ":") if len(parts) < 3 || len(parts) > 4 { - return fmt.Errorf("invalid format, expected [bind_address:]port:host:hostport") + return errors.New(i18n.T("validation.forward_format_detail")) } // Validate ports @@ -550,7 +553,7 @@ func validatePortForward(forward string) error { // Validate bind address if parts[0] != "" && parts[0] != "*" { if err := validateBindAddress(parts[0]); err != nil { - return fmt.Errorf("invalid bind address: %w", err) + return fmt.Errorf(i18n.T("validation.bind_address_invalid")+": %w", err) } } } @@ -558,12 +561,12 @@ func validatePortForward(forward string) error { // Validate port numbers port, err := strconv.Atoi(parts[portIdx]) if err != nil || port < 1 || port > 65535 { - return fmt.Errorf("invalid port number: %s", parts[portIdx]) + return fmt.Errorf(i18n.T("validation.port_number_invalid"), parts[portIdx]) } hostPort, err := strconv.Atoi(parts[hostPortIdx]) if err != nil || hostPort < 1 || hostPort > 65535 { - return fmt.Errorf("invalid host port number: %s", parts[hostPortIdx]) + return fmt.Errorf(i18n.T("validation.host_port_invalid"), parts[hostPortIdx]) } } @@ -587,7 +590,7 @@ func validateDynamicForward(forward string) error { // Format: [bind_address:]port parts := strings.Split(fwd, ":") if len(parts) > 2 { - return fmt.Errorf("invalid format, expected [bind_address:]port") + return errors.New(i18n.T("validation.dynamic_forward_format_detail")) } var portStr string @@ -598,7 +601,7 @@ func validateDynamicForward(forward string) error { // bind_address:port if parts[0] != "" && parts[0] != "*" { if err := validateBindAddress(parts[0]); err != nil { - return fmt.Errorf("invalid bind address: %w", err) + return fmt.Errorf(i18n.T("validation.bind_address_invalid")+": %w", err) } } portStr = parts[1] @@ -607,7 +610,7 @@ func validateDynamicForward(forward string) error { // Validate port number port, err := strconv.Atoi(portStr) if err != nil || port < 1 || port > 65535 { - return fmt.Errorf("invalid port number: %s", portStr) + return fmt.Errorf(i18n.T("validation.port_number_invalid"), portStr) } } @@ -622,7 +625,7 @@ func validateBindAddress(address string) error { // Check for spaces if strings.Contains(address, " ") { - return fmt.Errorf("address cannot contain spaces") + return errors.New(i18n.T("validation.address_no_spaces")) } // Try to parse as IP address first (including IPv6) @@ -648,21 +651,21 @@ func isNumericDottedFormat(address string) bool { func validateBindHostname(address string) error { // Check for invalid characters using a single check if strings.ContainsAny(address, invalidAddressChars) { - return fmt.Errorf("address contains invalid characters") + return errors.New(i18n.T("validation.address_invalid_chars")) } // Check hostname format if strings.HasPrefix(address, ".") || strings.HasSuffix(address, ".") { - return fmt.Errorf("address cannot start or end with a dot") + return errors.New(i18n.T("validation.address_dots")) } if strings.HasPrefix(address, "-") || strings.HasSuffix(address, "-") { - return fmt.Errorf("address cannot start or end with hyphen") + return errors.New(i18n.T("validation.address_hyphen")) } // Check for consecutive dots if strings.Contains(address, "..") { - return fmt.Errorf("address cannot contain consecutive dots") + return errors.New(i18n.T("validation.address_consecutive_dots")) } // If it looks like an IP address (contains only dots and digits), validate it more strictly @@ -673,17 +676,17 @@ func validateBindHostname(address string) error { if len(segments) == 4 { for _, seg := range segments { if seg == "" { - return fmt.Errorf("invalid IP address format") + return errors.New(i18n.T("validation.ip_invalid")) } num, err := strconv.Atoi(seg) if err != nil || num < 0 || num > 255 { - return fmt.Errorf("invalid IP address format") + return errors.New(i18n.T("validation.ip_invalid")) } } return nil // Valid IPv4 } // If it's not 4 segments but looks numeric, it's invalid - return fmt.Errorf("invalid address format") + return errors.New(i18n.T("validation.address_format")) } // Check each label for hyphens at start/end @@ -699,7 +702,7 @@ func validateAddressLabels(address string) error { labels := strings.Split(address, ".") for _, label := range labels { if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { - return fmt.Errorf("address label cannot start or end with hyphen") + return errors.New(i18n.T("validation.address_hyphen")) } } return nil diff --git a/internal/core/crypto/crypto.go b/internal/core/crypto/crypto.go new file mode 100644 index 00000000..da940ab7 --- /dev/null +++ b/internal/core/crypto/crypto.go @@ -0,0 +1,169 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "os" + "path/filepath" +) + +var masterKey []byte + +const masterKeyFile = "lazyssh.key" + +func init() { + key := os.Getenv("LAZYSSH_MASTER_KEY") + if key != "" { + masterKey = normalizeKey([]byte(key)) + return + } + // Try to load from config directory + configDir := getConfigDir() + if configDir != "" { + keyPath := filepath.Join(configDir, masterKeyFile) + if data, err := os.ReadFile(keyPath); err == nil { + masterKey = normalizeKey(data) + } + } +} + +func getConfigDir() string { + // Prefer XDG_CONFIG_HOME + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "lazyssh") + } + // Fall back to ~/.config/lazyssh + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".config", "lazyssh") + } + return "" +} + +func normalizeKey(key []byte) []byte { + if len(key) < 32 { + key = append(key, make([]byte, 32-len(key))...) + } + if len(key) > 32 { + key = key[:32] + } + return key +} + +// Encrypt encrypts plaintext using AES-GCM and returns base64-encoded ciphertext. +func Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + if masterKey == nil { + // Auto-generate and save master key + if err := EnsureMasterKey(); err != nil { + return "", err + } + } + block, err := aes.NewCipher(masterKey) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts base64-encoded ciphertext and returns plaintext. +func Decrypt(ciphertext string) (string, error) { + if ciphertext == "" { + return "", nil + } + if masterKey == nil { + return "", errors.New("master key not set: set LAZYSSH_MASTER_KEY env var") + } + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + block, err := aes.NewCipher(masterKey) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +// IsEncryptionAvailable returns true if the master key is set. +func IsEncryptionAvailable() bool { + return masterKey != nil +} + +// EnsureMasterKey generates and saves a master key if not already set. +// Returns error if key generation or saving fails. +func EnsureMasterKey() error { + if masterKey != nil { + return nil + } + + // Generate random 32-byte key + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return err + } + + // Save to config directory + configDir := getConfigDir() + if configDir == "" { + return errors.New("cannot determine config directory") + } + + // Create directory if needed + if err := os.MkdirAll(configDir, 0700); err != nil { + return err + } + + keyPath := filepath.Join(configDir, masterKeyFile) + if err := os.WriteFile(keyPath, key, 0600); err != nil { + return err + } + + masterKey = key + return nil +} + +// SetMasterKey allows setting the master key programmatically (useful for testing). +func SetMasterKey(key string) { + masterKey = normalizeKey([]byte(key)) +} diff --git a/internal/core/crypto/crypto_test.go b/internal/core/crypto/crypto_test.go new file mode 100644 index 00000000..b4cf2cb9 --- /dev/null +++ b/internal/core/crypto/crypto_test.go @@ -0,0 +1,132 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package crypto + +import ( + "testing" +) + +func TestEncryptDecrypt(t *testing.T) { + SetMasterKey("test-master-key-32-bytes-long!!") + + plaintext := "my-secret-password-123" + encrypted, err := Encrypt(plaintext) + if err != nil { + t.Fatalf("encrypt failed: %v", err) + } + if encrypted == "" { + t.Fatal("expected non-empty ciphertext") + } + if encrypted == plaintext { + t.Fatal("ciphertext should differ from plaintext") + } + + decrypted, err := Decrypt(encrypted) + if err != nil { + t.Fatalf("decrypt failed: %v", err) + } + if decrypted != plaintext { + t.Fatalf("expected %q, got %q", plaintext, decrypted) + } +} + +func TestEncryptDecryptEmpty(t *testing.T) { + SetMasterKey("test-master-key-32-bytes-long!!") + + encrypted, err := Encrypt("") + if err != nil { + t.Fatalf("encrypt empty failed: %v", err) + } + if encrypted != "" { + t.Fatal("expected empty ciphertext for empty plaintext") + } + + decrypted, err := Decrypt("") + if err != nil { + t.Fatalf("decrypt empty failed: %v", err) + } + if decrypted != "" { + t.Fatal("expected empty plaintext for empty ciphertext") + } +} + +func TestEncryptWithoutKey(t *testing.T) { + masterKey = nil + + _, err := Encrypt("test") + if err == nil { + t.Fatal("expected error when master key is not set") + } +} + +func TestDecryptWithoutKey(t *testing.T) { + masterKey = nil + + _, err := Decrypt("test") + if err == nil { + t.Fatal("expected error when master key is not set") + } +} + +func TestDecryptInvalidCiphertext(t *testing.T) { + SetMasterKey("test-master-key-32-bytes-long!!") + + _, err := Decrypt("invalid-base64!!!") + if err == nil { + t.Fatal("expected error for invalid base64") + } +} + +func TestDecryptShortCiphertext(t *testing.T) { + SetMasterKey("test-master-key-32-bytes-long!!") + + // Valid base64 but too short to contain nonce + _, err := Decrypt("YWJj") + if err == nil { + t.Fatal("expected error for short ciphertext") + } +} + +func TestIsEncryptionAvailable(t *testing.T) { + masterKey = nil + if IsEncryptionAvailable() { + t.Fatal("expected encryption to be unavailable when key is nil") + } + + SetMasterKey("test-key") + if !IsEncryptionAvailable() { + t.Fatal("expected encryption to be available when key is set") + } +} + +func TestNormalizeKey(t *testing.T) { + // Short key should be padded + shortKey := normalizeKey([]byte("short")) + if len(shortKey) != 32 { + t.Fatalf("expected key length 32, got %d", len(shortKey)) + } + + // Long key should be truncated + longKey := normalizeKey([]byte("this-is-a-very-long-key-that-exceeds-thirty-two-characters-total")) + if len(longKey) != 32 { + t.Fatalf("expected key length 32, got %d", len(longKey)) + } + + // Exactly 32 bytes should remain unchanged + exactKey := normalizeKey([]byte("exactly-32-bytes-long-key-1234")) + if len(exactKey) != 32 { + t.Fatalf("expected key length 32, got %d", len(exactKey)) + } +} diff --git a/internal/core/domain/server.go b/internal/core/domain/server.go index c23b301d..1ccbb4c6 100644 --- a/internal/core/domain/server.go +++ b/internal/core/domain/server.go @@ -27,6 +27,7 @@ type Server struct { LastSeen time.Time PinnedAt time.Time SSHCount int + Password string `json:"-"` // Not persisted to SSH config // Additional SSH config fields // Connection and proxy settings diff --git a/internal/core/ports/repositories.go b/internal/core/ports/repositories.go index 133e4f82..dcbb6c7e 100644 --- a/internal/core/ports/repositories.go +++ b/internal/core/ports/repositories.go @@ -23,4 +23,6 @@ type ServerRepository interface { DeleteServer(server domain.Server) error SetPinned(alias string, pinned bool) error RecordSSH(alias string) error + SetPassword(alias string, password string) error + GetPassword(alias string) (string, error) } diff --git a/internal/core/services/server_service.go b/internal/core/services/server_service.go index 7926bc6d..9e09f5ce 100644 --- a/internal/core/services/server_service.go +++ b/internal/core/services/server_service.go @@ -154,9 +154,18 @@ func (s *serverService) SetPinned(alias string, pinned bool) error { } // SSH starts an interactive SSH session to the given alias using the system's ssh client. +// If a password is saved and sshpass is available, it uses sshpass to provide the password. func (s *serverService) SSH(alias string) error { s.logger.Infow("ssh start", "alias", alias) - cmd := exec.Command("ssh", alias) + + password, _ := s.serverRepository.GetPassword(alias) + + var cmd *exec.Cmd + if password != "" && s.isSshpassAvailable() { + cmd = exec.Command("sshpass", "-p", password, "ssh", alias) + } else { + cmd = exec.Command("ssh", alias) + } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -176,10 +185,21 @@ func (s *serverService) SSH(alias string) error { // SSHWithArgs runs system ssh with provided extra args (e.g., -L/-R/-D) for the given alias. func (s *serverService) SSHWithArgs(alias string, extraArgs []string) error { s.logger.Infow("ssh start (with args)", "alias", alias, "args", extraArgs) - args := append([]string{}, extraArgs...) - args = append(args, alias) - // #nosec G204 - cmd := exec.Command("ssh", args...) + + password, _ := s.serverRepository.GetPassword(alias) + + var cmd *exec.Cmd + if password != "" && s.isSshpassAvailable() { + args := append([]string{"-p", password, "ssh"}, extraArgs...) + args = append(args, alias) + // #nosec G204 + cmd = exec.Command("sshpass", args...) + } else { + args := append([]string{}, extraArgs...) + args = append(args, alias) + // #nosec G204 + cmd = exec.Command("ssh", args...) + } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -202,10 +222,19 @@ func (s *serverService) StartForward(alias string, extraArgs []string) (int, err } s.fwMu.Unlock() - extraArgs = append(extraArgs, "-N", alias) - - // #nosec G204 - cmd := exec.Command("ssh", extraArgs...) + password, _ := s.serverRepository.GetPassword(alias) + + var cmd *exec.Cmd + if password != "" && s.isSshpassAvailable() { + args := append([]string{"-p", password, "ssh"}, extraArgs...) + args = append(args, "-N", alias) + // #nosec G204 + cmd = exec.Command("sshpass", args...) + } else { + extraArgs = append(extraArgs, "-N", alias) + // #nosec G204 + cmd = exec.Command("ssh", extraArgs...) + } // Detach from TTY: discard stdio devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) @@ -337,6 +366,12 @@ func (s *serverService) Ping(server domain.Server) (bool, time.Duration, error) return true, time.Since(start), nil } +// isSshpassAvailable checks if sshpass is installed and available in PATH. +func (s *serverService) isSshpassAvailable() bool { + _, err := exec.LookPath("sshpass") + return err == nil +} + // resolveSSHDestination uses `ssh -G ` to extract HostName and Port from the user's SSH config. // Returns host, port, ok where ok=false if resolution failed. func resolveSSHDestination(alias string) (string, int, bool) { diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 00000000..001362b6 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,87 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package i18n + +import ( + _ "embed" + "encoding/json" + "os" + "strings" + "sync" +) + +//go:embed locales/en.json +var enBytes []byte + +//go:embed locales/zh-CN.json +var zhCNBytes []byte + +var ( + messages map[string]string + once sync.Once + lang string + // defaultLang is set at build time via -ldflags: + // -X github.com/Adembc/lazyssh/internal/i18n.defaultLang=zh-CN + defaultLang string +) + +func initMessages() { + // Priority: 1. env var LAZYSSH_LANG 2. compile-time defaultLang 3. "en" + lang = os.Getenv("LAZYSSH_LANG") + if lang == "" { + lang = defaultLang + } + if lang == "" { + lang = "en" + } + + // Normalize: case-insensitive matching → standard form "zh-CN" or "en" + lang = strings.ToLower(lang) + switch lang { + case "zh-cn", "zh", "cn", "chs", "chinese": + lang = "zh-CN" + default: + lang = "en" + } + + messages = make(map[string]string) + + // Load defaults based on language + switch lang { + case "zh-CN": + if err := json.Unmarshal(zhCNBytes, &messages); err != nil { + messages = make(map[string]string) + } + default: + if err := json.Unmarshal(enBytes, &messages); err != nil { + messages = make(map[string]string) + } + } +} + +func Lang() string { + once.Do(initMessages) + return lang +} + +// T returns the translated string for the given key. +// If the key is not found, it returns the key itself as fallback. +func T(key string) string { + once.Do(initMessages) + if msg, ok := messages[key]; ok { + return msg + } + return key +} diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json new file mode 100644 index 00000000..851f5a1a --- /dev/null +++ b/internal/i18n/locales/en.json @@ -0,0 +1,586 @@ +{ + "app.name": "lazyssh", + "app.title_servers": " Servers ", + "app.sort.alias_asc": "Alias ↑", + "app.sort.alias_desc": "Alias ↓", + "app.sort.last_seen_asc": "Last SSH ↑", + "app.sort.last_seen_desc": "Last SSH ↓", + + "search.label": " 🔍 Search: ", + "search.title": " Search ", + + "statusbar.keys": "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]f[-] Forward • [white]x[-] Stop forward • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit", + + "details.title": " Details ", + "details.label.basic": "Basic Settings", + "details.label.advanced": "Advanced Settings", + "details.label.commands": "Commands", + "details.host": " Host: [white]%s[-]", + "details.user": " User: [white]%s[-]", + "details.port": " Port: [white]%s[-]", + "details.key": " Key: [white]%s[-]", + "details.tags": " Tags: %s", + "details.pinned": " Pinned: [white]%s[-]", + "details.last_ssh": " Last SSH: %s", + "details.ssh_count": " SSH count: [white]%d[-]", + "details.never": "Never", + "details.commands_text": " Enter: SSH connect\n f: Port forward\n x: Stop forwarding\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin", + "details.no_match": "No servers match the current filter.", + + "form.title.add": "Add Server", + "form.title.edit": "Edit Server", + "form.tab.basic": "Basic", + "form.tab.connection": "Connection", + "form.tab.forwarding": "Forwarding", + "form.tab.auth": "Auth", + "form.tab.authentication": "Auth", + "form.tab.advanced": "Advanced", + "form.btn.save": "Save", + "form.btn.cancel": "Cancel", + "form.hint": "[white]^H/^L[-] Tab • [white]^S[-] Save • [white]Esc[-] Cancel", + "form.help.no_available": "[dim]No help available for this field[-]", + "form.help.title": "[yellow::b]📖 %s[-::-]", + "form.help.syntax": "[cyan]Syntax:[-] %s", + "form.help.examples": "[cyan]Examples:[-]", + "form.help.default": "[dim]Default: %s[-]", + "form.help.since": "[dim]Available since %s[-]", + "form.save_failed": "Save failed: %v", + "form.close": "Close", + + "field.label.alias": "Alias:", + "field.label.host": "Host/IP:", + "field.label.user": "User:", + "field.label.port": "Port:", + "field.label.keys": "Keys:", + "field.label.tags": "Tags:", + "field.label.password": "Password:", + "field.label.auth_method": "Auth Method:", + "field.label.login_mode": "Login Mode:", + "auth.method.key": "Key", + "auth.method.password": "Password", + "auth.method.key_password": "Key+Password", + "auth.login_mode.interactive": "Interactive", + "auth.login_mode.batch": "Batch", + "auth.login_mode.auto": "Auto", + "help.auth_method": "Choose authentication method. Key uses SSH public key auth, Password uses password auth.", + "help.login_mode": "Interactive: always request TTY; Batch: no TTY; Auto: request TTY as needed.", + "field.label.proxy_jump": "ProxyJump:", + "field.label.proxy_command": "ProxyCommand:", + "field.label.remote_command": "RemoteCommand:", + "field.label.request_tty": "RequestTTY:", + "field.label.session_type": "SessionType:", + "field.label.connect_timeout": "ConnectTimeout:", + "field.label.connection_attempts": "ConnectionAttempts:", + "field.label.ip_qos": "IPQoS:", + "field.label.batch_mode": "BatchMode:", + "field.label.bind_address": "BindAddress:", + "field.label.bind_interface": "BindInterface:", + "field.label.address_family": "AddressFamily:", + "field.label.canonicalize_hostname": "CanonicalizeHostname:", + "field.label.canonical_domains": "CanonicalDomains:", + "field.label.canonicalize_fallback": "CanonicalizeFallbackLocal:", + "field.label.canonicalize_max_dots": "CanonicalizeMaxDots:", + "field.label.canonicalize_cname": "CanonicalizePermittedCNAMEs:", + "field.label.server_alive_interval": "ServerAliveInterval:", + "field.label.server_alive_count": "ServerAliveCountMax:", + "field.label.compression": "Compression:", + "field.label.tcp_keepalive": "TCPKeepAlive:", + "field.label.control_master": "ControlMaster:", + "field.label.control_path": "ControlPath:", + "field.label.control_persist": "ControlPersist:", + "field.label.local_forward": "LocalForward:", + "field.label.remote_forward": "RemoteForward:", + "field.label.dynamic_forward": "DynamicForward:", + "field.label.clear_all_forwardings": "ClearAllForwardings:", + "field.label.exit_on_forward_failure": "ExitOnForwardFailure:", + "field.label.gateway_ports": "GatewayPorts:", + "field.label.forward_agent": "ForwardAgent:", + "field.label.forward_x11": "ForwardX11:", + "field.label.forward_x11_trusted": "ForwardX11Trusted:", + "field.label.pubkey_auth": "PubkeyAuthentication:", + "field.label.identities_only": "IdentitiesOnly:", + "field.label.add_keys_to_agent": "AddKeysToAgent:", + "field.label.identity_agent": "IdentityAgent:", + "field.label.password_auth": "PasswordAuthentication:", + "field.label.kbd_auth": "KbdInteractiveAuthentication:", + "field.label.num_password_prompts": "NumberOfPasswordPrompts:", + "field.label.preferred_auth": "PreferredAuthentications:", + "field.label.pubkey_algorithms": "PubkeyAcceptedAlgorithms:", + "field.label.hostkey_algorithms": "HostbasedAcceptedAlgorithms:", + "field.label.strict_host_key": "StrictHostKeyChecking:", + "field.label.check_host_ip": "CheckHostIP:", + "field.label.fingerprint_hash": "FingerprintHash:", + "field.label.known_hosts_file": "UserKnownHostsFile:", + "field.label.hostkey_algos": "HostKeyAlgorithms:", + "field.label.ciphers": "Ciphers:", + "field.label.macs": "MACs:", + "field.label.kex_algorithms": "KexAlgorithms:", + "field.label.verify_dns": "VerifyHostKeyDNS:", + "field.label.update_host_keys": "UpdateHostKeys:", + "field.label.hash_known_hosts": "HashKnownHosts:", + "field.label.visual_host_key": "VisualHostKey:", + "field.label.local_command": "LocalCommand:", + "field.label.permit_local_command": "PermitLocalCommand:", + "field.label.escape_char": "EscapeChar:", + "field.label.send_env": "SendEnv:", + "field.label.set_env": "SetEnv:", + "field.label.log_level": "LogLevel:", + "field.label.exit_on_forward_failure": "ExitOnForwardFailure:", + + "help.section.proxy": "▶ Proxy & Commands[-]", + "help.section.connection": "▶ Connection Settings[-]", + "help.section.bind": "▶ Bind Options[-]", + "help.section.canonical": "▶ Hostname Canonicalization[-]", + "help.section.keepalive": "▶ Keepalive[-]", + "help.section.multiplexing": "▶ Multiplexing[-]", + "help.section.port_forwarding": "▶ Port Forwarding[-]", + "help.section.agent_x11": "▶ Agent & X11 Forwarding[-]", + "help.section.pubkey": "▶ Public Key Auth[-]", + "help.section.ssh_agent": "▶ SSH Agent[-]", + "help.section.password": "▶ Password & Interactive[-]", + "help.section.security": "▶ Security[-]", + "help.section.command": "▶ Command Execution[-]", + "help.section.environment": "▶ Environment[-]", + + "field.placeholder.required": "Required", + "field.placeholder.default": "Default", + "field.placeholder.example": "e.g.", + "field.placeholder.comma_separated": "Comma-separated", + "field.placeholder.current_user": "Current user", + "field.placeholder.none": "None", + "field.placeholder.seconds": "Seconds", + + "validation.alias_required": "Alias is required", + "validation.host_required": "Host is required", + "validation.port_invalid": "Port must be a number", + "validation.port_range": "Port range 1-65535", + "validation.tags_format": "Tags must be comma-separated", + "validation.forward_format": "Format: localport:remotehost:remoteport", + "validation.proxy_command_invalid": "Invalid ProxyCommand format", + "validation.control_path_invalid": "Invalid ControlPath format", + + "confirm.delete": "Are you sure you want to delete server %s (%s@%s:%d)?\n\nThis cannot be undone.", + "confirm.delete_cancel": "Cancel", + "confirm.delete_confirm": "Delete", + + "forward.title": "Port Forward: %s", + "forward.type": "Type", + "forward.port": "Port", + "forward.host": "Host", + "forward.host_port": "Remote Port", + "forward.bind_address": "Bind Address (optional)", + "forward.mode": "Mode", + "forward.type_local": "Local", + "forward.type_remote": "Remote", + "forward.type_dynamic": "Dynamic", + "forward.mode_only": "Forward only", + "forward.mode_ssh": "Forward+SSH", + "forward.start": "Start", + "forward.invalid_port": "Invalid port: %s", + "forward.invalid_bind": "Invalid bind address: %s", + "forward.invalid_host": "Invalid host: %s", + "forward.invalid_host_port": "Invalid remote port: %s", + "forward.starting": "Starting port forward…", + "forward.failed": "Forward failed: %s", + "forward.started": "Port forward started (pid %d)", + "forward.stopped": "Forwarding stopped for %s", + "forward.stop_failed": "Stop forward failed: %s", + + "status.temporary": "Refreshing…", + "status.refresh_failed": "Refresh failed: %s", + "status.refreshed": "Refreshed %d servers", + "status.pinging": "Pinging %s…", + "status.ping_up": "Ping %s: Up (%s)", + "status.ping_down": "Ping %s: Down", + "status.copied": "Copied: %s", + "status.copy_failed": "Copy failed", + "status.sort": "Sort: %s", + "status.tags_updated": "Tags updated", + + "details.empty": "No servers match the current filter.", + + "placeholder.required": "Required", + "placeholder.default": "Default", + "placeholder.example": "e.g.", + "placeholder.comma_separated": "Comma-separated", + "placeholder.current_user": "Current user", + "placeholder.none": "None", + "placeholder.seconds": "Seconds", + "field.placeholder.password": "Leave empty to enter on connect", + "status.sshpass_unavailable": "sshpass not installed, passwords will not be auto-filled", + "validation.password_too_long": "Password too long", + + "help.alias": "Unique identifier for the connection", + "help.host": "SSH server address (IP or domain)", + "help.port": "SSH port number (default: 22)", + "help.user": "Login username (leave empty for current user)", + "help.keys": "Private key file paths, comma-separated", + "help.identity_agent": "SSH agent path", + "help.proxy_jump": "Jump host, e.g. bastion.example.com", + "help.proxy_command": "Proxy command, e.g. ssh -W %h:%p jump.example.com", + "help.remote_command": "Remote command, e.g. tmux attach", + "help.local_forward": "Local forward, e.g. 8080:localhost:80", + "help.remote_forward": "Remote forward, e.g. 80:localhost:8080", + "help.dynamic_forward": "Dynamic forward (SOCKS proxy), e.g. 1080", + "help.forward_agent": "Whether to forward SSH agent", + "help.forward_x11": "Whether to forward X11 display", + "help.tags": "Tags for grouping and filtering, comma-separated", + "help.connect_timeout": "Connection timeout in seconds", + "help.connection_attempts": "Number of connection retries", + "help.ip_qos": "IP QoS service type", + "help.batch_mode": "Batch mode (no interaction)", + "help.compression": "Enable compression", + "help.address_family": "Address family: any, inet, inet6", + "help.request_tty": "TTY allocation mode: auto, yes, no, force", + "help.session_type": "Session type", + "help.pubkey_auth": "Public key authentication", + "help.password_auth": "Password authentication", + "help.preferred_auth": "Authentication priority, comma-separated", + "help.identities_only": "Use only specified key files", + "help.add_keys_to_agent": "Add keys to agent automatically", + "help.kbd_auth": "Keyboard interactive authentication", + "help.num_password_prompts": "Number of password prompts", + "help.pubkey_algorithms": "Accepted public key algorithms", + "help.hostkey_algorithms": "Accepted host key algorithms", + "help.control_master": "Enable connection multiplexing", + "help.control_path": "Control socket path", + "help.control_persist": "Keep duration, e.g. 10m, 4h", + "help.server_alive_interval": "Keepalive interval (seconds)", + "help.server_alive_count": "Max keepalive failures", + "help.tcp_keepalive": "Enable TCP keepalive", + "help.strict_host_key": "Strict host key checking: ask, yes, no", + "help.known_hosts": "Known hosts file path", + "help.ciphers": "Cipher algorithms", + "help.macs": "MAC algorithms", + "help.check_host_ip": "Whether to check host IP", + "help.fingerprint_hash": "Fingerprint hash algorithm", + "help.verify_dns": "Verify host keys via DNS", + "help.update_host_keys": "Whether to update host keys", + "help.visual_host_key": "Show visual host key", + "help.kex_algorithms": "Key exchange algorithms", + "help.canonical_hostname": "Hostname canonicalization mode", + "help.canonical_domains": "Canonical domain suffixes", + "help.canonical_fallback": "Allow local fallback", + "help.canonical_max_dots": "Max dots before canonicalization", + "help.canonical_cnames": "Allowed CNAME mappings", + "help.local_command": "Local command", + "help.permit_local": "Permit local command", + "help.escape_char": "Escape character", + "help.send_env": "Environment variables to send", + "help.set_env": "Environment variables to set", + "help.log_level": "Log level: DEBUG, INFO, ERROR", + "help.bind_address": "Bind address", + "help.bind_interface": "Bind network interface", + "help.password": "SSH authentication password. Encrypted locally.", + + "placeholder.host_required": "Required", + "placeholder.port_default": "Default: 22", + "placeholder.user_default": "Default: current user", + "placeholder.timeout_seconds": "Seconds (default: none)", + "placeholder.timeout_default": "Default: %d seconds", + "placeholder.connection_attempts": "Default: 1", + "placeholder.alive_interval": "Seconds (default: 0)", + "placeholder.alive_interval_default": "Default: %d seconds", + "placeholder.alive_count": "Default: 3", + "placeholder.password_prompts": "Default: 3", + "placeholder.max_dots": "Default: 1", + "placeholder.ip_qos": "Default: af21 cs1", + "placeholder.escape_char": "Default: ~", + "placeholder.identity_agent": "Default: SSH_AUTH_SOCK", + "placeholder.known_hosts": "Default: ~/.ssh/known_hosts", + "placeholder.keys_example": "e.g. ~/.ssh/id_rsa, ~/.ssh/id_ed25519", + "placeholder.tags_comma": "Comma-separated tags", + "placeholder.proxy_jump_example": "e.g. bastion.example.com", + "placeholder.proxy_command_example": "e.g. ssh -W %h:%p jump.example.com", + "placeholder.remote_command_example": "e.g. tmux attach", + "placeholder.local_forward_example": "e.g. 8080:localhost:80, 3000:localhost:3000", + "placeholder.remote_forward_example": "e.g. 80:localhost:8080", + "placeholder.dynamic_forward_example": "e.g. 1080, 1081", + "placeholder.control_path_example": "e.g. ~/.ssh/master-%r@%h:%p", + "placeholder.control_persist_example": "e.g. 10m, 4h, yes, no", + "placeholder.preferred_auth_example": "e.g. publickey,password", + "placeholder.algorithms": "Algorithms (supports +/-/^ prefix)", + "placeholder.bind_address_example": "IP, hostname, * (all), or localhost", + "placeholder.canonical_domains_example": "e.g. example.com, internal.net", + "placeholder.canonical_cname_example": "e.g. *.example.com:example.net", + "placeholder.local_command_example": "e.g. echo 'Connected to %h'", + "placeholder.send_env_example": "e.g. LANG, LC_*, TERM", + "placeholder.set_env_example": "e.g. FOO=bar, DEBUG=1", + + "field_help.alias": "Unique identifier for the alias. This is what you type after 'ssh'.", + "field_help.host": "The actual hostname or IP address to connect to. Can be a domain name or IP address.", + "field_help.port": "Port number on the remote host. The standard SSH port is 22.", + "field_help.user": "Username to log in as on the remote machine. If unspecified, the current username is used.", + "field_help.keys": "Paths to SSH private key files for authentication. Multiple keys can be specified separated by commas.", + "field_help.proxy_jump": "Specifies one or more jump hosts to use to reach the target. Useful for accessing servers behind firewalls.", + "field_help.proxy_command": "Command used to connect to the server. Useful for connecting through proxies or custom connection methods.", + "field_help.remote_command": "Command to execute on the remote machine after successful connection.", + "field_help.connect_timeout": "Timeout in seconds for establishing the connection. Useful for slow or unreliable networks.", + "field_help.connection_attempts": "Number of tries before giving up on the connection.", + "field_help.session_type": "Requested session type. 'none' (-N flag) is useful for port forwarding without needing a shell.", + "field_help.request_tty": "Request a pseudo-terminal for the session. Required for interactive programs.", + "field_help.local_forward": "Forward a local port to a remote address. Useful for accessing remote services through SSH.", + "field_help.remote_forward": "Forward a remote port to a local address. Allows remote users to access local services.", + "field_help.dynamic_forward": "Create a SOCKS proxy on the specified port. Useful for routing traffic through SSH.", + "field_help.forward_agent": "Forward SSH agent connection to the remote host. Allows using local SSH keys on the remote server.", + "field_help.forward_x11": "Enable X11 forwarding for GUI applications over SSH.", + "field_help.tags": "Custom tags for organizing and filtering servers. Comma-separated list.", + "field_help.ip_qos": "Quality of Service (QoS) / DSCP / TOS for SSH connections. Different values can be specified for interactive and bulk traffic.", + "field_help.bind_address": "Use a specific source address for connections. Useful for multi-homed hosts.", + "field_help.bind_interface": "Use a specific network interface for connections. Useful for routing through specific NIC.", + "field_help.address_family": "Restrict connections to use IPv4 or IPv6 addresses only.", + "field_help.canonicalize_hostname": "Control whether hostname canonicalization is performed. Useful for shortening hostnames.", + "field_help.canonical_domains": "Search domains for hostname canonicalization. SSH will try appending these domains.", + "field_help.canonicalize_fallback": "Whether to fail if canonicalization fails. If yes, uses the original hostname.", + "field_help.canonicalize_max_dots": "Maximum number of dots in the hostname before canonicalization is disabled.", + "field_help.canonicalize_cname": "Rules for CNAME following during canonicalization.", + "field_help.forward_x11_trusted": "Enable trusted X11 forwarding. Less secure but more compatible.", + "field_help.clear_all_forwardings": "Clear all port forwardings set in the configuration file.", + "field_help.exit_on_forward_failure": "Terminate the connection if port forwarding fails.", + "field_help.gateway_ports": "Allow remote hosts to connect to forwarded ports.", + "field_help.pubkey_auth": "Enable or disable public key authentication.", + "field_help.password_auth": "Enable or disable password authentication.", + "field_help.preferred_auth": "Order of authentication methods to try.", + "field_help.identities_only": "Use only authentication identity files configured in ssh_config, ignoring ssh-agent.", + "field_help.add_keys_to_agent": "Automatically add keys to ssh-agent when used.", + "field_help.kbd_auth": "Enable keyboard interactive authentication (e.g., for two-factor authentication).", + "field_help.num_password_prompts": "Number of password prompts before giving up.", + "field_help.identity_agent": "Location of the authentication agent socket.", + "field_help.pubkey_algorithms": "Signature algorithms accepted for public key authentication.", + "field_help.hostkey_algorithms": "Signature algorithms accepted for host authentication.", + "field_help.control_master": "Enable connection multiplexing. Reuse existing connections for faster access.", + "field_help.control_path": "Control socket path for connection multiplexing.", + "field_help.control_persist": "Keep the master connection open in the background after the initial client exits.", + "field_help.server_alive_interval": "Seconds between keepalive messages. Prevents idle connections from being dropped.", + "field_help.server_alive_count": "Number of keepalive messages before disconnecting.", + "field_help.tcp_keepalive": "Send TCP keepalive messages to detect broken connections.", + "field_help.strict_host_key": "How to handle unknown host keys. 'ask' prompts user, 'no' auto-adds, 'yes' requires pre-existing key.", + "field_help.known_hosts_file": "File to store host keys in. Multiple files can be specified.", + "field_help.hostkey_algos": "Priority order for host key algorithms. Use +/- to add/remove defaults.", + "field_help.ciphers": "Priority order for encryption algorithms.", + "field_help.macs": "Priority order for message authentication code algorithms.", + "field_help.check_host_ip": "Check host IP addresses in the known_hosts file.", + "field_help.fingerprint_hash": "Hash algorithm for displaying key fingerprints.", + "field_help.verify_dns": "Use DNS SSHFP records to verify host keys.", + "field_help.update_host_keys": "Automatically update new host keys in known_hosts.", + "field_help.hash_known_hosts": "Hash hostnames and addresses in the known_hosts file.", + "field_help.visual_host_key": "Show ASCII art representation of the host key.", + "field_help.kex_algorithms": "Key exchange algorithms to use.", + "field_help.local_command": "Command to execute on the local machine after connecting.", + "field_help.permit_local_command": "Permit execution of LocalCommand.", + "field_help.escape_char": "Escape character for the SSH session (defaults to ~). Set to 'none' to disable.", + "field_help.send_env": "Environment variables to send to the server.", + "field_help.set_env": "Set environment variables for the SSH session.", + "field_help.log_level": "Verbosity of the log. Higher levels show more debugging details.", + "field_help.compression": "Enable compression to reduce bandwidth usage. Useful for slow connections.", + "field_help.batch_mode": "Disable all interactive prompts. Useful for scripts and automation.", + "field_help.bind_address_field": "Use a specific source address for connections. Useful for multi-homed hosts.", + "field_help.bind_interface_field": "Use a specific network interface for connections. Useful for routing through specific NIC.", + "field_help.address_family_field": "Restrict connections to use IPv4 or IPv6 addresses only.", + "field_help.canonicalize_hostname_field": "Control whether hostname canonicalization is performed. Useful for shortening hostnames.", + "field_help.canonical_domains_field": "Search domains for hostname canonicalization. SSH will try appending these domains.", + "field_help.canonicalize_fallback_field": "Whether to fail if canonicalization fails. If yes, uses the original hostname.", + "field_help.canonicalize_max_dots_field": "Maximum number of dots in the hostname before canonicalization is disabled.", + "field_help.canonicalize_cname_field": "Rules for CNAME following during canonicalization.", + "field_help.exit_on_forward_failure_field": "Terminate the connection if port forwarding fails.", + "field_help.gateway_ports_field": "Allow remote hosts to connect to forwarded ports.", + + "placeholder_tags": "Comma-separated tags", + "placeholder_proxy_jump": "e.g. bastion.example.com", + "placeholder_proxy_command": "e.g. ssh -W %h:%p jump.example.com", + "placeholder_remote_command": "e.g. tmux attach", + "placeholder_local_forward": "e.g. 8080:localhost:80, 3000:localhost:3000", + "placeholder_remote_forward": "e.g. 80:localhost:8080", + "placeholder_dynamic_forward": "e.g. 1080, 1081", + "placeholder_control_path": "e.g. ~/.ssh/master-%r@%h:%p", + "placeholder_control_persist": "e.g. 10m, 4h, yes, no", + "placeholder_preferred_auth": "e.g. publickey,password", + "placeholder_algorithms": "Algorithms (supports +/-/^ prefix)", + "placeholder_bind_address": "IP, hostname, * (all), or localhost", + "placeholder_canonical_domains": "e.g. example.com, internal.net", + "placeholder_canonical_cname": "e.g. *.example.com:example.net", + "placeholder_local_command": "e.g. echo 'Connected to %h'", + "placeholder_send_env": "e.g. LANG, LC_*, TERM", + "placeholder_set_env": "e.g. FOO=bar, DEBUG=1", + "placeholder_keys": "e.g. ~/.ssh/id_rsa, ~/.ssh/id_ed25519", + "placeholder_port_default": "Default: 22", + "placeholder_user_default": "Default: current user", + "placeholder_timeout_seconds": "Seconds (default: none)", + "placeholder_timeout_default": "Default: %d seconds", + "placeholder_connection_attempts": "Default: 1", + "placeholder_alive_interval": "Seconds (default: 0)", + "placeholder_alive_interval_default": "Default: %d seconds", + "placeholder_alive_count": "Default: 3", + "placeholder_password_prompts": "Default: 3", + "placeholder_max_dots": "Default: 1", + "placeholder_ip_qos": "Default: af21 cs1", + "placeholder_escape_char": "Default: ~", + "placeholder_identity_agent": "Default: SSH_AUTH_SOCK", + "placeholder_known_hosts": "Default: ~/.ssh/known_hosts", + + "section_proxy_command": "▶ Proxy & Commands[-]", + "section_connection_settings": "▶ Connection Settings[-]", + "section_bind_options": "▶ Bind Options[-]", + "section_canonicalization": "▶ Hostname Canonicalization[-]", + "section_keepalive": "▶ Keepalive[-]", + "section_multiplexing": "▶ Multiplexing[-]", + "section_port_forwarding": "▶ Port Forwarding[-]", + "section_agent_x11": "▶ Agent & X11 Forwarding[-]", + "section_pubkey_auth": "▶ Public Key Auth[-]", + "section_ssh_agent": "▶ SSH Agent[-]", + "section_password_interactive": "▶ Password & Interactive[-]", + "section_security": "▶ Security[-]", + "section_command_execution": "▶ Command Execution[-]", + "section_environment": "▶ Environment[-]", + "section_debugging": "▶ Debugging[-]", + + "option_default": "Default (%s)", + "option_none": "None", + "option_yes": "Yes", + "option_no": "No", + "option_ask": "Ask", + "option_auto": "Auto", + "option_force": "Force", + "option_confirm": "Confirm", + + "edit_tags_title": " Edit Tags: %s ", + "edit_tags_label": "Tags (comma):", + "edit_tags_saved": "Tags updated", + + "port_forward_title": " Port Forward: %s ", + "port_forward_type": "Type", + "port_forward_port": "Port", + "port_forward_host": "Host", + "port_forward_host_port": "Remote Port", + "port_forward_bind": "Bind Address (optional)", + "port_forward_mode": "Mode", + "port_forward_start": "Start", + "port_forward_cancel": "Cancel", + "port_forward_type_local": "Local", + "port_forward_type_remote": "Remote", + "port_forward_type_dynamic": "Dynamic", + "port_forward_mode_only": "Forward only", + "port_forward_mode_ssh": "Forward+SSH", + "port_forward_starting": "Starting port forward…", + "port_forward_started": "Port forward started (pid %d)", + "port_forward_failed": "Forward failed: %s", + "port_forward_invalid_port": "Invalid port: %s", + "port_forward_invalid_bind": "Invalid bind address: %s", + "port_forward_invalid_host": "Invalid host: %s", + "port_forward_invalid_host_port": "Invalid remote port: %s", + "port_forward_stopped": "Forwarding stopped for %s", + "port_forward_stop_failed": "Stop forward failed: %s", + + "status_refreshing": "Refreshing…", + "status_refresh_failed": "Refresh failed: %s", + "status_refreshed": "Refreshed %d servers", + "status_pinging": "Pinging %s…", + "status_ping_up": "Ping %s: Up (%s)", + "status_ping_down": "Ping %s: Down", + "status_copied": "Copied: %s", + "status_copy_failed": "Copy failed", + "status_sort": "Sort: %s", + "status_tags_updated": "Tags updated", + + "delete_confirm_title": "Delete server %s (%s@%s:%d)?\n\nThis action cannot be undone.", + "delete_cancel": "Cancel", + "delete_confirm": "Delete", + + "detail_basic": "Basic Settings", + "detail_advanced": "Advanced Settings", + "detail_commands": "Commands", + "detail_host": " Host: [white]%s[-]", + "detail_user": " User: [white]%s[-]", + "detail_port": " Port: [white]%s[-]", + "detail_key": " Key: [white]%s[-]", + "detail_tags": " Tags: %s", + "detail_pinned": " Pinned: [white]%s[-]", + "detail_last_ssh": " Last SSH: %s", + "detail_ssh_count": " SSH count: [white]%d[-]", + "detail_never": "Never", + "detail_commands_list": " Enter: SSH connect\n f: Port forward\n x: Stop forwarding\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin", + "detail_no_match": "No servers match the current filter.", + + "time.just_now": "Just now", + "time.minutes_ago": "%d minutes ago", + "time.hours_ago": "%d hours ago", + "time.days_ago": "%d days ago", + "time.months_ago": "%d months ago", + "time.years_ago": "%d years ago", + "list.last_ssh": "Last SSH: %s", + + "status.ping_down_err": "Ping %s: Down (%v)", + "form.unsaved_changes": "You have unsaved changes. Exit anyway?", + "form.btn.discard": "Discard", + "form.validation_title": "Validation failed (%d errors):\n\n", + "form.validation_more": "\n... and %d more errors", + "form.btn.ok": "OK", + "form.help.panel_title": "Help", + + "validation.alias_format": "Alias can only contain letters, numbers, dots, hyphens and underscores", + "validation.host_format": "Host must be a valid hostname or IP address", + "validation.user_format": "User must start with a letter and only contain letters, numbers, dots, hyphens and underscores", + "validation.keys_access": "Key file not found or inaccessible", + "validation.timeout_format": "Connection timeout must be a positive number or 'none'", + "validation.attempts_format": "Connection attempts must be a positive number", + "validation.alive_interval_format": "Keepalive interval must be non-negative", + "validation.alive_count_format": "Keepalive count must be non-negative", + "validation.ipqos_format": "IPQoS must be a valid QoS value (e.g. 'af21 cs1', 'lowdelay', 'ef')", + "validation.bind_format": "Bind address must be a valid IP address, hostname or '*'", + "validation.dynamic_forward_format": "Dynamic forward must be in format [bind_address:]port", + "validation.password_prompts_range": "Password prompts must be between 0 and 10", + "validation.max_dots_format": "Canonicalize max dots must be non-negative", + "validation.escape_char_format": "Escape character must be a single character, 'none' or ^X format (e.g. ^A)", + "validation.known_hosts_access": "Known hosts file not found or inaccessible", + "validation.timeout_invalid": "Invalid timeout value", + "validation.timeout_positive": "Timeout must be positive or 'none'", + "validation.invalid_number": "Invalid number", + "validation.non_negative": "Must be non-negative", + "validation.ipqos_max": "IPQoS accepts at most 2 values", + "validation.ipqos_value_invalid": "Invalid IPQoS value: %s", + "validation.host_no_spaces": "Host cannot contain spaces", + "validation.hostname_too_long": "Hostname too long", + "validation.host_invalid_chars": "Host contains invalid characters", + "validation.hostname_dots": "Hostname cannot start or end with a dot", + "validation.hostname_consecutive_dots": "Hostname cannot contain consecutive dots", + "validation.hostname_empty_label": "Hostname contains empty labels", + "validation.hostname_label_too_long": "Hostname label too long", + "validation.hostname_label_hyphen": "Hostname labels cannot start or end with a hyphen", + "validation.forward_format_detail": "Invalid format, expected [bind_address:]port:host:hostport", + "validation.bind_address_invalid": "Invalid bind address: %w", + "validation.port_number_invalid": "Invalid port number: %s", + "validation.host_port_invalid": "Invalid remote port number: %s", + "validation.dynamic_forward_format_detail": "Invalid format, expected [bind_address:]port", + "validation.address_no_spaces": "Address cannot contain spaces", + "validation.address_invalid_chars": "Address contains invalid characters", + "validation.address_dots": "Address cannot start or end with a dot", + "validation.address_hyphen": "Address cannot start or end with a hyphen", + "validation.address_consecutive_dots": "Address cannot contain consecutive dots", + "validation.ip_invalid": "Invalid IP address format", + "validation.address_format": "Invalid address format", + "validation.files_not_found": "Files not found: %s", + "validation.files_not_accessible": "Files inaccessible: %s", + "validation.file_invalid_chars": "File path contains invalid characters", + + "default.none_system": "None (system default)", + "default.seconds": "%s seconds", + "default.disabled": "0 (disabled)", + "default.none": "None", + "default.current_username": "Current username", + + "field_help.alias.syntax": "Any non-whitespace string", + "field_help.alias.examples": "myserver|prod-db|dev-web-01", + "field_help.alias.default": "Required", + "field_help.host.syntax": "Hostname | IP Address", + "field_help.host.examples": "example.com|192.168.1.100|2001:db8::1", + "field_help.host.default": "Required", + "field_help.port.syntax": "Port number (1-65535)", + "field_help.port.examples": "22|2222|8022", + "field_help.port.default": "22", + "field_help.user.syntax": "Username", + "field_help.user.examples": "root|ubuntu|admin|deploy", + "field_help.user.default": "Current username", + "field_help.keys.syntax": "path[,path,...]", + "field_help.keys.examples": "~/.ssh/id_ed25519|~/.ssh/id_rsa,~/.ssh/id_ed25519", + "field_help.keys.default": "~/.ssh/id_rsa, ~/.ssh/id_ed25519 etc." +} diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json new file mode 100644 index 00000000..818fa208 --- /dev/null +++ b/internal/i18n/locales/zh-CN.json @@ -0,0 +1,586 @@ +{ + "app.name": "lazyssh", + "app.title_servers": " 服务器 ", + "app.sort.alias_asc": "别名 ↑", + "app.sort.alias_desc": "别名 ↓", + "app.sort.last_seen_asc": "最近 SSH ↑", + "app.sort.last_seen_desc": "最近 SSH ↓", + + "search.label": " 🔍 搜索: ", + "search.title": " 搜索 ", + + "statusbar.keys": "[white]↑↓[-] 导航 • [white]Enter[-] SSH • [white]f[-] 转发 • [white]x[-] 停止转发 • [white]c[-] 复制SSH • [white]a[-] 添加 • [white]e[-] 编辑 • [white]g[-] 探测 • [white]d[-] 删除 • [white]p[-] 置顶/取消 • [white]/[-] 搜索 • [white]q[-] 退出", + + "details.title": " 详情 ", + "details.label.basic": "基本设置", + "details.label.advanced": "高级设置", + "details.label.commands": "命令", + "details.host": " 主机: [white]%s[-]", + "details.user": " 用户: [white]%s[-]", + "details.port": " 端口: [white]%s[-]", + "details.key": " 密钥: [white]%s[-]", + "details.tags": " 标签: %s", + "details.pinned": " 已置顶: [white]%s[-]", + "details.last_ssh": " 最近SSH: %s", + "details.ssh_count": " SSH次数: [white]%d[-]", + "details.never": "从未", + "details.commands_text": " 回车: SSH连接\n f: 端口转发\n x: 停止转发\n c: 复制SSH命令\n g: 探测服务器\n r: 刷新列表\n a: 添加新服务器\n e: 编辑条目\n t: 编辑标签\n d: 删除条目\n p: 置顶/取消", + "details.no_match": "没有服务器匹配当前筛选条件。", + + "form.title.add": "添加服务器", + "form.title.edit": "编辑服务器", + "form.tab.basic": "基本", + "form.tab.connection": "连接", + "form.tab.forwarding": "转发", + "form.tab.auth": "认证", + "form.tab.authentication": "认证", + "form.tab.advanced": "高级", + "form.btn.save": "保存", + "form.btn.cancel": "取消", + "form.hint": "[white]^H/^L[-] 切换标签 • [white]^S[-] 保存 • [white]Esc[-] 取消", + "form.help.no_available": "[dim]暂无此字段帮助信息[-]", + "form.help.title": "[yellow::b]📖 %s[-::-]", + "form.help.syntax": "[cyan]语法:[-] %s", + "form.help.examples": "[cyan]示例:[-]", + "form.help.default": "[dim]默认值: %s[-]", + "form.help.since": "[dim]自 %s 起可用[-]", + "form.save_failed": "保存失败: %v", + "form.close": "关闭", + + "field.label.alias": "别名:", + "field.label.host": "主机/IP:", + "field.label.user": "用户:", + "field.label.port": "端口:", + "field.label.keys": "密钥:", + "field.label.tags": "标签:", + "field.label.password": "密码:", + "field.label.auth_method": "认证方式:", + "field.label.login_mode": "登录模式:", + "auth.method.key": "密钥", + "auth.method.password": "密码", + "auth.method.key_password": "密钥+密码", + "auth.login_mode.interactive": "交互式", + "auth.login_mode.batch": "批处理", + "auth.login_mode.auto": "自动", + "help.auth_method": "选择认证方式。密钥使用 SSH 公钥认证,密码使用密码认证。", + "help.login_mode": "交互式:始终请求 TTY;批处理:不请求 TTY;自动:根据需要请求 TTY。", + "field.label.proxy_jump": "代理跳板:", + "field.label.proxy_command": "代理命令:", + "field.label.remote_command": "远程命令:", + "field.label.request_tty": "请求TTY:", + "field.label.session_type": "会话类型:", + "field.label.connect_timeout": "连接超时:", + "field.label.connection_attempts": "连接重试:", + "field.label.ip_qos": "IP服务质量:", + "field.label.batch_mode": "批处理模式:", + "field.label.bind_address": "绑定地址:", + "field.label.bind_interface": "绑定接口:", + "field.label.address_family": "地址族:", + "field.label.canonicalize_hostname": "主机名规范化:", + "field.label.canonical_domains": "规范域名:", + "field.label.canonicalize_fallback": "规范回退:", + "field.label.canonicalize_max_dots": "规范最大点数:", + "field.label.canonicalize_cname": "规范CNAME:", + "field.label.server_alive_interval": "保活间隔:", + "field.label.server_alive_count": "保活次数:", + "field.label.compression": "压缩:", + "field.label.tcp_keepalive": "TCP保活:", + "field.label.control_master": "主控连接:", + "field.label.control_path": "控制路径:", + "field.label.control_persist": "控制持久:", + "field.label.local_forward": "本地转发:", + "field.label.remote_forward": "远程转发:", + "field.label.dynamic_forward": "动态转发:", + "field.label.clear_all_forwardings": "清除所有转发:", + "field.label.exit_on_forward_failure": "转发失败时退出:", + "field.label.gateway_ports": "网关端口:", + "field.label.forward_agent": "转发代理:", + "field.label.forward_x11": "转发X11:", + "field.label.forward_x11_trusted": "X11转发(信任):", + "field.label.pubkey_auth": "公钥认证:", + "field.label.identities_only": "仅允许身份认证:", + "field.label.add_keys_to_agent": "添加密钥到代理:", + "field.label.identity_agent": "身份代理:", + "field.label.password_auth": "密码认证:", + "field.label.kbd_auth": "键盘交互认证:", + "field.label.num_password_prompts": "密码提示次数:", + "field.label.preferred_auth": "首选认证方式:", + "field.label.pubkey_algorithms": "公钥接受算法:", + "field.label.hostkey_algorithms": "主机验证接受算法:", + "field.label.strict_host_key": "严格主机密钥检查:", + "field.label.check_host_ip": "检查主机IP:", + "field.label.fingerprint_hash": "指纹哈希:", + "field.label.known_hosts_file": "已知主机文件:", + "field.label.hostkey_algos": "主机密钥算法:", + "field.label.ciphers": "加密算法:", + "field.label.macs": "消息认证码:", + "field.label.kex_algorithms": "密钥交换算法:", + "field.label.verify_dns": "验证DNS主机密钥:", + "field.label.update_host_keys": "更新主机密钥:", + "field.label.hash_known_hosts": "哈希已知主机:", + "field.label.visual_host_key": "可视化主机密钥:", + "field.label.local_command": "本地命令:", + "field.label.permit_local_command": "允许本地命令:", + "field.label.escape_char": "转义字符:", + "field.label.send_env": "发送环境变量:", + "field.label.set_env": "设置环境变量:", + "field.label.log_level": "日志级别:", + "field.label.exit_on_forward_failure": "转发失败时退出:", + + "help.section.proxy": "▶ 代理与命令[-]", + "help.section.connection": "▶ 连接设置[-]", + "help.section.bind": "▶ 绑定选项[-]", + "help.section.canonical": "▶ 主机名规范化[-]", + "help.section.keepalive": "▶ 保活[-]", + "help.section.multiplexing": "▶ 多路复用[-]", + "help.section.port_forwarding": "▶ 端口转发[-]", + "help.section.agent_x11": "▶ Agent与X11转发[-]", + "help.section.pubkey": "▶ 公钥认证[-]", + "help.section.ssh_agent": "▶ SSH 代理[-]", + "help.section.password": "▶ 密码与交互式[-]", + "help.section.security": "▶ 安全[-]", + "help.section.command": "▶ 命令执行[-]", + "help.section.environment": "▶ 环境变量[-]", + + "field.placeholder.required": "必填", + "field.placeholder.default": "默认", + "field.placeholder.example": "例如", + "field.placeholder.comma_separated": "逗号分隔", + "field.placeholder.current_user": "当前用户", + "field.placeholder.none": "无", + "field.placeholder.seconds": "秒", + + "validation.alias_required": "别名不能为空", + "validation.host_required": "主机不能为空", + "validation.port_invalid": "端口必须是数字", + "validation.port_range": "端口范围 1-65535", + "validation.tags_format": "标签用逗号分隔", + "validation.forward_format": "格式: 本地端口:远程主机:远程端口", + "validation.proxy_command_invalid": "ProxyCommand 格式无效", + "validation.control_path_invalid": "ControlPath 格式无效", + + "confirm.delete": "确定要删除服务器 %s (%s@%s:%d)?\n\n此操作不可撤销。", + "confirm.delete_cancel": "取消", + "confirm.delete_confirm": "删除", + + "forward.title": "端口转发: %s", + "forward.type": "类型", + "forward.port": "端口", + "forward.host": "主机", + "forward.host_port": "远程端口", + "forward.bind_address": "绑定地址(可选)", + "forward.mode": "模式", + "forward.type_local": "本地转发", + "forward.type_remote": "远程转发", + "forward.type_dynamic": "动态转发", + "forward.mode_only": "仅转发", + "forward.mode_ssh": "转发+SSH", + "forward.start": "启动", + "forward.invalid_port": "端口无效: %s", + "forward.invalid_bind": "绑定地址无效: %s", + "forward.invalid_host": "主机无效: %s", + "forward.invalid_host_port": "远程端口无效: %s", + "forward.starting": "正在启动端口转发…", + "forward.failed": "转发失败: %s", + "forward.started": "端口转发已启动 (pid %d)", + "forward.stopped": "已停止 %s 的转发", + "forward.stop_failed": "停止转发失败: %s", + + "status.temporary": "正在刷新…", + "status.refresh_failed": "刷新失败: %s", + "status.refreshed": "已刷新 %d 个服务器", + "status.pinging": "正在探测 %s…", + "status.ping_up": "探测 %s: 在线 (%s)", + "status.ping_down": "探测 %s: 离线", + "status.copied": "已复制: %s", + "status.copy_failed": "复制失败", + "status.sort": "排序: %s", + "status.tags_updated": "标签已更新", + + "details.empty": "没有服务器匹配当前筛选条件。", + + "placeholder.required": "必填", + "placeholder.default": "默认", + "placeholder.example": "例如", + "placeholder.comma_separated": "逗号分隔", + "placeholder.current_user": "当前用户", + "placeholder.none": "无", + "placeholder.seconds": "秒", + "field.placeholder.password": "留空则每次连接时输入", + "status.sshpass_unavailable": "sshpass 未安装,密码将不会被自动填入", + "validation.password_too_long": "密码过长", + + "help.alias": "连接的唯一标识符", + "help.host": "SSH 服务器地址(IP 或域名)", + "help.port": "SSH 端口号(默认:22)", + "help.user": "登录用户名(留空使用当前用户)", + "help.keys": "私钥文件路径,多个用逗号分隔", + "help.identity_agent": "SSH agent 路径", + "help.proxy_jump": "跳板机,例如:bastion.example.com", + "help.proxy_command": "代理命令,例如:ssh -W %h:%p jump.example.com", + "help.remote_command": "远程命令,例如:tmux attach", + "help.local_forward": "本地转发,例如:8080:localhost:80", + "help.remote_forward": "远程转发,例如:80:localhost:8080", + "help.dynamic_forward": "动态转发(SOCKS 代理),例如:1080", + "help.forward_agent": "是否转发 SSH agent", + "help.forward_x11": "是否转发 X11 显示", + "help.tags": "标签,用逗号分隔,用于分组和筛选", + "help.connect_timeout": "连接超时时间(秒)", + "help.connection_attempts": "连接重试次数", + "help.ip_qos": "IP QoS 服务类型", + "help.batch_mode": "批处理模式(无交互)", + "help.compression": "是否启用压缩", + "help.address_family": "地址族:any, inet, inet6", + "help.request_tty": "TTY 分配模式:auto, yes, no, force", + "help.session_type": "会话类型", + "help.pubkey_auth": "公钥认证", + "help.password_auth": "密码认证", + "help.preferred_auth": "认证优先级,用逗号分隔", + "help.identities_only": "是否仅使用指定密钥", + "help.add_keys_to_agent": "是否将密钥添加到 agent", + "help.kbd_auth": "键盘交互认证", + "help.num_password_prompts": "密码提示次数", + "help.pubkey_algorithms": "接受的公钥算法", + "help.hostkey_algorithms": "接受的主机密钥算法", + "help.control_master": "是否启用主连接", + "help.control_path": "控制套接字路径", + "help.control_persist": "连接保持时间,例如:10m, 4h", + "help.server_alive_interval": "保活间隔(秒)", + "help.server_alive_count": "保活失败次数阈值", + "help.tcp_keepalive": "是否启用 TCP 保活", + "help.strict_host_key": "严格主机密钥检查:ask, yes, no", + "help.known_hosts": "已知主机文件路径", + "help.ciphers": "加密算法", + "help.macs": "消息认证码算法", + "help.check_host_ip": "是否检查主机 IP", + "help.fingerprint_hash": "指纹哈希算法", + "help.verify_dns": "是否通过 DNS 验证主机密钥", + "help.update_host_keys": "是否更新主机密钥", + "help.visual_host_key": "是否显示可视化主机密钥", + "help.kex_algorithms": "密钥交换算法", + "help.canonical_hostname": "主机名规范化模式", + "help.canonical_domains": "规范域名后缀", + "help.canonical_fallback": "是否允许本地回退", + "help.canonical_max_dots": "规范域名中最多点数", + "help.canonical_cnames": "允许的 CNAME 映射", + "help.local_command": "本地命令", + "help.permit_local": "是否允许本地命令", + "help.escape_char": "转义字符", + "help.send_env": "发送的环境变量", + "help.set_env": "设置的环境变量", + "help.log_level": "日志级别:DEBUG, INFO, ERROR", + "help.bind_address": "绑定地址", + "help.bind_interface": "绑定网络接口", + "help.password": "SSH 认证密码。加密存储在本地。", + + "placeholder.host_required": "必填", + "placeholder.port_default": "默认: 22", + "placeholder.user_default": "默认: 当前用户", + "placeholder.timeout_seconds": "秒(默认: 无)", + "placeholder.timeout_default": "默认: %d 秒", + "placeholder.connection_attempts": "默认: 1", + "placeholder.alive_interval": "秒(默认: 0)", + "placeholder.alive_interval_default": "默认: %d 秒", + "placeholder.alive_count": "默认: 3", + "placeholder.password_prompts": "默认: 3", + "placeholder.max_dots": "默认: 1", + "placeholder.ip_qos": "默认: af21 cs1", + "placeholder.escape_char": "默认: ~", + "placeholder.identity_agent": "默认: SSH_AUTH_SOCK", + "placeholder.known_hosts": "默认: ~/.ssh/known_hosts", + "placeholder.keys_example": "例如: ~/.ssh/id_rsa, ~/.ssh/id_ed25519", + "placeholder.tags_comma": "逗号分隔的标签", + "placeholder.proxy_jump_example": "例如: bastion.example.com", + "placeholder.proxy_command_example": "例如: ssh -W %h:%p jump.example.com", + "placeholder.remote_command_example": "例如: tmux attach", + "placeholder.local_forward_example": "例如: 8080:localhost:80, 3000:localhost:3000", + "placeholder.remote_forward_example": "例如: 80:localhost:8080", + "placeholder.dynamic_forward_example": "例如: 1080, 1081", + "placeholder.control_path_example": "例如: ~/.ssh/master-%r@%h:%p", + "placeholder.control_persist_example": "例如: 10m, 4h, yes, no", + "placeholder.preferred_auth_example": "例如: publickey,password", + "placeholder.algorithms": "算法(支持 +/-/^ 前缀)", + "placeholder.bind_address_example": "IP、主机名、*(全部)或 localhost", + "placeholder.canonical_domains_example": "例如: example.com, internal.net", + "placeholder.canonical_cname_example": "例如: *.example.com:example.net", + "placeholder.local_command_example": "例如: echo '已连接到 %h'", + "placeholder.send_env_example": "例如: LANG, LC_*, TERM", + "placeholder.set_env_example": "例如: FOO=bar, DEBUG=1", + + "field_help.alias": "别名的唯一标识符。这是 'ssh' 命令后输入的内容。", + "field_help.host": "要连接的实际主机名或 IP 地址。可以是域名或 IP 地址。", + "field_help.port": "远程主机的端口号。标准 SSH 端口是 22。", + "field_help.user": "登录到远程机器的用户名。如果未指定,则使用当前用户名。", + "field_help.keys": "用于认证的 SSH 私钥文件路径。可以指定多个密钥。", + "field_help.proxy_jump": "指定一个或多个跳板机(堡垒机)以到达目标。对于访问防火墙后的服务器很有用。", + "field_help.proxy_command": "用于连接到服务器的命令。对于通过代理或自定义连接方法连接很有用。", + "field_help.remote_command": "成功连接后在远程机器上执行的命令。", + "field_help.connect_timeout": "建立连接的超时时间(秒)。对于慢速或不稳定网络很有用。", + "field_help.connection_attempts": "放弃连接之前尝试连接的次数。", + "field_help.session_type": "请求的会话类型。'none'(-N 标志)对于仅端口转发而不需要 shell 的情况很有用。", + "field_help.request_tty": "请求会话的伪终端。交互式程序需要此选项。", + "field_help.local_forward": "将本地端口转发到远程地址。对于通过 SSH 隧道访问远程服务很有用。", + "field_help.remote_forward": "将远程端口转发到本地地址。允许远程用户访问本地服务。", + "field_help.dynamic_forward": "在指定端口创建 SOCKS 代理。对于通过 SSH 路由流量很有用。", + "field_help.forward_agent": "将 SSH agent 连接转发到远程主机。允许在远程服务器上使用本地 SSH 密钥。", + "field_help.forward_x11": "启用 X11 转发以支持 SSH 上的 GUI 应用程序。", + "field_help.tags": "用于组织和筛选服务器的自定义标签。逗号分隔列表。", + "field_help.ip_qos": "SSH 连接的服务质量(QoS)/ DSCP / TOS。可以为交互式和批量流量指定不同的值。", + "field_help.bind_address": "为连接使用特定的源地址。对于多宿主主机很有用。", + "field_help.bind_interface": "为连接使用特定的网络接口。对于通过特定网卡路由很有用。", + "field_help.address_family": "限制连接使用 IPv4 或 IPv6 地址。", + "field_help.canonicalize_hostname": "控制是否执行主机名规范化。对于缩短主机名很有用。", + "field_help.canonical_domains": "主机名规范化的搜索域名。SSH 将尝试附加这些域名。", + "field_help.canonicalize_fallback": "如果规范化失败是否失败。如果为 yes,则使用原始主机名。", + "field_help.canonicalize_max_dots": "禁用规范化之前主机名中的最多点数。", + "field_help.canonicalize_cname": "规范化期间 CNAME 跟随的规则。", + "field_help.forward_x11_trusted": "启用受信任的 X11 转发。安全性较低但兼容性更好。", + "field_help.clear_all_forwardings": "清除配置文件中设置的所有端口转发。", + "field_help.exit_on_forward_failure": "如果端口转发失败则终止连接。", + "field_help.gateway_ports": "允许远程主机连接到转发的端口。", + "field_help.pubkey_auth": "启用或禁用公钥认证。", + "field_help.password_auth": "启用或禁用密码认证。", + "field_help.preferred_auth": "尝试的认证方法顺序。", + "field_help.identities_only": "仅使用 ssh_config 中配置的认证身份文件,忽略 ssh-agent。", + "field_help.add_keys_to_agent": "使用时自动将密钥添加到 ssh-agent。", + "field_help.kbd_auth": "启用键盘交互认证(例如用于双因素认证)。", + "field_help.num_password_prompts": "放弃之前的密码提示次数。", + "field_help.identity_agent": "认证代理套接字的位置。", + "field_help.pubkey_algorithms": "公钥认证接受的签名算法。", + "field_help.hostkey_algorithms": "主机认证接受的签名算法。", + "field_help.control_master": "启用连接多路复用。重用现有连接以提高速度。", + "field_help.control_path": "连接多路复用的控制套接字路径。", + "field_help.control_persist": "初始客户端退出后在后台保持主连接打开。", + "field_help.server_alive_interval": "保活消息之间的秒数。防止空闲连接断开。", + "field_help.server_alive_count": "断开连接之前的保活消息次数。", + "field_help.tcp_keepalive": "发送 TCP 保活消息以检测断开的连接。", + "field_help.strict_host_key": "如何处理未知的主机密钥。'ask' 提示用户,'no' 自动添加,'yes' 需要预先存在密钥。", + "field_help.known_hosts_file": "存储主机密钥的文件。可以指定多个文件。", + "field_help.hostkey_algos": "主机密钥算法的优先级顺序。使用 +/- 添加/移除默认值。", + "field_help.ciphers": "加密算法的优先级顺序。", + "field_help.macs": "消息认证码算法的优先级顺序。", + "field_help.check_host_ip": "在 known_hosts 文件中检查主机 IP 地址。", + "field_help.fingerprint_hash": "显示密钥指纹的哈希算法。", + "field_help.verify_dns": "使用 DNS SSHFP 记录验证主机密钥。", + "field_help.update_host_keys": "自动更新 known_hosts 中的新主机密钥。", + "field_help.hash_known_hosts": "对 known_hosts 文件中的主机名和地址进行哈希处理。", + "field_help.visual_host_key": "显示主机密钥的 ASCII 艺术表示。", + "field_help.kex_algorithms": "要使用的密钥交换算法。", + "field_help.local_command": "连接后在本地机器上执行的命令。", + "field_help.permit_local_command": "允许执行 LocalCommand。", + "field_help.escape_char": "SSH 会话的转义字符(默认为 ~)。设置为 'none' 以禁用。", + "field_help.send_env": "要发送到服务器的环境变量。", + "field_help.set_env": "为 SSH 会话设置环境变量。", + "field_help.log_level": "日志的详细程度。较高级别显示更多调试细节。", + "field_help.compression": "启用压缩以减少带宽使用。对于慢速连接很有用。", + "field_help.batch_mode": "禁用所有交互式提示。对于脚本和自动化很有用。", + "field_help.bind_address_field": "为连接使用特定的源地址。对于多宿主主机很有用。", + "field_help.bind_interface_field": "为连接使用特定的网络接口。对于通过特定网卡路由很有用。", + "field_help.address_family_field": "限制连接使用 IPv4 或 IPv6 地址。", + "field_help.canonicalize_hostname_field": "控制是否执行主机名规范化。对于缩短主机名很有用。", + "field_help.canonical_domains_field": "主机名规范化的搜索域名。SSH 将尝试附加这些域名。", + "field_help.canonicalize_fallback_field": "如果规范化失败是否失败。如果为 yes,则使用原始主机名。", + "field_help.canonicalize_max_dots_field": "禁用规范化之前主机名中的最多点数。", + "field_help.canonicalize_cname_field": "规范化期间 CNAME 跟随的规则。", + "field_help.exit_on_forward_failure_field": "如果端口转发失败则终止连接。", + "field_help.gateway_ports_field": "允许远程主机连接到转发的端口。", + + "placeholder_tags": "逗号分隔的标签", + "placeholder_proxy_jump": "例如: bastion.example.com", + "placeholder_proxy_command": "例如: ssh -W %h:%p jump.example.com", + "placeholder_remote_command": "例如: tmux attach", + "placeholder_local_forward": "例如: 8080:localhost:80, 3000:localhost:3000", + "placeholder_remote_forward": "例如: 80:localhost:8080", + "placeholder_dynamic_forward": "例如: 1080, 1081", + "placeholder_control_path": "例如: ~/.ssh/master-%r@%h:%p", + "placeholder_control_persist": "例如: 10m, 4h, yes, no", + "placeholder_preferred_auth": "例如: publickey,password", + "placeholder_algorithms": "算法(支持 +/-/^ 前缀)", + "placeholder_bind_address": "IP、主机名、*(全部)或 localhost", + "placeholder_canonical_domains": "例如: example.com, internal.net", + "placeholder_canonical_cname": "例如: *.example.com:example.net", + "placeholder_local_command": "例如: echo '已连接到 %h'", + "placeholder_send_env": "例如: LANG, LC_*, TERM", + "placeholder_set_env": "例如: FOO=bar, DEBUG=1", + "placeholder_keys": "例如: ~/.ssh/id_rsa, ~/.ssh/id_ed25519", + "placeholder_port_default": "默认: 22", + "placeholder_user_default": "默认: 当前用户", + "placeholder_timeout_seconds": "秒(默认: 无)", + "placeholder_timeout_default": "默认: %d 秒", + "placeholder_connection_attempts": "默认: 1", + "placeholder_alive_interval": "秒(默认: 0)", + "placeholder_alive_interval_default": "默认: %d 秒", + "placeholder_alive_count": "默认: 3", + "placeholder_password_prompts": "默认: 3", + "placeholder_max_dots": "默认: 1", + "placeholder_ip_qos": "默认: af21 cs1", + "placeholder_escape_char": "默认: ~", + "placeholder_identity_agent": "默认: SSH_AUTH_SOCK", + "placeholder_known_hosts": "默认: ~/.ssh/known_hosts", + + "section_proxy_command": "▶ 代理与命令[-]", + "section_connection_settings": "▶ 连接设置[-]", + "section_bind_options": "▶ 绑定选项[-]", + "section_canonicalization": "▶ 主机名规范化[-]", + "section_keepalive": "▶ 保活[-]", + "section_multiplexing": "▶ 多路复用[-]", + "section_port_forwarding": "▶ 端口转发[-]", + "section_agent_x11": "▶ Agent与X11转发[-]", + "section_pubkey_auth": "▶ 公钥认证[-]", + "section_ssh_agent": "▶ SSH Agent[-]", + "section_password_interactive": "▶ 密码与交互式[-]", + "section_security": "▶ 安全[-]", + "section_command_execution": "▶ 命令执行[-]", + "section_environment": "▶ 环境变量[-]", + "section_debugging": "▶ 调试[-]", + + "option_default": "默认 (%s)", + "option_none": "无", + "option_yes": "是", + "option_no": "否", + "option_ask": "询问", + "option_auto": "自动", + "option_force": "强制", + "option_confirm": "确认", + + "edit_tags_title": " 编辑标签: %s ", + "edit_tags_label": "标签(逗号):", + "edit_tags_saved": "标签已更新", + + "port_forward_title": " 端口转发: %s ", + "port_forward_type": "类型", + "port_forward_port": "端口", + "port_forward_host": "主机", + "port_forward_host_port": "远程端口", + "port_forward_bind": "绑定地址(可选)", + "port_forward_mode": "模式", + "port_forward_start": "启动", + "port_forward_cancel": "取消", + "port_forward_type_local": "本地转发", + "port_forward_type_remote": "远程转发", + "port_forward_type_dynamic": "动态转发", + "port_forward_mode_only": "仅转发", + "port_forward_mode_ssh": "转发+SSH", + "port_forward_starting": "正在启动端口转发…", + "port_forward_started": "端口转发已启动 (pid %d)", + "port_forward_failed": "转发失败: %s", + "port_forward_invalid_port": "端口无效: %s", + "port_forward_invalid_bind": "绑定地址无效: %s", + "port_forward_invalid_host": "主机无效: %s", + "port_forward_invalid_host_port": "远程端口无效: %s", + "port_forward_stopped": "已停止 %s 的转发", + "port_forward_stop_failed": "停止转发失败: %s", + + "status_refreshing": "正在刷新…", + "status_refresh_failed": "刷新失败: %s", + "status_refreshed": "已刷新 %d 个服务器", + "status_pinging": "正在探测 %s…", + "status_ping_up": "探测 %s: 在线 (%s)", + "status_ping_down": "探测 %s: 离线", + "status_copied": "已复制: %s", + "status_copy_failed": "复制失败", + "status_sort": "排序: %s", + "status_tags_updated": "标签已更新", + + "delete_confirm_title": "删除服务器 %s (%s@%s:%d)?\n\n此操作不可撤销。", + "delete_cancel": "取消", + "delete_confirm": "删除", + + "detail_basic": "基本设置", + "detail_advanced": "高级设置", + "detail_commands": "命令", + "detail_host": " 主机: [white]%s[-]", + "detail_user": " 用户: [white]%s[-]", + "detail_port": " 端口: [white]%s[-]", + "detail_key": " 密钥: [white]%s[-]", + "detail_tags": " 标签: %s", + "detail_pinned": " 已置顶: [white]%s[-]", + "detail_last_ssh": " 最近SSH: %s", + "detail_ssh_count": " SSH次数: [white]%d[-]", + "detail_never": "从未", + "detail_commands_list": " 回车: SSH连接\n f: 端口转发\n x: 停止转发\n c: 复制SSH命令\n g: 探测服务器\n r: 刷新列表\n a: 添加新服务器\n e: 编辑条目\n t: 编辑标签\n d: 删除条目\n p: 置顶/取消", + "detail_no_match": "没有服务器匹配当前筛选条件。", + + "time.just_now": "刚刚", + "time.minutes_ago": "%d分钟前", + "time.hours_ago": "%d小时前", + "time.days_ago": "%d天前", + "time.months_ago": "%d个月前", + "time.years_ago": "%d年前", + "list.last_ssh": "最近SSH: %s", + + "status.ping_down_err": "探测 %s: 离线 (%v)", + "form.unsaved_changes": "有未保存的更改。确定要退出吗?", + "form.btn.discard": "放弃", + "form.validation_title": "验证失败 (%d 个错误):\n\n", + "form.validation_more": "\n... 还有 %d 个错误", + "form.btn.ok": "确定", + "form.help.panel_title": "帮助", + + "validation.alias_format": "别名只能包含字母、数字、点、连字符和下划线", + "validation.host_format": "主机必须是有效的主机名或IP地址", + "validation.user_format": "用户必须以字母开头,且只能包含字母、数字、点、连字符和下划线", + "validation.keys_access": "密钥文件未找到或无法访问", + "validation.timeout_format": "连接超时必须是正数或 'none'", + "validation.attempts_format": "连接重试次数必须是正数", + "validation.alive_interval_format": "保活间隔必须是非负数", + "validation.alive_count_format": "保活失败次数必须是非负数", + "validation.ipqos_format": "IPQoS 必须是有效的 QoS 值(例如 'af21 cs1', 'lowdelay', 'ef')", + "validation.bind_format": "绑定地址必须是有效的 IP 地址、主机名或 '*'", + "validation.dynamic_forward_format": "动态转发格式必须为 [bind_address:]port", + "validation.password_prompts_range": "密码提示次数必须在 0 到 10 之间", + "validation.max_dots_format": "规范域名最多点数必须是非负数", + "validation.escape_char_format": "转义字符必须是单个字符、'none' 或 ^X 格式(例如 ^A)", + "validation.known_hosts_access": "已知主机文件未找到或无法访问", + "validation.timeout_invalid": "无效的超时值", + "validation.timeout_positive": "超时必须是正数或 'none'", + "validation.invalid_number": "无效的数字", + "validation.non_negative": "必须是非负数", + "validation.ipqos_max": "IPQoS 最多接受 2 个值", + "validation.ipqos_value_invalid": "无效的 IPQoS 值: %s", + "validation.host_no_spaces": "主机不能包含空格", + "validation.hostname_too_long": "主机名太长", + "validation.host_invalid_chars": "主机包含无效字符", + "validation.hostname_dots": "主机名不能以点开头或结尾", + "validation.hostname_consecutive_dots": "主机名不能包含连续的点", + "validation.hostname_empty_label": "主机名包含空标签", + "validation.hostname_label_too_long": "主机名标签太长", + "validation.hostname_label_hyphen": "主机名标签不能以连字符开头或结尾", + "validation.forward_format_detail": "格式无效,应为 [bind_address:]port:host:hostport", + "validation.bind_address_invalid": "绑定地址无效: %w", + "validation.port_number_invalid": "端口号无效: %s", + "validation.host_port_invalid": "远程端口号无效: %s", + "validation.dynamic_forward_format_detail": "格式无效,应为 [bind_address:]port", + "validation.address_no_spaces": "地址不能包含空格", + "validation.address_invalid_chars": "地址包含无效字符", + "validation.address_dots": "地址不能以点开头或结尾", + "validation.address_hyphen": "地址不能以连字符开头或结尾", + "validation.address_consecutive_dots": "地址不能包含连续的点", + "validation.ip_invalid": "无效的 IP 地址格式", + "validation.address_format": "无效的地址格式", + "validation.files_not_found": "文件未找到: %s", + "validation.files_not_accessible": "文件无法访问: %s", + "validation.file_invalid_chars": "文件路径包含无效字符", + + "default.none_system": "无(系统默认)", + "default.seconds": "%s 秒", + "default.disabled": "0(禁用)", + "default.none": "无", + "default.current_username": "当前用户名", + + "field_help.alias.syntax": "任意无空格字符串", + "field_help.alias.examples": "myserver|prod-db|dev-web-01", + "field_help.alias.default": "必填", + "field_help.host.syntax": "主机名 | IP地址", + "field_help.host.examples": "example.com|192.168.1.100|2001:db8::1", + "field_help.host.default": "必填", + "field_help.port.syntax": "端口号 (1-65535)", + "field_help.port.examples": "22|2222|8022", + "field_help.port.default": "22", + "field_help.user.syntax": "用户名", + "field_help.user.examples": "root|ubuntu|admin|deploy", + "field_help.user.default": "当前用户名", + "field_help.keys.syntax": "路径[,路径,...]", + "field_help.keys.examples": "~/.ssh/id_ed25519|~/.ssh/id_rsa,~/.ssh/id_ed25519", + "field_help.keys.default": "~/.ssh/id_rsa, ~/.ssh/id_ed25519 等" +} From eb5ce7075c619eed77ba54b32e4aa2e822e999de Mon Sep 17 00:00:00 2001 From: ant <3822085410@qq.com> Date: Tue, 19 May 2026 22:18:24 +0800 Subject: [PATCH 2/3] feat(ui): add config export/import and fix i18n for tabs/help/modals - Add ExportServers/ImportServers to repository and service - Fix path expansion for ~/ in export/import (use HasPrefix instead of IsAbs) - Add keyboard shortcuts E/I for export/import - Add i18n translations for export/import functionality - Fix Tab labels to use i18n translations - Fix Help panel title and content formatting to use i18n - Fix modal buttons and validation error messages to use i18n - Add language suffix to build output filenames (lazyssh-zh-CN) - Add helper functions for tab name and abbrev translations --- build.sh | 23 +-- .../ssh_config_file/ssh_config_file_repo.go | 131 ++++++++++++++++ internal/adapters/ui/handlers.go | 99 ++++++++++++ internal/adapters/ui/server_form.go | 142 +++++++++++------- internal/core/ports/repositories.go | 17 +++ internal/core/ports/services.go | 2 + internal/core/services/server_service.go | 8 + internal/i18n/locales/en.json | 22 ++- internal/i18n/locales/zh-CN.json | 22 ++- 9 files changed, 400 insertions(+), 66 deletions(-) diff --git a/build.sh b/build.sh index 173589e3..038ebea8 100755 --- a/build.sh +++ b/build.sh @@ -123,6 +123,11 @@ build_binary() { fi } +LANG_SUFFIX="" +if [[ "$LANG" != "en" ]]; then + LANG_SUFFIX="-${LANG}" +fi + log_info "LazySSH Build" log_info "Version: ${VERSION}" log_info "Commit: ${COMMIT}" @@ -130,29 +135,27 @@ log_info "Language: ${LANG}" log_info "Output: ${OUTPUT_DIR}" echo "" -# Clean previous build rm -f "${OUTPUT_DIR}"/lazyssh* 2>/dev/null || true if [[ "$PARALLEL" == true ]]; then log_info "Building for all platforms..." echo "" - build_binary "linux" "amd64" "lazyssh-linux-amd64" - build_binary "linux" "arm64" "lazyssh-linux-arm64" - build_binary "darwin" "amd64" "lazyssh-darwin-amd64" - build_binary "darwin" "arm64" "lazyssh-darwin-arm64" - build_binary "windows" "amd64" "lazyssh-windows-amd64.exe" + build_binary "linux" "amd64" "lazyssh${LANG_SUFFIX}-linux-amd64" + build_binary "linux" "arm64" "lazyssh${LANG_SUFFIX}-linux-arm64" + build_binary "darwin" "amd64" "lazyssh${LANG_SUFFIX}-darwin-amd64" + build_binary "darwin" "arm64" "lazyssh${LANG_SUFFIX}-darwin-arm64" + build_binary "windows" "amd64" "lazyssh${LANG_SUFFIX}-windows-amd64.exe" echo "" log_success "All builds complete!" else - # Build for current platform CURRENT_OS=$(go env GOOS) CURRENT_ARCH=$(go env GOARCH) - build_binary "$CURRENT_OS" "$CURRENT_ARCH" "lazyssh" + build_binary "$CURRENT_OS" "$CURRENT_ARCH" "lazyssh${LANG_SUFFIX}" echo "" - log_success "Build complete: ${OUTPUT_DIR}/lazyssh" - log_info "Run: ${OUTPUT_DIR}/lazyssh" + log_success "Build complete: ${OUTPUT_DIR}/lazyssh${LANG_SUFFIX}" + log_info "Run: ${OUTPUT_DIR}/lazyssh${LANG_SUFFIX}" fi diff --git a/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go b/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go index d328c2a8..4ab50684 100644 --- a/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go +++ b/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go @@ -15,7 +15,12 @@ package ssh_config_file import ( + "encoding/json" "fmt" + "os" + "path/filepath" + "strings" + "time" "github.com/Adembc/lazyssh/internal/core/crypto" "github.com/Adembc/lazyssh/internal/core/domain" @@ -192,3 +197,129 @@ func (r *Repository) SetPassword(alias string, password string) error { func (r *Repository) GetPassword(alias string) (string, error) { return r.metadataManager.getPassword(alias) } + +func (r *Repository) ExportServers(path string) error { + servers, err := r.ListServers("") + if err != nil { + return fmt.Errorf("failed to list servers: %w", err) + } + + exportMeta := make(map[string]ports.ServerExportMeta) + for _, server := range servers { + meta := ports.ServerExportMeta{ + Tags: server.Tags, + SSHCount: server.SSHCount, + } + if !server.PinnedAt.IsZero() { + meta.PinnedAt = server.PinnedAt.Format(time.RFC3339) + } + if !server.LastSeen.IsZero() { + meta.LastSeen = server.LastSeen.Format(time.RFC3339) + } + if server.Password != "" { + meta.Password = server.Password + } + exportMeta[server.Alias] = meta + } + + data := ports.ExportData{ + Version: "1.0", + Servers: servers, + Metadata: exportMeta, + ExportedAt: time.Now().Format(time.RFC3339), + } + + absPath := path + if strings.HasPrefix(path, "~") { + home, _ := os.UserHomeDir() + relPath := strings.TrimPrefix(path, "~") + relPath = strings.TrimPrefix(relPath, "/") + absPath = filepath.Join(home, relPath) + } else if !filepath.IsAbs(path) { + home, _ := os.UserHomeDir() + absPath = filepath.Join(home, path) + } + + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal export data: %w", err) + } + + if err := os.WriteFile(absPath, jsonData, 0644); err != nil { + return fmt.Errorf("failed to write export file: %w", err) + } + + return nil +} + +func (r *Repository) ImportServers(path string, merge bool) (int, int, error) { + absPath := path + if strings.HasPrefix(path, "~") { + home, _ := os.UserHomeDir() + relPath := strings.TrimPrefix(path, "~") + relPath = strings.TrimPrefix(relPath, "/") + absPath = filepath.Join(home, relPath) + } else if !filepath.IsAbs(path) { + home, _ := os.UserHomeDir() + absPath = filepath.Join(home, path) + } + + jsonData, err := os.ReadFile(absPath) + if err != nil { + return 0, 0, fmt.Errorf("failed to read import file: %w", err) + } + + var data ports.ExportData + if err := json.Unmarshal(jsonData, &data); err != nil { + return 0, 0, fmt.Errorf("failed to parse import file: %w", err) + } + + cfg, err := r.loadConfig() + if err != nil { + return 0, 0, fmt.Errorf("failed to load config: %w", err) + } + + imported := 0 + skipped := 0 + + for _, server := range data.Servers { + exists := r.serverExists(cfg, server.Alias) + + if exists && !merge { + host := r.findHostByAlias(cfg, server.Alias) + if host != nil { + r.updateHostNodes(host, server) + imported++ + } + } else if !exists { + host := r.createHostFromServer(server) + cfg.Hosts = append(cfg.Hosts, host) + imported++ + } else { + skipped++ + } + + if meta, ok := data.Metadata[server.Alias]; ok { + password := meta.Password + server.Password = password + server.Tags = meta.Tags + if meta.PinnedAt != "" { + server.PinnedAt, _ = time.Parse(time.RFC3339, meta.PinnedAt) + } + if meta.LastSeen != "" { + server.LastSeen, _ = time.Parse(time.RFC3339, meta.LastSeen) + } + server.SSHCount = meta.SSHCount + server.Password = "" + _ = r.metadataManager.updateServer(server, server.Alias, password) + } + } + + if imported > 0 { + if err := r.saveConfig(cfg); err != nil { + return imported, skipped, fmt.Errorf("failed to save config: %w", err) + } + } + + return imported, skipped, nil +} diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 92226c6e..48fa026d 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -93,6 +93,12 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { case 'k': t.handleNavigateUp() return nil + case 'E': + t.handleExport() + return nil + case 'I': + t.handleImport() + return nil } if event.Key() == tcell.KeyEnter { @@ -645,3 +651,96 @@ func (t *tui) handleStopForwarding() { }() } } + +func (t *tui) handleExport() { + form := tview.NewForm() + form.SetTitle(i18n.T("export.title")) + form.SetBorder(true) + + pathField := tview.NewInputField() + pathField.SetLabel(i18n.T("export.path_label")) + pathField.SetPlaceholder("~/lazyssh-export.json") + pathField.SetText("~/lazyssh-export.json") + + form.AddFormItem(pathField) + form.AddButton(i18n.T("export.export_btn"), func() { + path := pathField.GetText() + go func() { + err := t.serverService.ExportServers(path) + t.app.QueueUpdateDraw(func() { + if err != nil { + modal := tview.NewModal(). + SetText(fmt.Sprintf(i18n.T("export.failed"), err.Error())). + AddButtons([]string{i18n.T("common.close")}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + t.returnToMain() + }) + t.app.SetRoot(modal, true) + } else { + modal := tview.NewModal(). + SetText(fmt.Sprintf(i18n.T("export.success_msg"), path)). + AddButtons([]string{i18n.T("common.close")}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + t.returnToMain() + }) + t.app.SetRoot(modal, true) + } + }) + }() + }) + form.AddButton(i18n.T("common.cancel"), func() { + t.returnToMain() + }) + + t.app.SetRoot(form, true) +} + +func (t *tui) handleImport() { + form := tview.NewForm() + form.SetTitle(i18n.T("import.title")) + form.SetBorder(true) + + pathField := tview.NewInputField() + pathField.SetLabel(i18n.T("import.path_label")) + pathField.SetPlaceholder("~/lazyssh-export.json") + + mergeField := tview.NewCheckbox() + mergeField.SetLabel(i18n.T("import.merge_label")) + mergeField.SetChecked(true) + + form.AddFormItem(pathField) + form.AddFormItem(mergeField) + form.AddButton(i18n.T("import.import_btn"), func() { + path := pathField.GetText() + merge := mergeField.IsChecked() + go func() { + imported, skipped, err := t.serverService.ImportServers(path, merge) + t.app.QueueUpdateDraw(func() { + if err != nil { + modal := tview.NewModal(). + SetText(fmt.Sprintf(i18n.T("import.failed"), err.Error())). + AddButtons([]string{i18n.T("common.close")}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + t.returnToMain() + }) + t.app.SetRoot(modal, true) + } else { + msg := fmt.Sprintf(i18n.T("import.success_msg"), imported, skipped) + modal := tview.NewModal(). + SetText(msg). + AddButtons([]string{i18n.T("common.close")}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + t.returnToMain() + t.refreshServerList() + }) + t.app.SetRoot(modal, true) + } + }) + }() + }) + form.AddButton(i18n.T("common.cancel"), func() { + t.returnToMain() + }) + + t.app.SetRoot(form, true) +} diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 389953a9..51e605ca 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -21,6 +21,7 @@ import ( "strings" "github.com/Adembc/lazyssh/internal/core/domain" + "github.com/Adembc/lazyssh/internal/i18n" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -103,13 +104,71 @@ var fieldNameTranslations = map[string]string{ "LogLevel": "日志级别", } +func translateTabName(name string) string { + if i18n.Lang() != "zh-CN" { + return name + } + switch name { + case "Basic": + return "基本" + case "Connection": + return "连接" + case "Forwarding": + return "转发" + case "Authentication": + return "认证" + case "Advanced": + return "高级" + default: + return name + } +} + +func translateTabAbbrev(name string) string { + if i18n.Lang() != "zh-CN" { + switch name { + case "Connection": + return "Conn" + case "Forwarding": + return "Fwd" + case "Authentication": + return "Auth" + case "Advanced": + return "Adv" + default: + return name + } + } + switch name { + case "Basic": + return "基本" + case "Connection": + return "连接" + case "Forwarding": + return "转发" + case "Authentication": + return "认证" + case "Advanced": + return "高级" + default: + return name + } +} + func translateFieldName(name string) string { + if i18n.Lang() != "zh-CN" { + return name + } if zh, ok := fieldNameTranslations[name]; ok { return zh } return name } +func getFieldLabel(fieldName string) string { + return translateFieldName(fieldName) + ":" +} + type ServerFormMode int const ( @@ -156,7 +215,7 @@ func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm { SetScrollable(true) helpPanel.SetBorder(true). SetBorderPadding(0, 0, 1, 1). - SetTitle(" Help "). + SetTitle(" " + i18n.T("form.help.panel_title") + " "). SetTitleAlign(tview.AlignCenter) // Create main container for form and help @@ -278,9 +337,9 @@ func (sf *ServerForm) build() { func (sf *ServerForm) titleForMode() string { if sf.mode == ServerFormEdit { - return "Edit Server" + return i18n.T("form.title.edit") } - return "Add Server" + return i18n.T("form.title.add") } func (sf *ServerForm) getCurrentTabIndex() int { @@ -295,9 +354,9 @@ func (sf *ServerForm) getCurrentTabIndex() int { func (sf *ServerForm) calculateTabsWidth(useAbbrev bool) int { width := 0 for i, tab := range sf.tabs { - tabName := tab + tabName := translateTabName(tab) if useAbbrev { - tabName = sf.tabAbbrev[tab] + tabName = translateTabAbbrev(tab) } width += len(tabName) + 2 // space + name + space if i < len(sf.tabs)-1 { @@ -326,9 +385,9 @@ func (sf *ServerForm) determineDisplayMode(width int) string { } func (sf *ServerForm) renderTab(tab string, isCurrent bool, useAbbrev bool, index int) string { - tabName := tab + tabName := translateTabName(tab) if useAbbrev { - tabName = sf.tabAbbrev[tab] + tabName = translateTabAbbrev(tab) } regionID := fmt.Sprintf("tab_%d", index) if isCurrent { @@ -554,46 +613,40 @@ func escapeForTview(text string) string { func (sf *ServerForm) formatDetailedHelp(help *FieldHelp) string { var b strings.Builder - // Calculate separator width dynamically - // Get the actual width of the help panel if possible - separatorWidth := 40 // Default width + separatorWidth := 40 if sf.helpPanel != nil { _, _, width, _ := sf.helpPanel.GetInnerRect() if width > 0 { - separatorWidth = width // Fill entire width + separatorWidth = width } } - // Title with field name and separator below - b.WriteString(fmt.Sprintf("[yellow::b]📖 %s[-::-]\n", translateFieldName(help.Field))) - b.WriteString("[#444444]" + strings.Repeat("─", separatorWidth) + "[-]\n\n") + b.WriteString(fmt.Sprintf(i18n.T("form.help.title"), translateFieldName(help.Field))) + b.WriteString("\n[#444444]" + strings.Repeat("─", separatorWidth) + "[-]\n\n") - // Description - needs escaping as it might contain brackets b.WriteString(fmt.Sprintf("%s\n\n", escapeForTview(help.Description))) - // Syntax - needs escaping as it often contains brackets like [user@] if help.Syntax != "" { - b.WriteString("[cyan]Syntax:[-] ") - b.WriteString(fmt.Sprintf("%s\n\n", escapeForTview(help.Syntax))) + b.WriteString(fmt.Sprintf(i18n.T("form.help.syntax"), escapeForTview(help.Syntax))) + b.WriteString("\n\n") } - // Examples - needs escaping as they might contain special characters if len(help.Examples) > 0 { - b.WriteString("[cyan]Examples:[-]\n") + b.WriteString(i18n.T("form.help.examples") + "\n") for _, ex := range help.Examples { b.WriteString(fmt.Sprintf(" • %s\n", escapeForTview(ex))) } b.WriteString("\n") } - // Default value - already processed by formatDefaultValue, no additional escaping needed if help.Default != "" { - b.WriteString(fmt.Sprintf("[dim]Default: %s[-]\n", help.Default)) + b.WriteString(fmt.Sprintf(i18n.T("form.help.default"), help.Default)) + b.WriteString("\n") } - // Version info - unlikely to contain brackets, but escape for safety if help.Since != "" { - b.WriteString(fmt.Sprintf("[dim]Available since: %s[-]\n", escapeForTview(help.Since))) + b.WriteString(fmt.Sprintf(i18n.T("form.help.since"), escapeForTview(help.Since))) + b.WriteString("\n") } return b.String() @@ -1029,12 +1082,12 @@ func (sf *ServerForm) validateField(fieldName, value string) string { // addDropDownWithHelp adds a dropdown field with help support func (sf *ServerForm) addDropDownWithHelp(form *tview.Form, label, fieldName string, options []string, initialOption int) { + translatedLabel := getFieldLabel(fieldName) dropdown := tview.NewDropDown(). - SetLabel(label). + SetLabel(translatedLabel). SetOptions(options, nil). SetCurrentOption(initialOption) - // Add focus handler to show help dropdown.SetFocusFunc(func() { sf.updateHelp(fieldName) }) @@ -1042,10 +1095,10 @@ func (sf *ServerForm) addDropDownWithHelp(form *tview.Form, label, fieldName str form.AddFormItem(dropdown) } -// addInputFieldWithHelp adds a regular input field with help support func (sf *ServerForm) addInputFieldWithHelp(form *tview.Form, label, fieldName, defaultValue string, width int, placeholder string) *tview.InputField { + translatedLabel := getFieldLabel(fieldName) field := tview.NewInputField(). - SetLabel(label). + SetLabel(translatedLabel). SetText(defaultValue). SetFieldWidth(width) @@ -1064,11 +1117,11 @@ func (sf *ServerForm) addInputFieldWithHelp(form *tview.Form, label, fieldName, // addValidatedInputField adds an input field with real-time validation func (sf *ServerForm) addValidatedInputField(form *tview.Form, label, fieldName, defaultValue string, width int, placeholder string) *tview.InputField { - // Store the original label without color tags - originalLabel := label + translatedLabel := getFieldLabel(fieldName) + originalLabel := translatedLabel field := tview.NewInputField(). - SetLabel(label). + SetLabel(translatedLabel). SetText(defaultValue). SetFieldWidth(width) @@ -1971,34 +2024,19 @@ func (sf *ServerForm) handleSave() bool { } // Build error message - errorMsg := fmt.Sprintf("Validation failed (%d error%s):\n\n", - sf.validation.GetErrorCount(), - func() string { - if sf.validation.GetErrorCount() == 1 { - return "" - } - return "s" - }()) - + errorCount := sf.validation.GetErrorCount() + errorMsg := i18n.T("form.validation_title") for i, err := range errors { errorMsg += fmt.Sprintf("%d. %s\n", i+1, err) } if truncated { - errorMsg += fmt.Sprintf("\n... and %d more error%s", - sf.validation.GetErrorCount()-maxErrorsToShow, - func() string { - if sf.validation.GetErrorCount()-maxErrorsToShow == 1 { - return "" - } - return "s" - }()) + errorMsg += fmt.Sprintf(i18n.T("form.validation_more"), errorCount-maxErrorsToShow) } - // Use tview's built-in Modal modal := tview.NewModal(). SetText(errorMsg). - AddButtons([]string{"OK"}). + AddButtons([]string{i18n.T("form.btn.ok")}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { sf.app.SetRoot(sf.Flex, true) }) @@ -2028,8 +2066,8 @@ func (sf *ServerForm) handleCancel() { // If app reference is available, show confirmation dialog if sf.app != nil { modal := tview.NewModal(). - SetText("You have unsaved changes. Are you sure you want to exit?"). - AddButtons([]string{"[yellow]S[-]ave", "[yellow]D[-]iscard", "[yellow]C[-]ancel"}). + SetText(i18n.T("form.unsaved_changes")). + AddButtons([]string{i18n.T("form.btn.save"), i18n.T("form.btn.discard"), i18n.T("form.btn.cancel")}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { switch buttonIndex { case 0: // Save diff --git a/internal/core/ports/repositories.go b/internal/core/ports/repositories.go index dcbb6c7e..089ce6ae 100644 --- a/internal/core/ports/repositories.go +++ b/internal/core/ports/repositories.go @@ -25,4 +25,21 @@ type ServerRepository interface { RecordSSH(alias string) error SetPassword(alias string, password string) error GetPassword(alias string) (string, error) + ExportServers(path string) error + ImportServers(path string, merge bool) (int, int, error) +} + +type ExportData struct { + Version string + Servers []domain.Server + Metadata map[string]ServerExportMeta + ExportedAt string +} + +type ServerExportMeta struct { + Tags []string + PinnedAt string + SSHCount int + LastSeen string + Password string } diff --git a/internal/core/ports/services.go b/internal/core/ports/services.go index 2407269f..80e725e8 100644 --- a/internal/core/ports/services.go +++ b/internal/core/ports/services.go @@ -32,4 +32,6 @@ type ServerService interface { StopForwarding(alias string) error IsForwarding(alias string) bool Ping(server domain.Server) (bool, time.Duration, error) + ExportServers(path string) error + ImportServers(path string, merge bool) (int, int, error) } diff --git a/internal/core/services/server_service.go b/internal/core/services/server_service.go index 9e09f5ce..9c8a3738 100644 --- a/internal/core/services/server_service.go +++ b/internal/core/services/server_service.go @@ -412,3 +412,11 @@ func resolveSSHDestination(alias string) (string, int, bool) { } return host, port, true } + +func (s *serverService) ExportServers(path string) error { + return s.serverRepository.ExportServers(path) +} + +func (s *serverService) ImportServers(path string, merge bool) (int, int, error) { + return s.serverRepository.ImportServers(path, merge) +} diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 851f5a1a..12594fc5 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -9,7 +9,7 @@ "search.label": " 🔍 Search: ", "search.title": " Search ", - "statusbar.keys": "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]f[-] Forward • [white]x[-] Stop forward • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit", + "statusbar.keys": "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]f[-] Forward • [white]x[-] Stop forward • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]E[-] Export • [white]I[-] Import • [white]/[-] Search • [white]q[-] Quit", "details.title": " Details ", "details.label.basic": "Basic Settings", @@ -582,5 +582,23 @@ "field_help.user.default": "Current username", "field_help.keys.syntax": "path[,path,...]", "field_help.keys.examples": "~/.ssh/id_ed25519|~/.ssh/id_rsa,~/.ssh/id_ed25519", - "field_help.keys.default": "~/.ssh/id_rsa, ~/.ssh/id_ed25519 etc." + "field_help.keys.default": "~/.ssh/id_rsa, ~/.ssh/id_ed25519 etc.", + + "export.title": " Export Config ", + "export.path_label": "Export Path:", + "export.export_btn": "Export", + "export.failed": "Export failed: %s", + "export.success": "Export Success", + "export.success_msg": "Exported to %s", + + "import.title": " Import Config ", + "import.path_label": "Import Path:", + "import.merge_label": "Merge Mode:", + "import.import_btn": "Import", + "import.failed": "Import failed: %s", + "import.success": "Import Success", + "import.success_msg": "Imported %d, skipped %d existing", + + "common.cancel": "Cancel", + "common.close": "Close" } diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 818fa208..e1efbb50 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -9,7 +9,7 @@ "search.label": " 🔍 搜索: ", "search.title": " 搜索 ", - "statusbar.keys": "[white]↑↓[-] 导航 • [white]Enter[-] SSH • [white]f[-] 转发 • [white]x[-] 停止转发 • [white]c[-] 复制SSH • [white]a[-] 添加 • [white]e[-] 编辑 • [white]g[-] 探测 • [white]d[-] 删除 • [white]p[-] 置顶/取消 • [white]/[-] 搜索 • [white]q[-] 退出", + "statusbar.keys": "[white]↑↓[-] 导航 • [white]Enter[-] SSH • [white]f[-] 转发 • [white]x[-] 停止转发 • [white]c[-] 复制SSH • [white]a[-] 添加 • [white]e[-] 编辑 • [white]g[-] 探测 • [white]d[-] 删除 • [white]p[-] 置顶/取消 • [white]E[-] 导出 • [white]I[-] 导入 • [white]/[-] 搜索 • [white]q[-] 退出", "details.title": " 详情 ", "details.label.basic": "基本设置", @@ -582,5 +582,23 @@ "field_help.user.default": "当前用户名", "field_help.keys.syntax": "路径[,路径,...]", "field_help.keys.examples": "~/.ssh/id_ed25519|~/.ssh/id_rsa,~/.ssh/id_ed25519", - "field_help.keys.default": "~/.ssh/id_rsa, ~/.ssh/id_ed25519 等" + "field_help.keys.default": "~/.ssh/id_rsa, ~/.ssh/id_ed25519 等", + + "export.title": " 导出配置 ", + "export.path_label": "导出路径:", + "export.export_btn": "导出", + "export.failed": "导出失败: %s", + "export.success": "导出成功", + "export.success_msg": "已导出到 %s", + + "import.title": " 导入配置 ", + "import.path_label": "导入路径:", + "import.merge_label": "合并模式:", + "import.import_btn": "导入", + "import.failed": "导入失败: %s", + "import.success": "导入成功", + "import.success_msg": "导入 %d 个,跳过 %d 个已存在", + + "common.cancel": "取消", + "common.close": "关闭" } From c6a70da44534b3ce74c30a23e137ee5666b411af Mon Sep 17 00:00:00 2001 From: ant <3822085410@qq.com> Date: Tue, 19 May 2026 22:28:09 +0800 Subject: [PATCH 3/3] fix(ui): restore AuthMethod and LoginMode fields in Basic form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Password field to ServerFormData struct - Add AuthMethod dropdown (密钥/密码/密钥+密码) - Add LoginMode dropdown (交互式/批处理/自动) - Load password from sf.original.Password for edit mode --- internal/adapters/ui/server_form.go | 248 ++++++++++------------------ 1 file changed, 83 insertions(+), 165 deletions(-) diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 51e605ca..b39addd5 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -1206,6 +1206,7 @@ func (sf *ServerForm) getDefaultValues() ServerFormData { Port: fmt.Sprint(sf.original.Port), Key: strings.Join(sf.original.IdentityFiles, ", "), Tags: strings.Join(sf.original.Tags, ", "), + Password: sf.original.Password, ProxyJump: sf.original.ProxyJump, ProxyCommand: sf.original.ProxyCommand, RemoteCommand: sf.original.RemoteCommand, @@ -1218,181 +1219,104 @@ func (sf *ServerForm) getDefaultValues() ServerFormData { AddressFamily: sf.original.AddressFamily, ExitOnForwardFailure: sf.original.ExitOnForwardFailure, IPQoS: sf.original.IPQoS, - // Hostname canonicalization + CanonicalizeHostname: sf.original.CanonicalizeHostname, CanonicalDomains: sf.original.CanonicalDomains, CanonicalizeFallbackLocal: sf.original.CanonicalizeFallbackLocal, CanonicalizeMaxDots: sf.original.CanonicalizeMaxDots, CanonicalizePermittedCNAMEs: sf.original.CanonicalizePermittedCNAMEs, - GatewayPorts: sf.original.GatewayPorts, - LocalForward: strings.Join(sf.original.LocalForward, ", "), - RemoteForward: strings.Join(sf.original.RemoteForward, ", "), - DynamicForward: strings.Join(sf.original.DynamicForward, ", "), - ClearAllForwardings: sf.original.ClearAllForwardings, - // Public key - PubkeyAuthentication: sf.original.PubkeyAuthentication, - IdentitiesOnly: sf.original.IdentitiesOnly, - // SSH Agent - AddKeysToAgent: sf.original.AddKeysToAgent, - IdentityAgent: sf.original.IdentityAgent, - // Password & Interactive + + GatewayPorts: sf.original.GatewayPorts, + LocalForward: strings.Join(sf.original.LocalForward, ", "), + RemoteForward: strings.Join(sf.original.RemoteForward, ", "), + DynamicForward: strings.Join(sf.original.DynamicForward, ", "), + ClearAllForwardings: sf.original.ClearAllForwardings, + + PubkeyAuthentication: sf.original.PubkeyAuthentication, + IdentitiesOnly: sf.original.IdentitiesOnly, + AddKeysToAgent: sf.original.AddKeysToAgent, + IdentityAgent: sf.original.IdentityAgent, PasswordAuthentication: sf.original.PasswordAuthentication, KbdInteractiveAuthentication: sf.original.KbdInteractiveAuthentication, NumberOfPasswordPrompts: sf.original.NumberOfPasswordPrompts, - // Advanced - PreferredAuthentications: sf.original.PreferredAuthentications, - ForwardAgent: sf.original.ForwardAgent, - ForwardX11: sf.original.ForwardX11, - ForwardX11Trusted: sf.original.ForwardX11Trusted, - ControlMaster: sf.original.ControlMaster, - ControlPath: sf.original.ControlPath, - ControlPersist: sf.original.ControlPersist, - ServerAliveInterval: sf.original.ServerAliveInterval, - ServerAliveCountMax: sf.original.ServerAliveCountMax, - Compression: sf.original.Compression, - TCPKeepAlive: sf.original.TCPKeepAlive, - BatchMode: sf.original.BatchMode, - StrictHostKeyChecking: sf.original.StrictHostKeyChecking, - UserKnownHostsFile: sf.original.UserKnownHostsFile, - HostKeyAlgorithms: sf.original.HostKeyAlgorithms, - PubkeyAcceptedAlgorithms: sf.original.PubkeyAcceptedAlgorithms, + PreferredAuthentications: sf.original.PreferredAuthentications, + + ForwardAgent: sf.original.ForwardAgent, + ForwardX11: sf.original.ForwardX11, + ForwardX11Trusted: sf.original.ForwardX11Trusted, + ControlMaster: sf.original.ControlMaster, + ControlPath: sf.original.ControlPath, + ControlPersist: sf.original.ControlPersist, + ServerAliveInterval: sf.original.ServerAliveInterval, + ServerAliveCountMax: sf.original.ServerAliveCountMax, + Compression: sf.original.Compression, + TCPKeepAlive: sf.original.TCPKeepAlive, + BatchMode: sf.original.BatchMode, + StrictHostKeyChecking: sf.original.StrictHostKeyChecking, + UserKnownHostsFile: sf.original.UserKnownHostsFile, + HostKeyAlgorithms: sf.original.HostKeyAlgorithms, + PubkeyAcceptedAlgorithms: sf.original.PubkeyAcceptedAlgorithms, HostbasedAcceptedAlgorithms: sf.original.HostbasedAcceptedAlgorithms, - MACs: sf.original.MACs, - Ciphers: sf.original.Ciphers, - KexAlgorithms: sf.original.KexAlgorithms, - VerifyHostKeyDNS: sf.original.VerifyHostKeyDNS, - UpdateHostKeys: sf.original.UpdateHostKeys, - HashKnownHosts: sf.original.HashKnownHosts, - VisualHostKey: sf.original.VisualHostKey, - LocalCommand: sf.original.LocalCommand, - PermitLocalCommand: sf.original.PermitLocalCommand, - EscapeChar: sf.original.EscapeChar, - SendEnv: strings.Join(sf.original.SendEnv, ", "), - SetEnv: strings.Join(sf.original.SetEnv, ", "), - LogLevel: sf.original.LogLevel, - } - } - // For new servers, use empty values instead of SSH defaults - // SSH defaults will be applied by the SSH client if values are not specified + MACs: sf.original.MACs, + Ciphers: sf.original.Ciphers, + KexAlgorithms: sf.original.KexAlgorithms, + VerifyHostKeyDNS: sf.original.VerifyHostKeyDNS, + UpdateHostKeys: sf.original.UpdateHostKeys, + HashKnownHosts: sf.original.HashKnownHosts, + VisualHostKey: sf.original.VisualHostKey, + LocalCommand: sf.original.LocalCommand, + PermitLocalCommand: sf.original.PermitLocalCommand, + EscapeChar: sf.original.EscapeChar, + SendEnv: strings.Join(sf.original.SendEnv, ", "), + SetEnv: strings.Join(sf.original.SetEnv, ", "), + LogLevel: sf.original.LogLevel, + } + } return ServerFormData{ - Alias: "", // Explicitly empty for new servers - Host: "", // Explicitly empty for new servers - User: "", // Empty for new servers (SSH will use current username) - Port: "22", // Keep port 22 as it's the standard SSH port - Key: "", // Empty for new servers (SSH will try default keys) - Tags: "", - - // All other fields should be empty for new servers - // The SSH client will use its defaults when these are not specified - ProxyJump: "", - ProxyCommand: "", - RemoteCommand: "", - RequestTTY: "", - SessionType: "", - ConnectTimeout: "", - ConnectionAttempts: "", - BindAddress: "", - BindInterface: "", - AddressFamily: "", - ExitOnForwardFailure: "", - IPQoS: "", - - // Hostname canonicalization - CanonicalizeHostname: "", - CanonicalDomains: "", - CanonicalizeFallbackLocal: "", - CanonicalizeMaxDots: "", - CanonicalizePermittedCNAMEs: "", - - // Port forwarding - LocalForward: "", - RemoteForward: "", - DynamicForward: "", - ClearAllForwardings: "", - GatewayPorts: "", - - // Authentication - PubkeyAuthentication: "", - IdentitiesOnly: "", - AddKeysToAgent: "", - IdentityAgent: "", - PasswordAuthentication: "", - KbdInteractiveAuthentication: "", - NumberOfPasswordPrompts: "", - PreferredAuthentications: "", - PubkeyAcceptedAlgorithms: "", - HostbasedAcceptedAlgorithms: "", - - // Forwarding - ForwardAgent: "", - ForwardX11: "", - ForwardX11Trusted: "", - - // Multiplexing - ControlMaster: "", - ControlPath: "", - ControlPersist: "", - - // Keep-alive - ServerAliveInterval: "", - ServerAliveCountMax: "", - TCPKeepAlive: "", - - // Connection - Compression: "", - BatchMode: "", - - // Security - StrictHostKeyChecking: "", - CheckHostIP: "", - FingerprintHash: "", - UserKnownHostsFile: "", - HostKeyAlgorithms: "", - MACs: "", - Ciphers: "", - KexAlgorithms: "", - VerifyHostKeyDNS: "", - UpdateHostKeys: "", - HashKnownHosts: "", - VisualHostKey: "", - - // Command execution - LocalCommand: "", - PermitLocalCommand: "", - EscapeChar: "", - - // Environment - SendEnv: "", - SetEnv: "", - - // Debugging - LogLevel: "", + Alias: "", + Host: "", + User: "", + Port: "", + Key: "", + Tags: "", + Password: "", } } -// createBasicForm creates the Basic configuration tab func (sf *ServerForm) createBasicForm() { form := tview.NewForm() defaultValues := sf.getDefaultValues() - // Add validated input fields sf.addValidatedInputField(form, "Alias:", "Alias", defaultValues.Alias, 20, GetFieldPlaceholder("Alias")) sf.addValidatedInputField(form, "Host/IP:", "Host", defaultValues.Host, 20, GetFieldPlaceholder("Host")) sf.addValidatedInputField(form, "User:", "User", defaultValues.User, 20, GetFieldPlaceholder("User")) sf.addValidatedInputField(form, "Port:", "Port", defaultValues.Port, 20, GetFieldPlaceholder("Port")) - // Keys field with autocomplete keysField := sf.addValidatedInputField(form, "Keys:", "Keys", defaultValues.Key, 40, GetFieldPlaceholder("Keys")) keysField.SetAutocompleteFunc(sf.createSSHKeyAutocomplete()) - // Tags field + authMethodOptions := []string{i18n.T("auth.method.key"), i18n.T("auth.method.password"), i18n.T("auth.method.key_password")} + authMethodIndex := 0 + if defaultValues.Password != "" { + if defaultValues.Key != "" { + authMethodIndex = 2 + } else { + authMethodIndex = 1 + } + } + sf.addDropDownWithHelp(form, "AuthMethod:", "AuthMethod", authMethodOptions, authMethodIndex) + + sf.addValidatedInputField(form, "Password:", "Password", defaultValues.Password, 40, GetFieldPlaceholder("Password")) + + loginModeOptions := []string{i18n.T("auth.login_mode.interactive"), i18n.T("auth.login_mode.batch"), i18n.T("auth.login_mode.auto")} + loginModeIndex := 2 + sf.addDropDownWithHelp(form, "LoginMode:", "LoginMode", loginModeOptions, loginModeIndex) + sf.addValidatedInputField(form, "Tags:", "Tags", defaultValues.Tags, 30, GetFieldPlaceholder("Tags")) - // Add save and cancel buttons - form.AddButton("Save", sf.handleSaveButton) - form.AddButton("Cancel", sf.handleCancel) + form.AddButton(i18n.T("form.btn.save"), sf.handleSaveButton) + form.AddButton(i18n.T("form.btn.cancel"), sf.handleCancel) - // Set up form-level input capture for shortcuts sf.setupFormShortcuts(form) sf.forms["Basic"] = form @@ -1773,14 +1697,14 @@ func (sf *ServerForm) createAdvancedForm() { } type ServerFormData struct { - Alias string - Host string - User string - Port string - Key string - Tags string - - // Connection and proxy settings + Alias string + Host string + User string + Port string + Key string + Tags string + Password string + ProxyJump string ProxyCommand string RemoteCommand string @@ -1793,33 +1717,27 @@ type ServerFormData struct { AddressFamily string ExitOnForwardFailure string IPQoS string - // Hostname canonicalization + CanonicalizeHostname string CanonicalDomains string CanonicalizeFallbackLocal string CanonicalizeMaxDots string CanonicalizePermittedCNAMEs string - // Port forwarding LocalForward string RemoteForward string DynamicForward string ClearAllForwardings string GatewayPorts string - // Authentication and key management - // Public key PubkeyAuthentication string IdentitiesOnly string - // SSH Agent - AddKeysToAgent string - IdentityAgent string - // Password & Interactive + AddKeysToAgent string + IdentityAgent string PasswordAuthentication string KbdInteractiveAuthentication string NumberOfPasswordPrompts string - // Advanced - PreferredAuthentications string + PreferredAuthentications string // Agent and X11 forwarding ForwardAgent string