mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-08 01:38:17 -10:00
Compare commits
25
Commits
polite-zig
...
fix/dnsc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
868b2c294c | ||
|
|
9827b4df4b | ||
|
|
a48309a813 | ||
|
|
5df5dec5d4 | ||
|
|
3ff3ef2ed4 | ||
|
|
73cc8c38ff | ||
|
|
ec7e2facbc | ||
|
|
4969c2dc51 | ||
|
|
94cb60b1f7 | ||
|
|
33fcfd54ef | ||
|
|
10bb248e50 | ||
|
|
e023fd507e | ||
|
|
1a9f0f38dd | ||
|
|
02831a80bf | ||
|
|
9ccff1798e | ||
|
|
cd5ef8b5c6 | ||
|
|
dda1fdbbc2 | ||
|
|
3b49df5662 | ||
|
|
32be71ff8e | ||
|
|
3f77da1524 | ||
|
|
bae3e9e45d | ||
|
|
fae7e211a7 | ||
|
|
346da14431 | ||
|
|
15174f6c31 | ||
|
|
f29df040bb |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Generated
+68
-10
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "./")
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,48 @@
|
||||
[](https://travis-ci.org/atotto/clipboard)
|
||||
|
||||
[](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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,4 @@
|
||||
coverage.txt
|
||||
dist/
|
||||
_examples/catppuccin
|
||||
_examples/frames/
|
||||
+11
@@ -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
@@ -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
@@ -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>
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
|
||||
<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 © 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"local>catppuccin/renovate-config"
|
||||
]
|
||||
}
|
||||
+21
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
+125
@@ -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))))
|
||||
}
|
||||
+1472
File diff suppressed because it is too large.
Load diff
+898
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
<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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user