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