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) }