package interaction import ( "context" "fmt" "log" "strings" "time" "tunnel_pls/internal/config" "tunnel_pls/internal/random" "tunnel_pls/session/slug" "tunnel_pls/types" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" "golang.org/x/crypto/ssh" ) type Lifecycle interface { Close() error GetUser() string } type SessionRegistry interface { Update(user string, oldKey, newKey types.SessionKey) error } type Controller interface { SetChannel(channel ssh.Channel) SetLifecycle(lifecycle Lifecycle) Start() SetWH(w, h int) Redraw() SetSessionRegistry(registry SessionRegistry) } type Forwarder interface { Close() error GetTunnelType() types.TunnelType GetForwardedPort() uint16 } type Interaction struct { channel ssh.Channel slugManager slug.Manager forwarder Forwarder lifecycle Lifecycle sessionRegistry SessionRegistry program *tea.Program ctx context.Context cancel context.CancelFunc } func (i *Interaction) SetWH(w, h int) { if i.program != nil { i.program.Send(tea.WindowSizeMsg{ Width: w, Height: h, }) } } type commandItem struct { name string desc string } type model struct { domain string protocol string tunnelType types.TunnelType port uint16 keymap keymap help help.Model quitting bool showingCommands bool editingSlug bool showingComingSoon bool commandList list.Model slugInput textinput.Model slugError string interaction *Interaction width int height int } func (m *model) getTunnelURL() string { if m.tunnelType == types.HTTP { return buildURL(m.protocol, m.interaction.slugManager.Get(), m.domain) } return fmt.Sprintf("tcp://%s:%d", m.domain, m.port) } type keymap struct { quit key.Binding command key.Binding random key.Binding } type tickMsg time.Time func NewInteraction(slugManager slug.Manager, forwarder Forwarder) *Interaction { ctx, cancel := context.WithCancel(context.Background()) return &Interaction{ channel: nil, slugManager: slugManager, forwarder: forwarder, lifecycle: nil, sessionRegistry: nil, program: nil, ctx: ctx, cancel: cancel, } } func (i *Interaction) SetSessionRegistry(registry SessionRegistry) { i.sessionRegistry = registry } func (i *Interaction) SetLifecycle(lifecycle Lifecycle) { i.lifecycle = lifecycle } func (i *Interaction) SetChannel(channel ssh.Channel) { i.channel = channel } func (i *Interaction) Stop() { if i.cancel != nil { i.cancel() } if i.program != nil { i.program.Kill() i.program = nil } } func getResponsiveWidth(screenWidth, padding, minWidth, maxWidth int) int { width := screenWidth - padding if width > maxWidth { width = maxWidth } if width < minWidth { width = minWidth } return width } func shouldUseCompactLayout(width int, threshold int) bool { return width < threshold } func truncateString(s string, maxLength int) string { if len(s) <= maxLength { return s } if maxLength < 4 { return s[:maxLength] } return s[:maxLength-3] + "..." } func (i commandItem) FilterValue() string { return i.name } func (i commandItem) Title() string { return i.name } func (i commandItem) Description() string { return i.desc } func tickCmd(d time.Duration) tea.Cmd { return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) }) } func (m *model) Init() tea.Cmd { return tea.Batch(textinput.Blink, tea.WindowSize()) } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tickMsg: m.showingComingSoon = false return m, tea.Batch(tea.ClearScreen, textinput.Blink) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.commandList.SetWidth(msg.Width) m.commandList.SetHeight(msg.Height - 4) if msg.Width < 80 { m.slugInput.Width = msg.Width - 10 } else { m.slugInput.Width = 50 } return m, nil case tea.QuitMsg: m.quitting = true return m, tea.Batch(tea.ClearScreen, textinput.Blink, tea.Quit) case tea.KeyMsg: if m.showingComingSoon { m.showingComingSoon = false return m, tea.Batch(tea.ClearScreen, textinput.Blink) } if m.editingSlug { if m.tunnelType != types.HTTP { m.editingSlug = false m.slugError = "" return m, tea.Batch(tea.ClearScreen, textinput.Blink) } switch msg.String() { case "esc": m.editingSlug = false m.slugError = "" return m, tea.Batch(tea.ClearScreen, textinput.Blink) case "enter": inputValue := m.slugInput.Value() if err := m.interaction.sessionRegistry.Update(m.interaction.lifecycle.GetUser(), types.SessionKey{ Id: m.interaction.slugManager.Get(), Type: types.HTTP, }, types.SessionKey{ Id: inputValue, Type: types.HTTP, }); err != nil { m.slugError = err.Error() return m, nil } m.editingSlug = false m.slugError = "" return m, tea.Batch(tea.ClearScreen, textinput.Blink) case "ctrl+c": m.editingSlug = false m.slugError = "" return m, tea.Batch(tea.ClearScreen, textinput.Blink) default: if key.Matches(msg, m.keymap.random) { newSubdomain := generateRandomSubdomain() m.slugInput.SetValue(newSubdomain) m.slugError = "" m.slugInput, cmd = m.slugInput.Update(msg) return m, cmd } m.slugError = "" m.slugInput, cmd = m.slugInput.Update(msg) return m, cmd } } if m.showingCommands { switch { case key.Matches(msg, m.keymap.quit): m.showingCommands = false return m, tea.Batch(tea.ClearScreen, textinput.Blink) case msg.String() == "enter": selectedItem := m.commandList.SelectedItem() if selectedItem != nil { item := selectedItem.(commandItem) if item.name == "slug" { m.showingCommands = false m.editingSlug = true m.slugInput.SetValue(m.interaction.slugManager.Get()) m.slugInput.Focus() return m, tea.Batch(tea.ClearScreen, textinput.Blink) } else if item.name == "tunnel-type" { m.showingCommands = false m.showingComingSoon = true return m, tea.Batch(tickCmd(5*time.Second), tea.ClearScreen, textinput.Blink) } m.showingCommands = false return m, nil } case msg.String() == "esc": m.showingCommands = false return m, tea.Batch(tea.ClearScreen, textinput.Blink) } m.commandList, cmd = m.commandList.Update(msg) return m, cmd } switch { case key.Matches(msg, m.keymap.quit): m.quitting = true return m, tea.Batch(tea.ClearScreen, textinput.Blink, tea.Quit) case key.Matches(msg, m.keymap.command): m.showingCommands = true return m, tea.Batch(tea.ClearScreen, textinput.Blink) } } return m, nil } func (i *Interaction) Redraw() { if i.program != nil { i.program.Send(tea.ClearScreen()) } } func (m *model) helpView() string { return "\n" + m.help.ShortHelpView([]key.Binding{ m.keymap.command, m.keymap.quit, }) } func (m *model) View() string { if m.quitting { return "" } if m.showingComingSoon { isCompact := shouldUseCompactLayout(m.width, 60) var boxPadding int var boxMargin int if isCompact { boxPadding = 1 boxMargin = 1 } else { boxPadding = 3 boxMargin = 2 } titleStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#7D56F4")). PaddingTop(1). PaddingBottom(1) messageBoxWidth := getResponsiveWidth(m.width, 10, 30, 60) messageBoxStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FAFAFA")). Background(lipgloss.Color("#1A1A2E")). Bold(true). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#7D56F4")). Padding(1, boxPadding). MarginTop(boxMargin). MarginBottom(boxMargin). Width(messageBoxWidth). Align(lipgloss.Center) helpStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#666666")). Italic(true). MarginTop(1) var b strings.Builder b.WriteString("\n\n") var title string if shouldUseCompactLayout(m.width, 40) { title = "Coming Soon" } else { title = "⏳ Coming Soon" } b.WriteString(titleStyle.Render(title)) b.WriteString("\n\n") var message string if shouldUseCompactLayout(m.width, 50) { message = "Coming soon!\nStay tuned." } else { message = "🚀 This feature is coming very soon!\n Stay tuned for updates." } b.WriteString(messageBoxStyle.Render(message)) b.WriteString("\n\n") var helpText string if shouldUseCompactLayout(m.width, 60) { helpText = "Press any key..." } else { helpText = "This message will disappear in 5 seconds or press any key..." } b.WriteString(helpStyle.Render(helpText)) return b.String() } if m.editingSlug { isCompact := shouldUseCompactLayout(m.width, 70) isVeryCompact := shouldUseCompactLayout(m.width, 50) var boxPadding int var boxMargin int if isVeryCompact { boxPadding = 1 boxMargin = 1 } else if isCompact { boxPadding = 1 boxMargin = 1 } else { boxPadding = 2 boxMargin = 2 } titleStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#7D56F4")). PaddingTop(1). PaddingBottom(1) instructionStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FAFAFA")). MarginTop(1) inputBoxStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#7D56F4")). Padding(1, boxPadding). MarginTop(boxMargin). MarginBottom(boxMargin) helpStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#666666")). Italic(true). MarginTop(1) errorBoxStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF0000")). Background(lipgloss.Color("#3D0000")). Bold(true). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#FF0000")). Padding(0, boxPadding). MarginTop(1). MarginBottom(1) rulesBoxWidth := getResponsiveWidth(m.width, 10, 30, 60) rulesBoxStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FAFAFA")). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#7D56F4")). Padding(0, boxPadding). MarginTop(1). MarginBottom(1). Width(rulesBoxWidth) var b strings.Builder var title string if isVeryCompact { title = "Edit Subdomain" } else { title = "🔧 Edit Subdomain" } b.WriteString(titleStyle.Render(title)) b.WriteString("\n\n") if m.tunnelType != types.HTTP { warningBoxWidth := getResponsiveWidth(m.width, 10, 30, 60) warningBoxStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFA500")). Background(lipgloss.Color("#3D2000")). Bold(true). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#FFA500")). Padding(1, boxPadding). MarginTop(boxMargin). MarginBottom(boxMargin). Width(warningBoxWidth) var warningText string if isVeryCompact { warningText = "⚠️ TCP tunnels don't support custom subdomains." } else { warningText = "⚠️ TCP tunnels cannot have custom subdomains. Only HTTP/HTTPS tunnels support subdomain customization." } b.WriteString(warningBoxStyle.Render(warningText)) b.WriteString("\n\n") var helpText string if isVeryCompact { helpText = "Press any key to go back" } else { helpText = "Press Enter or Esc to go back" } b.WriteString(helpStyle.Render(helpText)) return b.String() } var rulesContent string if isVeryCompact { rulesContent = "Rules:\n3-20 chars\na-z, 0-9, -\nNo leading/trailing -" } else if isCompact { rulesContent = "📋 Rules:\n • 3-20 chars\n • a-z, 0-9, -\n • No leading/trailing -" } else { rulesContent = "📋 Rules: \n\t• 3-20 chars \n\t• a-z, 0-9, - \n\t• No leading/trailing -" } b.WriteString(rulesBoxStyle.Render(rulesContent)) b.WriteString("\n") var instruction string if isVeryCompact { instruction = "Custom subdomain:" } else { instruction = "Enter your custom subdomain:" } b.WriteString(instructionStyle.Render(instruction)) b.WriteString("\n") if m.slugError != "" { errorInputBoxStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#FF0000")). Padding(1, boxPadding). MarginTop(boxMargin). MarginBottom(1) b.WriteString(errorInputBoxStyle.Render(m.slugInput.View())) b.WriteString("\n") b.WriteString(errorBoxStyle.Render("❌ " + m.slugError)) b.WriteString("\n") } else { b.WriteString(inputBoxStyle.Render(m.slugInput.View())) b.WriteString("\n") } previewURL := buildURL(m.protocol, m.slugInput.Value(), m.domain) previewWidth := getResponsiveWidth(m.width, 10, 30, 80) if len(previewURL) > previewWidth-10 { previewURL = truncateString(previewURL, previewWidth-10) } previewStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#04B575")). Italic(true). Width(previewWidth) b.WriteString(previewStyle.Render(fmt.Sprintf("Preview: %s", previewURL))) b.WriteString("\n") var helpText string if isVeryCompact { helpText = "Enter: save • CTRL+R: random • Esc: cancel" } else { helpText = "Press Enter to save • CTRL+R for random • Esc to cancel" } b.WriteString(helpStyle.Render(helpText)) return b.String() } if m.showingCommands { isCompact := shouldUseCompactLayout(m.width, 60) titleStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#7D56F4")). PaddingTop(1). PaddingBottom(1) helpStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#666666")). Italic(true). MarginTop(1) var b strings.Builder b.WriteString("\n") var title string if shouldUseCompactLayout(m.width, 40) { title = "Commands" } else { title = "⚡ Commands" } b.WriteString(titleStyle.Render(title)) b.WriteString("\n\n") b.WriteString(m.commandList.View()) b.WriteString("\n") var helpText string if isCompact { helpText = "↑/↓ Nav • Enter Select • Esc Cancel" } else { helpText = "↑/↓ Navigate • Enter Select • Esc Cancel" } b.WriteString(helpStyle.Render(helpText)) return b.String() } titleStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#7D56F4")). PaddingTop(1) subtitleStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#888888")). Italic(true) urlStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#7D56F4")). Underline(true). Italic(true) urlBoxStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#04B575")). Bold(true). Italic(true) keyHintStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#7D56F4")). Bold(true) var b strings.Builder isCompact := shouldUseCompactLayout(m.width, 85) var asciiArtMargin int if isCompact { asciiArtMargin = 0 } else { asciiArtMargin = 1 } asciiArtStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#7D56F4")). MarginBottom(asciiArtMargin) var asciiArt string if shouldUseCompactLayout(m.width, 50) { asciiArt = "TUNNEL PLS" } else if isCompact { asciiArt = ` ▀█▀ █ █ █▄ █ █▄ █ ██▀ █ ▄▀▀ █ ▄▀▀ █ ▀▄█ █ ▀█ █ ▀█ █▄▄ █▄▄ ▄█▀ █▄▄ ▄█▀` } else { asciiArt = ` ████████╗██╗ ██╗███╗ ██╗███╗ ██╗███████╗██╗ ██████╗ ██╗ ███████╗ ╚══██╔══╝██║ ██║████╗ ██║████╗ ██║██╔════╝██║ ██╔══██╗██║ ██╔════╝ ██║ ██║ ██║██╔██╗ ██║██╔██╗ ██║█████╗ ██║ ██████╔╝██║ ███████╗ ██║ ██║ ██║██║╚██╗██║██║╚██╗██║██╔══╝ ██║ ██╔═══╝ ██║ ╚════██║ ██║ ╚██████╔╝██║ ╚████║██║ ╚████║███████╗███████╗ ██║ ███████╗███████║ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝╚══════╝ ╚═╝ ╚══════╝╚══════╝` } b.WriteString(asciiArtStyle.Render(asciiArt)) b.WriteString("\n") if !shouldUseCompactLayout(m.width, 60) { b.WriteString(subtitleStyle.Render("Secure tunnel service by Bagas • ")) b.WriteString(urlStyle.Render("https://fossy.my.id")) b.WriteString("\n\n") } else { b.WriteString("\n") } boxMaxWidth := getResponsiveWidth(m.width, 10, 40, 80) var boxPadding int var boxMargin int if isCompact { boxPadding = 1 boxMargin = 1 } else { boxPadding = 2 boxMargin = 2 } responsiveInfoBox := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#7D56F4")). Padding(1, boxPadding). MarginTop(boxMargin). MarginBottom(boxMargin). Width(boxMaxWidth) authenticatedUser := m.interaction.lifecycle.GetUser() userInfoStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FAFAFA")). Bold(true) sectionHeaderStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#888888")). Bold(true) addressStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FAFAFA")) var infoContent string if shouldUseCompactLayout(m.width, 70) { infoContent = fmt.Sprintf("👤 %s\n\n%s\n%s", userInfoStyle.Render(authenticatedUser), sectionHeaderStyle.Render("🌐 FORWARDING ADDRESS:"), addressStyle.Render(fmt.Sprintf(" %s", urlBoxStyle.Render(m.getTunnelURL())))) } else { infoContent = fmt.Sprintf("👤 Authenticated as: %s\n\n%s\n %s", userInfoStyle.Render(authenticatedUser), sectionHeaderStyle.Render("🌐 FORWARDING ADDRESS:"), addressStyle.Render(urlBoxStyle.Render(m.getTunnelURL()))) } b.WriteString(responsiveInfoBox.Render(infoContent)) b.WriteString("\n") var quickActionsTitle string if shouldUseCompactLayout(m.width, 50) { quickActionsTitle = "Actions" } else if isCompact { quickActionsTitle = "Quick Actions" } else { quickActionsTitle = "✨ Quick Actions" } b.WriteString(titleStyle.Render(quickActionsTitle)) b.WriteString("\n") var featureMargin int if isCompact { featureMargin = 1 } else { featureMargin = 2 } compactFeatureStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FAFAFA")). MarginLeft(featureMargin) var commandsText string var quitText string if shouldUseCompactLayout(m.width, 60) { commandsText = fmt.Sprintf(" %s Commands", keyHintStyle.Render("[C]")) quitText = fmt.Sprintf(" %s Quit", keyHintStyle.Render("[Q]")) } else { commandsText = fmt.Sprintf(" %s Open commands menu", keyHintStyle.Render("[C]")) quitText = fmt.Sprintf(" %s Quit application", keyHintStyle.Render("[Q]")) } b.WriteString(compactFeatureStyle.Render(commandsText)) b.WriteString("\n") b.WriteString(compactFeatureStyle.Render(quitText)) if !shouldUseCompactLayout(m.width, 70) { b.WriteString("\n\n") footerStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#666666")). Italic(true) b.WriteString(footerStyle.Render("Press 'C' to customize your tunnel settings")) } return b.String() } func (i *Interaction) Start() { lipgloss.SetColorProfile(termenv.TrueColor) domain := config.Getenv("DOMAIN", "localhost") protocol := "http" if config.Getenv("TLS_ENABLED", "false") == "true" { protocol = "https" } tunnelType := i.forwarder.GetTunnelType() port := i.forwarder.GetForwardedPort() items := []list.Item{ commandItem{name: "slug", desc: "Set custom subdomain"}, commandItem{name: "tunnel-type", desc: "Change tunnel type (Coming Soon)"}, } delegate := list.NewDefaultDelegate() delegate.ShowDescription = true delegate.SetHeight(2) commandList := list.New(items, delegate, 80, 20) commandList.Title = "Select a command" commandList.SetShowStatusBar(false) commandList.SetFilteringEnabled(false) commandList.SetShowHelp(false) ti := textinput.New() ti.Placeholder = "my-custom-slug" ti.CharLimit = 20 ti.Width = 50 m := &model{ domain: domain, protocol: protocol, tunnelType: tunnelType, port: port, commandList: commandList, slugInput: ti, interaction: i, keymap: keymap{ quit: key.NewBinding( key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit"), ), command: key.NewBinding( key.WithKeys("c"), key.WithHelp("c", "commands"), ), random: key.NewBinding( key.WithKeys("ctrl+r"), key.WithHelp("ctrl+r", "random"), ), }, help: help.New(), } i.program = tea.NewProgram( m, tea.WithInput(i.channel), tea.WithOutput(i.channel), tea.WithAltScreen(), tea.WithMouseCellMotion(), tea.WithoutSignals(), tea.WithoutSignalHandler(), tea.WithFPS(30), ) _, err := i.program.Run() if err != nil { log.Printf("Cannot close tea: %s \n", err) } i.program.Kill() i.program = nil if err := m.interaction.lifecycle.Close(); err != nil { log.Printf("Cannot close session: %s \n", err) } } func buildURL(protocol, subdomain, domain string) string { return fmt.Sprintf("%s://%s.%s", protocol, subdomain, domain) } func generateRandomSubdomain() string { return random.GenerateRandomString(20) }