Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
Go + Wails v3 + Vue 3 + SQLite + FreeRDP3 (purego) 183 tests, 76 source files, 9,910 lines of code Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
323 lines
7.5 KiB
Go
323 lines
7.5 KiB
Go
package importer
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/vstockwell/wraith/internal/plugin"
|
|
)
|
|
|
|
// MobaConfImporter parses MobaXTerm .mobaconf configuration files and extracts
|
|
// connections, groups, host keys, and terminal theme settings.
|
|
type MobaConfImporter struct{}
|
|
|
|
func (m *MobaConfImporter) Name() string { return "MobaXTerm" }
|
|
func (m *MobaConfImporter) FileExtensions() []string { return []string{".mobaconf"} }
|
|
|
|
// Parse reads a .mobaconf file and returns the extracted import result.
|
|
func (m *MobaConfImporter) Parse(data []byte) (*plugin.ImportResult, error) {
|
|
result := &plugin.ImportResult{
|
|
Groups: []plugin.ImportGroup{},
|
|
Connections: []plugin.ImportConnection{},
|
|
HostKeys: []plugin.ImportHostKey{},
|
|
}
|
|
|
|
sections := parseSections(string(data))
|
|
|
|
// Parse [Bookmarks_N] sections
|
|
for name, lines := range sections {
|
|
if !strings.HasPrefix(name, "Bookmarks") {
|
|
continue
|
|
}
|
|
|
|
groupName := ""
|
|
for _, line := range lines {
|
|
key, value := splitKV(line)
|
|
if key == "SubRep" && value != "" {
|
|
groupName = value
|
|
result.Groups = append(result.Groups, plugin.ImportGroup{
|
|
Name: groupName,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Parse session lines within the bookmarks section
|
|
for _, line := range lines {
|
|
key, value := splitKV(line)
|
|
if key == "SubRep" || key == "ImgNum" || key == "" {
|
|
continue
|
|
}
|
|
|
|
// Session lines start with a name (possibly prefixed with *)
|
|
// and contain = followed by the session definition
|
|
if value == "" {
|
|
continue
|
|
}
|
|
|
|
sessionName := key
|
|
// Strip leading * from session names (marks favorites)
|
|
sessionName = strings.TrimPrefix(sessionName, "*")
|
|
|
|
conn := parseSessionLine(sessionName, value)
|
|
if conn != nil {
|
|
conn.GroupName = groupName
|
|
result.Connections = append(result.Connections, *conn)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse [SSH_Hostkeys] section
|
|
if hostKeyLines, ok := sections["SSH_Hostkeys"]; ok {
|
|
for _, line := range hostKeyLines {
|
|
key, value := splitKV(line)
|
|
if key == "" || value == "" {
|
|
continue
|
|
}
|
|
hk := parseHostKeyLine(key, value)
|
|
if hk != nil {
|
|
result.HostKeys = append(result.HostKeys, *hk)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse [Colors] section
|
|
if colorLines, ok := sections["Colors"]; ok {
|
|
theme := parseColorSection(colorLines)
|
|
if theme != nil {
|
|
result.Theme = theme
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// parseSections splits .mobaconf INI content into named sections.
|
|
// Returns a map of section name -> lines within that section.
|
|
func parseSections(content string) map[string][]string {
|
|
sections := make(map[string][]string)
|
|
currentSection := ""
|
|
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimRight(line, "\r")
|
|
|
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
currentSection = line[1 : len(line)-1]
|
|
if _, exists := sections[currentSection]; !exists {
|
|
sections[currentSection] = []string{}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if currentSection != "" && line != "" {
|
|
sections[currentSection] = append(sections[currentSection], line)
|
|
}
|
|
}
|
|
|
|
return sections
|
|
}
|
|
|
|
// splitKV splits a line on the first = sign into key and value.
|
|
func splitKV(line string) (string, string) {
|
|
idx := strings.Index(line, "=")
|
|
if idx < 0 {
|
|
return line, ""
|
|
}
|
|
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
|
|
}
|
|
|
|
// parseSessionLine parses a MobaXTerm session definition string.
|
|
// Format: #type#flags%field1%field2%...
|
|
// SSH (#109#): fields[0]=host, fields[1]=port, fields[2]=username
|
|
// RDP (#91#): fields[0]=host, fields[1]=port, fields[2]=username
|
|
func parseSessionLine(name, value string) *plugin.ImportConnection {
|
|
// Find the protocol type marker
|
|
hashIdx := strings.Index(value, "#")
|
|
if hashIdx < 0 {
|
|
return nil
|
|
}
|
|
|
|
// Extract the type number between # markers
|
|
rest := value[hashIdx+1:]
|
|
secondHash := strings.Index(rest, "#")
|
|
if secondHash < 0 {
|
|
return nil
|
|
}
|
|
|
|
typeStr := rest[:secondHash]
|
|
afterType := rest[secondHash+1:]
|
|
|
|
// The remainder after the type and flags section: flags%field1%field2%...
|
|
// Split on the first % to separate flags from fields, then split the rest
|
|
parts := strings.Split(afterType, "%")
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
|
|
// parts[0] = flags (numeric), parts[1:] = fields
|
|
fields := parts[1:]
|
|
|
|
conn := &plugin.ImportConnection{
|
|
Name: name,
|
|
}
|
|
|
|
switch typeStr {
|
|
case "109": // SSH
|
|
conn.Protocol = "ssh"
|
|
if len(fields) >= 1 {
|
|
conn.Hostname = fields[0]
|
|
}
|
|
if len(fields) >= 2 {
|
|
port, err := strconv.Atoi(fields[1])
|
|
if err == nil && port > 0 {
|
|
conn.Port = port
|
|
} else {
|
|
conn.Port = 22
|
|
}
|
|
} else {
|
|
conn.Port = 22
|
|
}
|
|
if len(fields) >= 3 {
|
|
conn.Username = fields[2]
|
|
}
|
|
case "91": // RDP
|
|
conn.Protocol = "rdp"
|
|
if len(fields) >= 1 {
|
|
conn.Hostname = fields[0]
|
|
}
|
|
if len(fields) >= 2 {
|
|
port, err := strconv.Atoi(fields[1])
|
|
if err == nil && port > 0 {
|
|
conn.Port = port
|
|
} else {
|
|
conn.Port = 3389
|
|
}
|
|
} else {
|
|
conn.Port = 3389
|
|
}
|
|
if len(fields) >= 3 {
|
|
conn.Username = fields[2]
|
|
}
|
|
default:
|
|
// Unknown protocol type — skip
|
|
return nil
|
|
}
|
|
|
|
if conn.Hostname == "" {
|
|
return nil
|
|
}
|
|
|
|
return conn
|
|
}
|
|
|
|
// parseHostKeyLine parses a line from the [SSH_Hostkeys] section.
|
|
// Key format: keytype@port:hostname
|
|
// Value: the fingerprint data
|
|
func parseHostKeyLine(key, value string) *plugin.ImportHostKey {
|
|
// Parse key format: keytype@port:hostname
|
|
atIdx := strings.Index(key, "@")
|
|
if atIdx < 0 {
|
|
return nil
|
|
}
|
|
|
|
keyType := key[:atIdx]
|
|
rest := key[atIdx+1:]
|
|
|
|
colonIdx := strings.Index(rest, ":")
|
|
if colonIdx < 0 {
|
|
return nil
|
|
}
|
|
|
|
portStr := rest[:colonIdx]
|
|
hostname := rest[colonIdx+1:]
|
|
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
return &plugin.ImportHostKey{
|
|
Hostname: hostname,
|
|
Port: port,
|
|
KeyType: keyType,
|
|
Fingerprint: value,
|
|
}
|
|
}
|
|
|
|
// parseColorSection extracts a terminal theme from the [Colors] section.
|
|
func parseColorSection(lines []string) *plugin.ImportTheme {
|
|
colorMap := make(map[string]string)
|
|
for _, line := range lines {
|
|
key, value := splitKV(line)
|
|
if key != "" && value != "" {
|
|
colorMap[key] = value
|
|
}
|
|
}
|
|
|
|
// Need at least foreground and background to form a theme
|
|
fg, hasFg := colorMap["ForegroundColour"]
|
|
bg, hasBg := colorMap["BackgroundColour"]
|
|
if !hasFg || !hasBg {
|
|
return nil
|
|
}
|
|
|
|
theme := &plugin.ImportTheme{
|
|
Name: "MobaXTerm Import",
|
|
Foreground: rgbToHex(fg),
|
|
Background: rgbToHex(bg),
|
|
}
|
|
|
|
if cursor, ok := colorMap["CursorColour"]; ok {
|
|
theme.Cursor = rgbToHex(cursor)
|
|
}
|
|
|
|
// Map MobaXTerm color names to the 16-color array positions
|
|
// Standard ANSI order: black, red, green, yellow, blue, magenta, cyan, white,
|
|
// then bright variants in the same order
|
|
colorNames := [16][2]string{
|
|
{"Black", ""},
|
|
{"Red", ""},
|
|
{"Green", ""},
|
|
{"Yellow", ""},
|
|
{"Blue", ""},
|
|
{"Magenta", ""},
|
|
{"Cyan", ""},
|
|
{"White", ""},
|
|
{"BoldBlack", ""},
|
|
{"BoldRed", ""},
|
|
{"BoldGreen", ""},
|
|
{"BoldYellow", ""},
|
|
{"BoldBlue", ""},
|
|
{"BoldMagenta", ""},
|
|
{"BoldCyan", ""},
|
|
{"BoldWhite", ""},
|
|
}
|
|
|
|
for i, cn := range colorNames {
|
|
name := cn[0]
|
|
if val, ok := colorMap[name]; ok {
|
|
theme.Colors[i] = rgbToHex(val)
|
|
}
|
|
}
|
|
|
|
return theme
|
|
}
|
|
|
|
// rgbToHex converts a "R,G,B" string to "#RRGGBB" hex format.
|
|
func rgbToHex(rgb string) string {
|
|
parts := strings.Split(rgb, ",")
|
|
if len(parts) != 3 {
|
|
return rgb // Return as-is if not in expected format
|
|
}
|
|
|
|
r, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
|
g, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
|
b, err3 := strconv.Atoi(strings.TrimSpace(parts[2]))
|
|
|
|
if err1 != nil || err2 != nil || err3 != nil {
|
|
return rgb
|
|
}
|
|
|
|
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
|
}
|