Repository: jon4hz/fztea Branch: main Commit: 5df5fe532c5c Files: 24 Total size: 53.0 KB Directory structure: gitextract_ynrc7a8r/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── lint.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── LICENSE ├── README.md ├── demo.tape ├── flipperui/ │ ├── flipper.go │ └── opts.go ├── go.mod ├── go.sum ├── internal/ │ └── version/ │ └── version.go ├── main.go ├── middleware.go ├── model.go ├── recfz/ │ ├── conn.go │ ├── flipper.go │ ├── reader.go │ └── recfz.go ├── scripts/ │ ├── completions.sh │ └── manpages.sh └── server.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/workflows/lint.yml ================================================ name: lint on: [push, pull_request, workflow_dispatch] jobs: lint: uses: jon4hz/meta/.github/workflows/lint.yml@master ================================================ FILE: .github/workflows/release.yml ================================================ --- name: goreleaser on: push: tags: - "v*.*.*" workflow_dispatch: permissions: contents: write id-token: write packages: write jobs: prepare: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} env: flags: "" steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/setup-go@v6 with: go-version: 1.22 cache: true - shell: bash run: | echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - uses: actions/cache@v5 if: matrix.os == 'ubuntu-latest' with: path: dist/linux key: linux-${{ env.sha_short }} - uses: actions/cache@v5 if: matrix.os == 'macos-latest' with: path: dist/darwin key: darwin-${{ env.sha_short }} - uses: actions/cache@v5 if: matrix.os == 'windows-latest' with: path: dist/windows key: windows-${{ env.sha_short }} enableCrossOsArchive: true - if: ${{ github.event_name == 'workflow_dispatch' }} shell: bash run: echo "flags=--nightly" >> $GITHUB_ENV - if: matrix.os == 'windows-latest' shell: bash run: echo "flags=--skip=before" >> $GITHUB_ENV # skip before hooks on windows (shell scripts for manpages and completions) - uses: goreleaser/goreleaser-action@v6 if: steps.cache.outputs.cache-hit != 'true' # do not run if cache hit with: distribution: goreleaser-pro version: latest args: release --clean --split ${{ env.flags }} env: GITHUB_TOKEN: ${{ secrets.GH_PAT }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} FURY_TOKEN: ${{ secrets.FURY_TOKEN }} AUR_KEY: ${{ secrets.AUR_KEY }} release: runs-on: ubuntu-latest needs: prepare steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/setup-go@v6 with: go-version: 1.22 cache: true # copy the cashes from prepare - shell: bash run: | echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - uses: actions/cache@v5 with: path: dist/linux key: linux-${{ env.sha_short }} - uses: actions/cache@v5 with: path: dist/darwin key: darwin-${{ env.sha_short }} - uses: actions/cache@v5 with: path: dist/windows key: windows-${{ env.sha_short }} enableCrossOsArchive: true # release - uses: goreleaser/goreleaser-action@v6 if: steps.cache.outputs.cache-hit != 'true' # do not run if cache hit with: version: latest distribution: goreleaser-pro args: continue --merge env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} FURY_TOKEN: ${{ secrets.FURY_TOKEN }} AUR_KEY: ${{ secrets.AUR_KEY }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ .vscode .ssh completions/ manpages/ dist/ flipper_*.png ================================================ FILE: .golangci.yml ================================================ run: tests: false issues: max-issues-per-linter: 0 max-same-issues: 0 linters: enable: - bodyclose - exportloopref - goimports - gosec - nilerr - predeclared - revive - rowserrcheck - sqlclosecheck - tparallel - unconvert - unparam - whitespace ================================================ FILE: .goreleaser.yml ================================================ --- version: 2 variables: main: "." binary_name: "fztea" description: "TUI to interact with your flipper zero" github_url: "https://github.com/jon4hz/fztea" maintainer: "jonah " license: "MIT" homepage: "https://jon4hz.io" aur_package: |- # bin install -Dm755 "./fztea" "${pkgdir}/usr/bin/fztea" # license install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/fztea/LICENSE" # completions mkdir -p "${pkgdir}/usr/share/bash-completion/completions/" mkdir -p "${pkgdir}/usr/share/zsh/site-functions/" mkdir -p "${pkgdir}/usr/share/fish/vendor_completions.d/" install -Dm644 "./completions/fztea.bash" "${pkgdir}/usr/share/bash-completion/completions/fztea" install -Dm644 "./completions/fztea.zsh" "${pkgdir}/usr/share/zsh/site-functions/_fztea" install -Dm644 "./completions/fztea.fish" "${pkgdir}/usr/share/fish/vendor_completions.d/fztea.fish" # man pages install -Dm644 "./manpages/fztea.1.gz" "${pkgdir}/usr/share/man/man1/fztea.1.gz" before: hooks: - go mod tidy - ./scripts/completions.sh - ./scripts/manpages.sh builds: - id: default env: - CGO_ENABLED=0 main: "{{ .Var.main }}" binary: "{{ .Var.binary_name }}" ldflags: - -s - -w - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.Version={{ .Version }} - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.Commit={{ .Commit }} - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.Date={{ .Date }} - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.BuiltBy=goreleaser flags: - -trimpath goos: - linux goarch: - amd64 - arm64 - "386" - arm goarm: - "7" - id: windows env: - CGO_ENABLED=0 main: "{{ .Var.main }}" binary: "{{ .Var.binary_name }}" ldflags: - -s - -w - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.Version={{ .Version }} - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.Commit={{ .Commit }} - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.Date={{ .Date }} - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.BuiltBy=goreleaser flags: - -trimpath goos: - windows goarch: - amd64 - arm64 - "386" - arm goarm: - "7" ignore: - goos: windows goarch: arm64 - goos: windows goarm: "7" - id: macOS env: - CGO_ENABLED=1 # required for the serial lib of fztea main: "{{ .Var.main }}" binary: "{{ .Var.binary_name }}" ldflags: - -s - -w - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.Version={{ .Version }} - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.Commit={{ .Commit }} - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.Date={{ .Date }} - -X github.com/jon4hz/{{ .Var.binary_name }}/internal/version.BuiltBy=goreleaser flags: - -trimpath goos: - darwin ignore: - goos: darwin goarch: "386" archives: - id: default name_template: "{{ .Var.binary_name }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}-{{ .Mips }}{{ end }}" builds: - default - macOS files: - LICENSE* - README* - CHANGELOG* - manpages/ - completions - id: windows name_template: "{{ .Var.binary_name }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}-{{ .Mips }}{{ end }}" builds: - windows format_overrides: - goos: windows format: zip files: - LICENSE* - README* - CHANGELOG* checksum: name_template: "checksums.txt" nfpms: - file_name_template: "{{ .Var.binary_name }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}-{{ .Mips }}{{ end }}" vendor: jon4hz homepage: "{{ .Var.homepage }}" maintainer: "{{ .Var.maintainer }}" description: "{{ .Var.description }}" license: "{{ .Var.license }}" formats: - apk - deb - rpm contents: - src: ./completions/fztea.bash dst: /etc/bash_completion.d/fztea - src: ./completions/fztea.fish dst: /usr/share/fish/vendor_completions.d/fztea.fish - src: ./completions/fztea.zsh dst: /usr/share/zsh/site-functions/_fztea - src: ./manpages/fztea.1.gz dst: /usr/share/man/man1/fztea.1.gz aurs: - name: "{{ .Var.binary_name }}-bin" homepage: "{{ .Var.homepage }}" description: "{{ .Var.description }}" maintainers: - "{{ .Var.maintainer }}" license: "{{ .Var.license }}" private_key: "{{ .Env.AUR_KEY }}" git_url: "ssh://aur@aur.archlinux.org/{{ .Var.binary_name }}-bin.git" package: "{{ .Var.aur_package }}" source: enabled: true snapshot: version_template: "{{ incpatch .Version }}-devel" changelog: sort: asc use: github filters: exclude: - "^docs:" - "^test:" groups: - title: "New Features" regexp: "^.*feat[(\\w)]*:+.*$" order: 0 - title: "Bug fixes" regexp: "^.*fix[(\\w)]*:+.*$" order: 10 - title: Others order: 999 furies: - account: jon4hz brews: - name: "{{ .Var.binary_name }}" repository: owner: jon4hz name: homebrew-tap token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" commit_author: name: jon4hz email: me@jon4hz.io homepage: "{{ .Var.homepage }}" description: "{{ .Var.description }}" install: |- bin.install "{{ .Var.binary_name }}" bash_completion.install "completions/{{ .Var.binary_name }}.bash" => "{{ .Var.binary_name }}" zsh_completion.install "completions/{{ .Var.binary_name }}.zsh" => "_{{ .Var.binary_name }}" fish_completion.install "completions/{{ .Var.binary_name }}.fish" man1.install "manpages/{{ .Var.binary_name }}.1.gz" ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 Jonah Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # 🐬🧋 Fztea [![lint](https://github.com/jon4hz/fztea/actions/workflows/lint.yml/badge.svg)](https://github.com/jon4hz/fztea/actions/workflows/lint.yml) [![goreleaser](https://github.com/jon4hz/fztea/actions/workflows/release.yml/badge.svg)](https://github.com/jon4hz/fztea/actions/workflows/release.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/jon4hz/fztea)](https://goreportcard.com/report/github.com/jon4hz/fztea) [![Powered by Dolphines](https://img.shields.io/badge/Powered%20by-Dolphins-blue)](https://img.shields.io/badge/Powered%20by-Dolphins-blue) A [bubbletea](https://github.com/charmbracelet/bubbletea)-bubble and TUI to interact with your [flipper zero](https://flipperzero.one/). The flipper will be automatically detected, if multiple flippers are connected, the first one will be used. ## 🚀 Installation ```bash # using go directly $ go install github.com/jon4hz/fztea@latest # from aur (btw) $ yay -S fztea-bin # local pkg manager ## debian / ubuntu $ dpkg -i fztea-v0.6.2-linux-amd64.deb ## rhel / fedora / suse $ rpm -i fztea-v0.6.2-linux-amd64.rpm ## alpine $ apk add --allow-untrusted fztea-v0.6.2-linux-amd64.apk # homebrew (macOS & linux) $ brew install jon4hz/homebrew-tap/fztea # windows # -> I'm sure you'll figure something out :) ``` ## ✨ Usage ```bash # trying to autodetect that dolphin $ fztea # no flipper found automatically :( $ fztea -p /dev/ttyACM0 ``` ## ⚡️ SSH fztea also allows you to start an ssh server, serving the flipper zero ui over a remote connection. Why? - Why not! ```bash # start the ssh server listening on localhost:2222 (default) $ fztea server -l 127.0.0.1:2222 # connect to the server (from the same machine) $ ssh localhost -p 2222 ``` By default, `fztea` doesn't require any authentication but you can specify an `authorized_keys` file if you want to. ```bash # use authorized_keys for authentication $ fztea server -l 127.0.0.1:2222 -k ~/.ssh/authorized_keys ``` ## 📸 Screenshots You can take a screenshot of the flipper using `ctrl+s` at any time. `Fztea` will store the screenshot in the working directoy, by default in a 1024x512px resolution. The size of the screenshot can be customized using the `--screenshot-resolution` flag. ``` $ fztea --screenshot-resolution=1920x1080 ``` ## ⌨️ Button Mapping | Key | Flipper Event | Keypress Type |-----------------|---------------|--------------| | w, ↑ | up | short | | d, → | right | short | | s, ↓ | down | short | | a, ← | left | short | | o, enter, space | ok | short | | b, back, esc | back | short | | W, shift + ↑ | up | long | | D, shift + → | right | long | | S, shift + ↓ | down | long | | A, shift + ← | left | long | | O | ok | long | | B | back | long | ## 🌈 Custom colors You can set custom fore- and background colors using the `--bg-color` and `--fg-color` flags. ``` $ fztea --bg-color="#8A0000" --fg-color="#000000" ``` Results in: ![ColorScreenshot](/.github/assets/custom_colors.png) ## 🎬 Demo ### Local ![LocalDemo](/.github/assets/demo.gif) ### SSH https://user-images.githubusercontent.com/26183582/181772189-13d7aeaa-ac26-4701-8104-a71ed218539c.mp4 ================================================ FILE: demo.tape ================================================ Output .github/assets/demo.gif Require fztea Set Width 2048 Set Height 1074 Set Margin 20 Set MarginFill "#000000" Set BorderRadius 10 Set WindowBar Colorful Type fztea Sleep 1 Enter Sleep 3 Enter Sleep 1 Down@700ms 2 Enter Sleep 3 Enter Sleep 3 Backspace 1 Sleep 2 ================================================ FILE: flipperui/flipper.go ================================================ package flipperui import ( "fmt" "image" "image/png" "os" "strings" "sync" "time" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/disintegration/imaging" "github.com/flipperdevices/go-flipper" "github.com/jon4hz/fztea/recfz" ) const ( // building blocks to draw the flipper screen in the terminal. fullBlock = '█' upperHalfBlock = '▀' lowerHalfBlock = '▄' // screen size of the flipper flipperScreenHeight = 32 flipperScreenWidth = 128 // fzEventCoolDown is the time that must pass between two events that are sent to the flipper. // That poor serial connection can handle only so much :( fzEventCoolDown = time.Millisecond * 10 ) var ( // colors of the flipper screen colorBg = lipgloss.Color("#FF8C00") colorFg = lipgloss.Color("#000000") ) type ( // ScreenMsg is a message that is sent when the flipper sends a screen update. ScreenMsg struct { screen string image image.Image } ) // ErrStyle is the style of the error message var ErrStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")) // Model represents the flipper model. // It also implements the bubbletea.Model interface. type Model struct { // viewport is used to handle resizing easily viewport viewport.Model // fz is the flipper zero device fz *recfz.FlipperZero // err represents the last error that occurred. It will be displayed for a few seconds. err error // errTime is the time when the last error occurred errTime time.Time // content is the current screen of the flipper as a string content string // lastFZEvent is the time of the last event that was sent to the flipper. lastFZEvent time.Time // screenUpdate is a channel that receives screen updates from the flipper screenUpdate <-chan ScreenMsg // currentScreen is the last screen that was received from the flipper currentScreen image.Image // mutex to ensure that only one goroutine can send events to the flipper at a time mu *sync.Mutex // resolution of the screenshots screenshotResolution struct { width int height int } // Style is the style of the flipper screen Style lipgloss.Style // bgColor is the background color of the flipper screen bgColor string // fgColor is the foreground color of the flipper screen fgColor string } var _ tea.Model = (*Model)(nil) // New constructs a new flipper model. func New(fz *recfz.FlipperZero, screenUpdate <-chan ScreenMsg, opts ...FlipperOpts) tea.Model { m := Model{ fz: fz, viewport: viewport.New(flipperScreenWidth, flipperScreenHeight), lastFZEvent: time.Now().Add(-fzEventCoolDown), screenUpdate: screenUpdate, mu: &sync.Mutex{}, screenshotResolution: struct { width int height int }{ width: 1024, height: 512, }, bgColor: "#FF8C00", fgColor: "#000000", } m.viewport.MouseWheelEnabled = false for _, opt := range opts { opt(&m) } colorBg = lipgloss.Color(m.bgColor) colorFg = lipgloss.Color(m.fgColor) m.Style = lipgloss.NewStyle().Background(colorBg).Foreground(colorFg) return &m } // Init is the bubbletea init function. // the initial listenScreenUpdate command is started here. func (m Model) Init() tea.Cmd { return listenScreenUpdate(m.screenUpdate) } // listenScreenUpdate listens for screen updates from the flipper and returns them as tea.Cmds. func listenScreenUpdate(u <-chan ScreenMsg) tea.Cmd { return func() tea.Msg { return <-u } } // Update is the bubbletea update function and handles all tea.Msgs. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC: return nil, tea.Quit case tea.KeyCtrlS: m.saveImage() return m, nil default: key, getlong := mapKey(msg) if key != -1 { m.sendFlipperEvent(key, getlong) } } case tea.MouseMsg: event := mapMouse(msg) if event != -1 { m.sendFlipperEvent(event, false) } case tea.WindowSizeMsg: m.viewport.Width = min(msg.Width, flipperScreenWidth) m.viewport.Height = min(msg.Height, flipperScreenHeight) m.viewport.SetContent(m.Style.Render(m.content)) case ScreenMsg: m.content = msg.screen m.currentScreen = msg.image m.viewport.SetContent(m.Style.Render(m.content)) cmds = append(cmds, listenScreenUpdate(m.screenUpdate)) } return m, tea.Batch(cmds...) } func min(a, b int) int { if a < b { return a } return b } // mapKey maps a tea.KeyMsg to a flipper.InputKey func mapKey(key tea.KeyMsg) (flipper.InputKey, bool) { switch key.String() { case "w", "up": return flipper.InputKeyUp, false case "a", "left": return flipper.InputKeyLeft, false case "s", "down": return flipper.InputKeyDown, false case "d", "right": return flipper.InputKeyRight, false case "o", "enter", " ": return flipper.InputKeyOk, false case "b", "backspace", "esc": return flipper.InputKeyBack, false case "W", "shift+up": return flipper.InputKeyUp, true case "A", "shift+left": return flipper.InputKeyLeft, true case "S", "shift+down": return flipper.InputKeyDown, true case "D", "shift+right": return flipper.InputKeyRight, true case "O": return flipper.InputKeyOk, true case "B": return flipper.InputKeyBack, true } return -1, false } // mapMouse maps a tea.MouseMsg to a flipper.InputKey func mapMouse(event tea.MouseMsg) flipper.InputKey { switch event.Type { case tea.MouseWheelUp: return flipper.InputKeyUp case tea.MouseWheelDown: return flipper.InputKeyDown } return -1 } // sendFlipperEvent sends an event to the flipper. It ensures that at most one event is sent every fzEventCoolDown. func (m *Model) sendFlipperEvent(event flipper.InputKey, isLong bool) { m.mu.Lock() defer m.mu.Unlock() if time.Since(m.lastFZEvent) < fzEventCoolDown { return } if !isLong { m.fz.SendShortPress(event) } else { m.fz.SendLongPress(event) } m.lastFZEvent = time.Now() } // View renders the flipper screen or an error message if there was an error. func (m Model) View() string { if m.err != nil && time.Since(m.errTime) < time.Second*4 { return ErrStyle.Render(fmt.Sprintf("%d %s", int((time.Second*4 - time.Since(m.errTime)).Seconds()), m.err)) } return m.viewport.View() } // UpdateScreen renders the terminal screen based on the flipper screen. // It also returns the flipper screen as an image. // This function is intended to be used as a callback for the flipper. func UpdateScreen(updates chan<- ScreenMsg) func(frame flipper.ScreenFrame) { return func(frame flipper.ScreenFrame) { var s strings.Builder for y := 0; y < 64; y += 2 { var l strings.Builder for x := 0; x < 128; x++ { r := fullBlock if !frame.IsPixelSet(x, y) && frame.IsPixelSet(x, y+1) { r = lowerHalfBlock } if frame.IsPixelSet(x, y) && !frame.IsPixelSet(x, y+1) { r = upperHalfBlock } if !frame.IsPixelSet(x, y) && !frame.IsPixelSet(x, y+1) { r = ' ' } l.WriteRune(r) } s.WriteString(l.String()) // if not last line if y < 62 { s.WriteRune('\n') } } // make sure we don't block go func() { updates <- ScreenMsg{ screen: s.String(), image: frame.ToImage(colorFg, colorBg), } }() } } // saveImage saves the current screen as a png image. func (m *Model) saveImage() { resImg := imaging.Resize(m.currentScreen, m.screenshotResolution.width, m.screenshotResolution.height, imaging.Box) out, err := os.Create(fmt.Sprintf("flipper_%s.png", time.Now().Format("20060102150405"))) if err != nil { m.setError(err) return } defer out.Close() if err := png.Encode(out, resImg); err != nil { m.setError(err) } } // setError sets the error message and the time when it occurred. func (m *Model) setError(err error) { m.err = err m.errTime = time.Now() } ================================================ FILE: flipperui/opts.go ================================================ package flipperui // FlipperOpts represents an optional configuration for the flipper model. type FlipperOpts func(*Model) // WithScreenshotResolution sets the resolution of the screenshot. func WithScreenshotResolution(width, height int) FlipperOpts { return func(m *Model) { m.screenshotResolution.width = width m.screenshotResolution.height = height } } // WithFgColor sets the foreground color of the flipper screen. func WithFgColor(color string) FlipperOpts { return func(m *Model) { m.fgColor = color } } // WithBgColor sets the background color of the flipper screen. func WithBgColor(color string) FlipperOpts { return func(m *Model) { m.bgColor = color } } ================================================ FILE: go.mod ================================================ module github.com/jon4hz/fztea go 1.24.0 toolchain go1.24.1 require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 github.com/charmbracelet/wish v1.4.7 github.com/disintegration/imaging v1.6.2 github.com/flipperdevices/go-flipper v0.6.0 github.com/muesli/coral v1.0.0 github.com/muesli/mango-coral v1.0.1 github.com/muesli/roff v0.1.0 go.bug.st/serial v1.6.4 ) require ( github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/keygen v0.5.3 // indirect github.com/charmbracelet/log v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/conpty v0.1.0 // indirect github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect github.com/charmbracelet/x/input v0.3.4 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/termios v0.1.0 // indirect github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/creack/goselect v0.1.2 // indirect github.com/creack/pty v1.1.21 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.23.0 // indirect google.golang.org/protobuf v1.27.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I= github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 h1:Ffon9TbltLGBsT6XE//YvNuu4OAaThXioqalhH11xEw= github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894/go.mod h1:hg+I6gvlMl16nS9ZzQNgBIrrCasGwEw0QiLsDcP01Ko= github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/flipperdevices/go-flipper v0.6.0 h1:e9M8anZc7wCi0BJK37MfevYefdfifslHzTV7CEtYrKI= github.com/flipperdevices/go-flipper v0.6.0/go.mod h1:rXHKrpiUSl2H3NM4lDj4V9P8xR/xTjlfmCWS1W9XUaw= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/coral v1.0.0 h1:odyqkoEg4aJAINOzvnjN4tUsdp+Zleccs7tRIAkkYzU= github.com/muesli/coral v1.0.0/go.mod h1:bf91M/dkp7iHQw73HOoR9PekdTJMTD6ihJgWoDitde8= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= github.com/muesli/mango-coral v1.0.1 h1:W3nGbUC/q5vLscQ6GPzteHZrJI1Msjw5Hns82o0xRkI= github.com/muesli/mango-coral v1.0.1/go.mod h1:EPSlYH67AtcxQrxssNw6r/lMFxHTjuDoGfq9Uxxevhg= github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: internal/version/version.go ================================================ package version var ( // Version is the current version of the application. Version = "development" // Commit is the git commit hash of the current version. Commit = "none" // Date is the build date of the current version. Date = "unknown" // BuiltBy is the user who built the current version. BuiltBy = "unknown" ) ================================================ FILE: main.go ================================================ package main import ( "fmt" "io" "log" "os" tea "github.com/charmbracelet/bubbletea" "github.com/jon4hz/fztea/flipperui" "github.com/jon4hz/fztea/internal/version" "github.com/jon4hz/fztea/recfz" "github.com/muesli/coral" mcoral "github.com/muesli/mango-coral" "github.com/muesli/roff" ) var rootFlags struct { port string screenshotResolution string fgColor string bgColor string } var rootCmd = &coral.Command{ Use: "fztea", Short: "TUI to interact with your flipper zero", Version: version.Version, Run: root, } func init() { rootCmd.PersistentFlags().StringVarP(&rootFlags.port, "port", "p", "", "serial port to connect to (default: auto-detected)") rootCmd.PersistentFlags().StringVar(&rootFlags.screenshotResolution, "screenshot-resolution", "1024x512", "screenshot resolution") rootCmd.PersistentFlags().StringVar(&rootFlags.fgColor, "fg-color", "#000000", "foreground color") rootCmd.PersistentFlags().StringVar(&rootFlags.bgColor, "bg-color", "#FF8C00", "background color") rootCmd.AddCommand(serverCmd, versionCmd, manCmd) } func root(cmd *coral.Command, _ []string) { // parse screenshot resolution screenshotResolution, err := parseScreenshotResolution() if err != nil { log.Fatalf("failed to parse screenshot resolution: %s", err) } screenUpdates := make(chan flipperui.ScreenMsg) fz, err := recfz.NewFlipperZero( recfz.WithContext(cmd.Context()), recfz.WithPort(rootFlags.port), recfz.WithStreamScreenCallback(flipperui.UpdateScreen(screenUpdates)), recfz.WithLogger(log.New(io.Discard, "", 0)), ) if err != nil { log.Fatal(err) } defer fz.Close() if err := fz.Connect(); err != nil { log.Fatal(err) } m := model{ flipper: flipperui.New(fz, screenUpdates, flipperui.WithScreenshotResolution(screenshotResolution.width, screenshotResolution.height), flipperui.WithFgColor(rootFlags.fgColor), flipperui.WithBgColor(rootFlags.bgColor), ), } if _, err := tea.NewProgram(m, tea.WithMouseCellMotion()).Run(); err != nil { log.Fatalln(err) } } func main() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } } var manCmd = &coral.Command{ Use: "man", Short: "generates the manpages", SilenceUsage: true, DisableFlagsInUseLine: true, Hidden: true, Args: coral.NoArgs, RunE: func(_ *coral.Command, _ []string) error { manPage, err := mcoral.NewManPage(1, rootCmd) if err != nil { return err } _, err = fmt.Fprint(os.Stdout, manPage.Build(roff.NewDocument())) return err }, } var versionCmd = &coral.Command{ Use: "version", Short: "Print the version info", Run: func(_ *coral.Command, _ []string) { fmt.Printf("Version: %s\n", version.Version) fmt.Printf("Commit: %s\n", version.Commit) fmt.Printf("Date: %s\n", version.Date) fmt.Printf("Build by: %s\n", version.BuiltBy) }, } func parseScreenshotResolution() (struct { width int height int }, error) { var screenshotResolution struct { width int height int } _, err := fmt.Sscanf(rootFlags.screenshotResolution, "%dx%d", &screenshotResolution.width, &screenshotResolution.height) return screenshotResolution, err } ================================================ FILE: middleware.go ================================================ package main import ( "errors" "sync" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" ) // connLimiter limits the number of concurrent connections. type connLimiter struct { sync.Mutex conns int maxConns int } // newConnLimiter returns a new connLimiter. func newConnLimiter(maxConns int) *connLimiter { return &connLimiter{ maxConns: maxConns, } } // Add adds a connection to the limiter. func (u *connLimiter) Add() error { u.Lock() defer u.Unlock() if u.conns >= u.maxConns { return errors.New("max connections reached") } u.conns++ return nil } // Remove removes a connection from the limiter. func (u *connLimiter) Remove() { u.Lock() defer u.Unlock() u.conns-- if u.conns < 0 { u.conns = 0 } } // connLimit is a wish middleware that limits the number of concurrent // connections. func connLimit(limiter *connLimiter) wish.Middleware { return func(sh ssh.Handler) ssh.Handler { return func(s ssh.Session) { if err := limiter.Add(); err != nil { wish.Fatalf(s, "max connections reached\n") return } sh(s) limiter.Remove() } } } ================================================ FILE: model.go ================================================ package main import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type model struct { flipper tea.Model width, height int } // Init is the bubbletea init function. func (m model) Init() tea.Cmd { return m.flipper.Init() } // Update is the bubbletea update function and handles all tea.Msgs. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c": return m, tea.Quit } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height } var cmd tea.Cmd m.flipper, cmd = m.flipper.Update(msg) return m, cmd } // View is the bubbletea view function. func (m model) View() string { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.flipper.View()) } ================================================ FILE: recfz/conn.go ================================================ package recfz import ( "bufio" "errors" "fmt" "io" "strings" "time" "github.com/flipperdevices/go-flipper" "go.bug.st/serial" "go.bug.st/serial/enumerator" ) // Connect connects to the flipper zero device. // It will indefinitely try to reconnect if the connection is lost. func (f *FlipperZero) Connect() error { if err := f.reconnect(); err != nil { return err } go f.reconnLoop() return nil } // reconnect starts a new connection to the flipper zero device. func (f *FlipperZero) reconnect() error { conn, err := f.newConn() if err != nil { return fmt.Errorf("could not open serial conn: %w", err) } fz, err := flipper.ConnectWithTimeout(conn, 10*time.Second) if err != nil { return fmt.Errorf("could not connect to flipper: %w", err) } f.logger.Println("successfully connected to flipper") f.SetFlipper(fz) f.SetConn(conn) return f.startScreenStream() } // newConn opens a new serial connection to the flipper zero device. // If the port is not static, it will try to autodetect the flipper zero device. // If the connection is already open, it will be closed and a new one will be opened. // If the connection is openend successfully, it will start an rpc session over serial. func (f *FlipperZero) newConn() (serial.Port, error) { port := f.port if !f.staticPort { var err error port, err = f.autodetectFlipper() if err != nil { return nil, err } } if conn := f.getConn(); conn != nil { conn.Close() } ser, err := serial.Open(port, &serial.Mode{}) if err != nil { return nil, err } br := bufio.NewReader(ser) _, err = readUntil(br, []byte("\r\n\r\n>: ")) if err != nil { return nil, err } _, err = ser.Write([]byte(startRPCSessionCommand)) if err != nil { return nil, err } token, err := br.ReadString('\r') if err != nil { return nil, err } if token != startRPCSessionCommand { return nil, errors.New(strings.TrimSpace(token)) } go f.checkConnLoop(ser) f.logger.Println("successfully opened serial connection to flipper") return ser, nil } // autodetectFlipper tries to automatically detect the flipper zero device. func (f *FlipperZero) autodetectFlipper() (string, error) { ports, err := enumerator.GetDetailedPortsList() if err != nil { return "", err } for _, p := range ports { if p.PID == flipperPid && p.VID == flipperVid { f.logger.Printf("found flipper on %s", p.Name) return p.Name, nil } } return "", errors.New("no flipper found") } // checkConnLoop checks if the connection is still alive by sending an empty message every 2 seconds. // If the connection is lost, it will trigger a reconnect. func (f *FlipperZero) checkConnLoop(r io.Writer) { ticker := time.NewTicker(time.Second * 2) defer ticker.Stop() for { select { case <-f.ctx.Done(): return case <-ticker.C: if !f.Connected() { continue } _, err := r.Write(nil) if err != nil { if f.getClosing() { return } f.logger.Printf("could not read from flipper: %s", err) f.reconnCh <- struct{}{} return } } } } // reconnLoop tries to reconnect to the flipper zero device if the connection is lost. // If a reconnect fails, it will indefinitely try again after 1 second. func (f *FlipperZero) reconnLoop() { for { select { case _, ok := <-f.reconnCh: if !ok { return } if f.connecting || !f.Connected() { continue } f.connecting = true f.SetFlipper(nil) for { if err := f.reconnect(); err != nil { f.logger.Printf("could not reconnect: %v", err) time.Sleep(time.Second) continue } break } f.connecting = false case <-f.ctx.Done(): return } } } ================================================ FILE: recfz/flipper.go ================================================ package recfz import ( "errors" "github.com/flipperdevices/go-flipper" ) // startScreenStream starts a screen stream from the flipper zero device. // It triggers a callback function for every new screen frame. func (f *FlipperZero) startScreenStream() error { f.mu.Lock() defer f.mu.Unlock() if f.streamScreenCallback == nil { return errors.New("no stream screen callback set") } if err := f.flipper.Gui.StartScreenStream(f.streamScreenCallback); err != nil { return err } f.logger.Println("started screen streaming...") return nil } // SendShortPress sends a short press event to the flipper zero device. // If the flipper zero device is not connected, it will do nothing. func (f *FlipperZero) SendShortPress(event flipper.InputKey) { f.mu.Lock() defer f.mu.Unlock() if f.flipper == nil { return } f.flipper.Gui.SendInputEvent(event, flipper.InputTypePress) //nolint:errcheck f.flipper.Gui.SendInputEvent(event, flipper.InputTypeShort) //nolint:errcheck f.flipper.Gui.SendInputEvent(event, flipper.InputTypeRelease) //nolint:errcheck } // SendLongPress sends a long press event to the flipper zero device. // If the flipper zero device is not connected, it will do nothing. func (f *FlipperZero) SendLongPress(event flipper.InputKey) { f.mu.Lock() defer f.mu.Unlock() if f.flipper == nil { return } f.flipper.Gui.SendInputEvent(event, flipper.InputTypePress) //nolint:errcheck f.flipper.Gui.SendInputEvent(event, flipper.InputTypeLong) //nolint:errcheck f.flipper.Gui.SendInputEvent(event, flipper.InputTypeRelease) //nolint:errcheck } ================================================ FILE: recfz/reader.go ================================================ package recfz import "bytes" type reader interface { ReadString(delim byte) (line string, err error) } func readUntil(r reader, delim []byte) (line []byte, err error) { for { var s string s, err = r.ReadString(delim[len(delim)-1]) if err != nil { return } line = append(line, []byte(s)...) if bytes.HasSuffix([]byte(s), delim) { return line[:len(line)-len(delim)], nil } } } ================================================ FILE: recfz/recfz.go ================================================ package recfz import ( "context" "fmt" "log" "sync" "github.com/flipperdevices/go-flipper" "go.bug.st/serial" ) const ( flipperPid = "5740" flipperVid = "0483" startRPCSessionCommand = "start_rpc_session\r" ) // Opts represents an optional configuration for the flipper zero. type Opts func(f *FlipperZero) // WithPort sets the port of the flipper zero. func WithPort(port string) Opts { return func(f *FlipperZero) { f.port = port } } // WithContext sets the context for the flipper zero. func WithContext(ctx context.Context) Opts { return func(f *FlipperZero) { f.parentCtx = ctx } } // WithStreamScreenCallback sets the callback for the screen stream. func WithStreamScreenCallback(cb func(frame flipper.ScreenFrame)) Opts { return func(f *FlipperZero) { f.streamScreenCallback = cb } } // WithLogger sets the logger for the flipper zero. func WithLogger(l *log.Logger) Opts { return func(f *FlipperZero) { f.logger = l } } // FlipperZero represents the flipper zero device. type FlipperZero struct { parentCtx context.Context ctx context.Context cancel context.CancelFunc port string conn serial.Port flipper *flipper.Flipper reconnCh chan struct{} connecting bool mu sync.Mutex staticPort bool streamScreenCallback func(frame flipper.ScreenFrame) logger *log.Logger isClosing bool } // NewFlipperZero creates a new flipper zero device. // If the port is not static, it will try to autodetect the flipper. func NewFlipperZero(opts ...Opts) (*FlipperZero, error) { f := &FlipperZero{ reconnCh: make(chan struct{}), logger: log.Default(), parentCtx: context.Background(), } for _, opt := range opts { opt(f) } f.ctx, f.cancel = context.WithCancel(f.parentCtx) if f.port == "" { p, err := f.autodetectFlipper() if err != nil { return nil, fmt.Errorf("could not autodetect flipper: %w", err) } f.port = p } else { f.staticPort = true } return f, nil } // Close closes the connection to the flipper zero. func (f *FlipperZero) Close() { f.mu.Lock() defer f.mu.Unlock() if f.isClosing { return } f.isClosing = true f.cancel() close(f.reconnCh) f.conn.Close() } func (f *FlipperZero) getClosing() bool { f.mu.Lock() defer f.mu.Unlock() return f.isClosing } // Connected returns true if the flipper zero is connected. func (f *FlipperZero) Connected() bool { f.mu.Lock() defer f.mu.Unlock() return f.flipper != nil } // SetFlipper can be used to set a flipper instance. func (f *FlipperZero) SetFlipper(fz *flipper.Flipper) { f.mu.Lock() defer f.mu.Unlock() f.flipper = fz } // SetConn sets a serial connection to the flipper zero. func (f *FlipperZero) SetConn(c serial.Port) { f.mu.Lock() defer f.mu.Unlock() f.conn = c } func (f *FlipperZero) getConn() serial.Port { f.mu.Lock() defer f.mu.Unlock() return f.conn } // GetFlipper returns the flipper instance. // If the flipper is not connected, it returns an error. func (f *FlipperZero) GetFlipper() (*flipper.Flipper, error) { f.mu.Lock() defer f.mu.Unlock() if f.flipper == nil { return nil, fmt.Errorf("flipper is not connected") } return f.flipper, nil } ================================================ FILE: scripts/completions.sh ================================================ #!/bin/sh set -e rm -rf completions mkdir completions for sh in bash zsh fish; do go run . completion "$sh" >"completions/fztea.$sh" done ================================================ FILE: scripts/manpages.sh ================================================ #!/bin/sh set -e rm -rf manpages mkdir manpages go run . man | gzip -c >manpages/fztea.1.gz ================================================ FILE: server.go ================================================ package main import ( "context" "log" "os" "os/signal" "syscall" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" bm "github.com/charmbracelet/wish/bubbletea" lm "github.com/charmbracelet/wish/logging" "github.com/jon4hz/fztea/flipperui" "github.com/jon4hz/fztea/recfz" "github.com/muesli/coral" ) var serverFlags struct { listen string authorizedKeys string } var serverCmd = &coral.Command{ Use: "server", Short: "Start an ssh server serving the flipper zero TUI", Run: server, } func init() { serverCmd.Flags().StringVarP(&serverFlags.listen, "listen", "l", "127.0.0.1:2222", "address to listen on") serverCmd.Flags().StringVarP(&serverFlags.authorizedKeys, "authorized-keys", "k", "", "authorized_keys file for public key authentication") } func server(cmd *coral.Command, _ []string) { // parse screenshot resolution screenshotResolution, err := parseScreenshotResolution() if err != nil { log.Fatalf("failed to parse screenshot resolution: %s", err) } screenUpdates := make(chan flipperui.ScreenMsg) fz, err := recfz.NewFlipperZero( recfz.WithPort(rootFlags.port), recfz.WithStreamScreenCallback(flipperui.UpdateScreen(screenUpdates)), recfz.WithContext(cmd.Context()), ) if err != nil { log.Fatal(err) } defer fz.Close() if err := fz.Connect(); err != nil { log.Fatal(err) } cl := newConnLimiter(1) sshOpts := []ssh.Option{ wish.WithAddress(serverFlags.listen), wish.WithHostKeyPath(".ssh/flipperzero_tea_ed25519"), wish.WithMiddleware( bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { _, _, active := s.Pty() if !active { wish.Fatalln(s, "no active terminal, skipping") return nil, nil } m := model{ flipper: flipperui.New(fz, screenUpdates, flipperui.WithScreenshotResolution(screenshotResolution.width, screenshotResolution.height), flipperui.WithFgColor(rootFlags.fgColor), flipperui.WithBgColor(rootFlags.bgColor)), } return m, []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} }), lm.Middleware(), connLimit(cl), ), } if serverFlags.authorizedKeys != "" { sshOpts = append(sshOpts, wish.WithAuthorizedKeys(serverFlags.authorizedKeys)) } s, err := wish.NewServer( sshOpts..., ) if err != nil { log.Fatalln(err) } done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) log.Printf("Starting SSH server on %s", serverFlags.listen) go func() { if err = s.ListenAndServe(); err != nil { log.Fatalln(err) } }() <-done log.Println("Stopping SSH server") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil { log.Fatalln(err) } }