Full Code of jon4hz/fztea for AI

main 5df5fe532c5c cached
24 files
53.0 KB
18.9k tokens
67 symbols
1 requests
Download .txt
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 <me@jon4hz.io>"
  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)
	}
}
Download .txt
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
Download .txt
SYMBOL INDEX (67 symbols across 10 files)

FILE: flipperui/flipper.go
  constant fullBlock (line 22) | fullBlock      = '█'
  constant upperHalfBlock (line 23) | upperHalfBlock = '▀'
  constant lowerHalfBlock (line 24) | lowerHalfBlock = '▄'
  constant flipperScreenHeight (line 27) | flipperScreenHeight = 32
  constant flipperScreenWidth (line 28) | flipperScreenWidth  = 128
  constant fzEventCoolDown (line 32) | fzEventCoolDown = time.Millisecond * 10
  type ScreenMsg (line 43) | type ScreenMsg struct
  type Model (line 54) | type Model struct
    method Init (line 122) | func (m Model) Init() tea.Cmd {
    method Update (line 134) | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    method sendFlipperEvent (line 223) | func (m *Model) sendFlipperEvent(event flipper.InputKey, isLong bool) {
    method View (line 238) | func (m Model) View() string {
    method saveImage (line 284) | func (m *Model) saveImage() {
    method setError (line 300) | func (m *Model) setError(err error) {
  function New (line 89) | func New(fz *recfz.FlipperZero, screenUpdate <-chan ScreenMsg, opts ...F...
  function listenScreenUpdate (line 127) | func listenScreenUpdate(u <-chan ScreenMsg) tea.Cmd {
  function min (line 173) | func min(a, b int) int {
  function mapKey (line 181) | func mapKey(key tea.KeyMsg) (flipper.InputKey, bool) {
  function mapMouse (line 212) | func mapMouse(event tea.MouseMsg) flipper.InputKey {
  function UpdateScreen (line 248) | func UpdateScreen(updates chan<- ScreenMsg) func(frame flipper.ScreenFra...

FILE: flipperui/opts.go
  type FlipperOpts (line 4) | type FlipperOpts
  function WithScreenshotResolution (line 7) | func WithScreenshotResolution(width, height int) FlipperOpts {
  function WithFgColor (line 15) | func WithFgColor(color string) FlipperOpts {
  function WithBgColor (line 22) | func WithBgColor(color string) FlipperOpts {

FILE: main.go
  function init (line 32) | func init() {
  function root (line 41) | func root(cmd *coral.Command, _ []string) {
  function main (line 75) | func main() {
  function parseScreenshotResolution (line 110) | func parseScreenshotResolution() (struct {

FILE: middleware.go
  type connLimiter (line 12) | type connLimiter struct
    method Add (line 26) | func (u *connLimiter) Add() error {
    method Remove (line 37) | func (u *connLimiter) Remove() {
  function newConnLimiter (line 19) | func newConnLimiter(maxConns int) *connLimiter {
  function connLimit (line 48) | func connLimit(limiter *connLimiter) wish.Middleware {

FILE: model.go
  type model (line 8) | type model struct
    method Init (line 14) | func (m model) Init() tea.Cmd {
    method Update (line 19) | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    method View (line 38) | func (m model) View() string {

FILE: recfz/conn.go
  method Connect (line 18) | func (f *FlipperZero) Connect() error {
  method reconnect (line 27) | func (f *FlipperZero) reconnect() error {
  method newConn (line 48) | func (f *FlipperZero) newConn() (serial.Port, error) {
  method autodetectFlipper (line 88) | func (f *FlipperZero) autodetectFlipper() (string, error) {
  method checkConnLoop (line 105) | func (f *FlipperZero) checkConnLoop(r io.Writer) {
  method reconnLoop (line 131) | func (f *FlipperZero) reconnLoop() {

FILE: recfz/flipper.go
  method startScreenStream (line 11) | func (f *FlipperZero) startScreenStream() error {
  method SendShortPress (line 26) | func (f *FlipperZero) SendShortPress(event flipper.InputKey) {
  method SendLongPress (line 39) | func (f *FlipperZero) SendLongPress(event flipper.InputKey) {

FILE: recfz/reader.go
  type reader (line 5) | type reader interface
  function readUntil (line 9) | func readUntil(r reader, delim []byte) (line []byte, err error) {

FILE: recfz/recfz.go
  constant flipperPid (line 14) | flipperPid             = "5740"
  constant flipperVid (line 15) | flipperVid             = "0483"
  constant startRPCSessionCommand (line 16) | startRPCSessionCommand = "start_rpc_session\r"
  type Opts (line 20) | type Opts
  function WithPort (line 23) | func WithPort(port string) Opts {
  function WithContext (line 30) | func WithContext(ctx context.Context) Opts {
  function WithStreamScreenCallback (line 37) | func WithStreamScreenCallback(cb func(frame flipper.ScreenFrame)) Opts {
  function WithLogger (line 44) | func WithLogger(l *log.Logger) Opts {
  type FlipperZero (line 51) | type FlipperZero struct
    method Close (line 93) | func (f *FlipperZero) Close() {
    method getClosing (line 105) | func (f *FlipperZero) getClosing() bool {
    method Connected (line 112) | func (f *FlipperZero) Connected() bool {
    method SetFlipper (line 119) | func (f *FlipperZero) SetFlipper(fz *flipper.Flipper) {
    method SetConn (line 126) | func (f *FlipperZero) SetConn(c serial.Port) {
    method getConn (line 132) | func (f *FlipperZero) getConn() serial.Port {
    method GetFlipper (line 140) | func (f *FlipperZero) GetFlipper() (*flipper.Flipper, error) {
  function NewFlipperZero (line 69) | func NewFlipperZero(opts ...Opts) (*FlipperZero, error) {

FILE: server.go
  function init (line 32) | func init() {
  function server (line 37) | func server(cmd *coral.Command, _ []string) {
Condensed preview — 24 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (59K chars).
[
  {
    "path": ".github/dependabot.yml",
    "chars": 207,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n  - package"
  },
  {
    "path": ".github/workflows/lint.yml",
    "chars": 126,
    "preview": "name: lint\non: [push, pull_request, workflow_dispatch]\n\njobs:\n  lint:\n    uses: jon4hz/meta/.github/workflows/lint.yml@m"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 3205,
    "preview": "---\nname: goreleaser\n\non:\n  push:\n    tags:\n      - \"v*.*.*\"\n  workflow_dispatch:\n\npermissions:\n  contents: write\n  id-t"
  },
  {
    "path": ".gitignore",
    "chars": 55,
    "preview": ".vscode\n.ssh\ncompletions/\nmanpages/\ndist/\nflipper_*.png"
  },
  {
    "path": ".golangci.yml",
    "chars": 306,
    "preview": "run:\n  tests: false\n\nissues:\n  max-issues-per-linter: 0\n  max-same-issues: 0\n\nlinters:\n  enable:\n    - bodyclose\n    - e"
  },
  {
    "path": ".goreleaser.yml",
    "chars": 6054,
    "preview": "---\nversion: 2\n\nvariables:\n  main: \".\"\n  binary_name: \"fztea\"\n  description: \"TUI to interact with your flipper zero\"\n  "
  },
  {
    "path": "LICENSE",
    "chars": 1062,
    "preview": "MIT License\n\nCopyright (c) 2022 Jonah\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof t"
  },
  {
    "path": "README.md",
    "chars": 3434,
    "preview": "# 🐬🧋 Fztea\n[![lint](https://github.com/jon4hz/fztea/actions/workflows/lint.yml/badge.svg)](https://github.com/jon4hz/fzt"
  },
  {
    "path": "demo.tape",
    "chars": 275,
    "preview": "Output .github/assets/demo.gif\n\nRequire fztea\n\nSet Width 2048\nSet Height 1074\n\nSet Margin 20\nSet MarginFill \"#000000\"\nSe"
  },
  {
    "path": "flipperui/flipper.go",
    "chars": 7852,
    "preview": "package flipperui\n\nimport (\n\t\"fmt\"\n\t\"image\"\n\t\"image/png\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bu"
  },
  {
    "path": "flipperui/opts.go",
    "chars": 685,
    "preview": "package flipperui\n\n// FlipperOpts represents an optional configuration for the flipper model.\ntype FlipperOpts func(*Mod"
  },
  {
    "path": "go.mod",
    "chars": 2542,
    "preview": "module github.com/jon4hz/fztea\n\ngo 1.24.0\n\ntoolchain go1.24.1\n\nrequire (\n\tgithub.com/charmbracelet/bubbles v0.21.0\n\tgith"
  },
  {
    "path": "go.sum",
    "chars": 10899,
    "preview": "github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=\ngithub.co"
  },
  {
    "path": "internal/version/version.go",
    "chars": 325,
    "preview": "package version\n\nvar (\n\t// Version is the current version of the application.\n\tVersion = \"development\"\n\t// Commit is the"
  },
  {
    "path": "main.go",
    "chars": 3237,
    "preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/jon4hz/fztea/fl"
  },
  {
    "path": "middleware.go",
    "chars": 1110,
    "preview": "package main\n\nimport (\n\t\"errors\"\n\t\"sync\"\n\n\t\"github.com/charmbracelet/ssh\"\n\t\"github.com/charmbracelet/wish\"\n)\n\n// connLim"
  },
  {
    "path": "model.go",
    "chars": 830,
    "preview": "package main\n\nimport (\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\ntype model stru"
  },
  {
    "path": "recfz/conn.go",
    "chars": 3653,
    "preview": "package recfz\n\nimport (\n\t\"bufio\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/flipperdevices/go-flipper\"\n\t\"go"
  },
  {
    "path": "recfz/flipper.go",
    "chars": 1584,
    "preview": "package recfz\n\nimport (\n\t\"errors\"\n\n\t\"github.com/flipperdevices/go-flipper\"\n)\n\n// startScreenStream starts a screen strea"
  },
  {
    "path": "recfz/reader.go",
    "chars": 402,
    "preview": "package recfz\n\nimport \"bytes\"\n\ntype reader interface {\n\tReadString(delim byte) (line string, err error)\n}\n\nfunc readUnti"
  },
  {
    "path": "recfz/recfz.go",
    "chars": 3318,
    "preview": "package recfz\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"sync\"\n\n\t\"github.com/flipperdevices/go-flipper\"\n\t\"go.bug.st/serial\"\n)\n"
  },
  {
    "path": "scripts/completions.sh",
    "chars": 138,
    "preview": "#!/bin/sh\nset -e\nrm -rf completions\nmkdir completions\nfor sh in bash zsh fish; do\n\tgo run . completion \"$sh\" >\"completio"
  },
  {
    "path": "scripts/manpages.sh",
    "chars": 91,
    "preview": "#!/bin/sh\nset -e\nrm -rf manpages\nmkdir manpages\ngo run . man | gzip -c >manpages/fztea.1.gz"
  },
  {
    "path": "server.go",
    "chars": 2847,
    "preview": "package main\n\nimport (\n\t\"context\"\n\t\"log\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea"
  }
]

About this extraction

This page contains the full source code of the jon4hz/fztea GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 24 files (53.0 KB), approximately 18.9k tokens, and a symbol index with 67 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!