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