diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cd6f03c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-and-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.26' + cache: true + + - name: Install Task + uses: go-task/setup-task@v1 + + - name: Run sanity checks and tests + run: task ci + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest + args: --timeout=5m + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bbf12e5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + id-token: write # Required for NPM OIDC + contents: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.26' + cache: true + + - name: Install Task + uses: go-task/setup-task@v1 + + - name: Run sanity checks and tests + run: task ci + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_PAT: ${{ secrets.GH_PAT }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - name: Publish to npm + run: npm publish diff --git a/.gitignore b/.gitignore index b947077..d39318b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ node_modules/ dist/ +.task/ +notes/ +debug/ +completions/ + +# Test coverage +coverage.out +coverage.html diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c9f8df7..8df1668 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,7 +1,20 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# GoReleaser v2 configuration +version: 2 + +project_name: bdcli + +# Before hook - runs before the build +before: + hooks: + - go mod tidy + - go mod download + - sh ./scripts/completions.sh builds: - - binary: bdcli + - id: bdcli + binary: bdcli + main: ./main.go env: - CGO_ENABLED=0 goos: @@ -11,39 +24,164 @@ builds: goarch: - amd64 - arm64 - - arm - - '386' - ignore: - - goos: darwin - goarch: '386' + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.CommitTimestamp}} + mod_timestamp: '{{ .CommitTimestamp }}' + +# Our binaries are small, so we can skip UPX compression +# to save time and avoid potential issues with antivirus software. +# upx: + # - enabled: true + # compress: best + # lzma: true + # brute: true archives: - - format: tar.gz - name_template: "{{.Binary}}_{{.Os}}_{{.Arch}}" + - formats: ["tar.gz"] + # name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' format_overrides: - - goos: windows - format: zip + - goos: windows + formats: ["zip"] + builds_info: + mtime: "{{ .CommitDate }}" + files: + - src: README.md + info: + mtime: "{{ .CommitDate }}" + - src: LICENSE + info: + mtime: "{{ .CommitDate }}" + - src: completions/* + info: + mtime: "{{ .CommitDate }}" + checksum: - name_template: 'bdcli_checksums.txt' + name_template: "bdcli_checksums.txt" + snapshot: - name_template: "{{ incpatch .Version }}-next" + version_template: "{{ incpatch .Version }}-next" + + +changelog: + sort: asc + use: github + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - Merge pull request + - Merge branch + groups: + - title: 'Features' + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: 'Bug Fixes' + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: 'Others' + order: 999 + + release: draft: true -chocolateys: - - name: betterdiscordcli - owners: BetterDiscord - title: BetterDiscord CLI - authors: BetterDiscord - project_url: https://betterdiscord.app/ + replace_existing_draft: true + target_commitish: '{{ .Commit }}' + prerelease: auto + mode: replace + header: | + ## BetterDiscord CLI {{ .Tag }} + + Install on Windows with winget: `winget install betterdiscord.cli` + Install on Mac/Linux with brew: `brew install betterdiscord/tap/bdcli` + footer: | + **Full Changelog**: https://github.com/BetterDiscord/cli/compare/{{ .PreviousTag }}...{{ .Tag }} + + --- + + Released by [GoReleaser](https://github.com/goreleaser/goreleaser). + + +homebrew_casks: + - name: cli + description: "A cross-platform CLI for managing BetterDiscord." + homepage: "https://betterdiscord.app/" + license: "Apache-2.0" + binaries: [bdcli] + completions: + bash: "completions/bdcli.bash" + zsh: "completions/bdcli.zsh" + fish: "completions/bdcli.fish" + commit_author: + name: zerebos + email: 6865942+zerebos@users.noreply.github.com + repository: + owner: BetterDiscord + name: homebrew-tap + branch: bdcli-{{ .Tag }}-{{ .Now.Format "20060102150405"}} + token: "{{ .Env.GH_PAT }}" + + +winget: + - name: BetterDiscord CLI + package_identifier: betterdiscord.cli + author: BetterDiscord + release_notes: "{{ .Changelog }}" + release_notes_url: https://github.com/BetterDiscord/cli/releases/tag/{{ .Tag }} + publisher: "BetterDiscord" + publisher_url: "https://github.com/BetterDiscord" + publisher_support_url: "https://github.com/BetterDiscord/cli/issues" + license: "Apache-2.0" + license_url: "https://github.com/BetterDiscord/cli/blob/main/LICENSE" + + short_description: "A cross-platform CLI for managing BetterDiscord." + homepage: "https://betterdiscord.app/" + tags: [cli, discord, utility, betterdiscord] + url_template: "https://github.com/BetterDiscord/cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" - icon_url: https://betterdiscord.app/resources/branding/logo_solid.png - copyright: 2023 BetterDiscord Limited - license_url: https://github.com/BetterDiscord/cli/blob/main/LICENSE - project_source_url: https://github.com/BetterDiscord/cli - docs_url: https://github.com/BetterDiscord/cli/wiki - bug_tracker_url: https://github.com/BetterDiscord/cli/issues - tags: "betterdiscord cli" - summary: A cross-platform CLI for managing BetterDiscord - description: A cross-platform CLI for managing BetterDiscord - release_notes: "https://github.com/BetterDiscord/cli/releases/tag/v{{ .Version }}" - skip_publish: true + commit_author: + name: zerebos + email: 6865942+zerebos@users.noreply.github.com + repository: + owner: betterdiscord + name: winget-pkgs + branch: bdcli-{{ .Tag }}-{{ .Now.Format "20060102150405"}} + token: "{{ .Env.GH_PAT }}" + pull_request: + enabled: true + base: + owner: microsoft + name: winget-pkgs + branch: master + draft: false + +# Can't do this on linux or macOS, so we'll just skip it for now. +# We can always add it back later if we want to support it. +# chocolateys: +# - name: betterdiscordcli +# owners: BetterDiscord +# title: BetterDiscord CLI +# authors: BetterDiscord +# project_url: https://betterdiscord.app/ +# url_template: "https://github.com/BetterDiscord/cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" +# icon_url: https://betterdiscord.app/resources/branding/logo_solid.png +# copyright: 2025 BetterDiscord Limited +# license_url: https://github.com/BetterDiscord/cli/blob/main/LICENSE +# project_source_url: https://github.com/BetterDiscord/cli +# docs_url: https://docs.betterdiscord.app/ +# bug_tracker_url: https://github.com/BetterDiscord/cli/issues +# tags: "betterdiscord cli discord" +# summary: A cross-platform CLI for managing BetterDiscord +# description: | +# A cross-platform CLI for managing BetterDiscord. +# Provides commands to install, uninstall, and manage BetterDiscord on your system. +# release_notes: "https://github.com/BetterDiscord/cli/releases/tag/v{{ .Version }}" + + + +git: + ignore_tags: + - 'nightly' diff --git a/README.md b/README.md new file mode 100644 index 0000000..1372c34 --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ +# BetterDiscord CLI + +[![Go Version](https://img.shields.io/github/go-mod/go-version/BetterDiscord/cli)](https://go.dev/) +[![Release](https://img.shields.io/github/v/release/BetterDiscord/cli)](https://github.com/BetterDiscord/cli/releases) +[![License](https://img.shields.io/github/license/BetterDiscord/cli)](LICENSE) +[![npm](https://img.shields.io/npm/v/@betterdiscord/cli)](https://www.npmjs.com/package/@betterdiscord/cli) + +A cross-platform command-line interface for installing, updating, and managing [BetterDiscord](https://betterdiscord.app/). + +## Features + +- 🚀 Easy installation and uninstallation of BetterDiscord +- 🔄 Support for multiple Discord channels (Stable, PTB, Canary) +- 🖥️ Cross-platform support (Windows, macOS, Linux) +- 📦 Available via npm for easy distribution +- ⚡ Fast and lightweight Go binary + +## Installation + +### Via npm (Recommended) + +```bash +npm install -g @betterdiscord/cli +``` + +### Via Go + +```bash +go install github.com/betterdiscord/cli@latest +``` + +### Via winget (Windows) + +```bash +winget install betterdiscord.cli +``` + +### Via Homebrew/Linuxbrew + +```bash +brew install betterdiscord/tap/bdcli +``` + +### Download Binary + +Download the latest release for your platform from the [releases page](https://github.com/BetterDiscord/cli/releases). + +## Usage + +### Install BetterDiscord + +Install BetterDiscord to a specific Discord channel: + +```bash +bdcli install --channel stable # Install to Discord Stable +bdcli install --channel ptb # Install to Discord PTB +bdcli install --channel canary # Install to Discord Canary +``` + +Install BetterDiscord by providing a Discord install path: + +```bash +bdcli install --path /path/to/Discord +``` + +### Uninstall BetterDiscord + +Uninstall BetterDiscord from a specific Discord channel: + +```bash +bdcli uninstall --channel stable # Uninstall from Discord Stable +bdcli uninstall --channel ptb # Uninstall from Discord PTB +bdcli uninstall --channel canary # Uninstall from Discord Canary +``` + +Uninstall BetterDiscord by providing a Discord install path: + +```bash +bdcli uninstall --path /path/to/Discord +``` + +### Check Version + +```bash +bdcli version +``` + +### Shell Completions + +```bash +bdcli completion bash +bdcli completion zsh +bdcli completion fish +``` + +### Help + +```bash +bdcli --help +bdcli [command] --help +``` + +### CLI Help Output + +``` +A cross-platform CLI for installing, updating, and managing BetterDiscord. + +Usage: + bdcli [flags] + bdcli [command] + +Available Commands: + completion Generate shell completions + help Help about any command + install Installs BetterDiscord to your Discord + uninstall Uninstalls BetterDiscord from your Discord + version Print the version number + +Flags: + -h, --help help for bdcli + +Use "bdcli [command] --help" for more information about a command. +``` + +## Supported Platforms + +- **Windows** (x64, ARM64, x86) +- **macOS** (x64, ARM64/M1/M2) +- **Linux** (x64, ARM64, ARM) + +## Development + +### Prerequisites + +- [Go](https://go.dev/) 1.26 or higher +- [Task](https://taskfile.dev/) (optional, for task automation) +- [GoReleaser](https://goreleaser.com/) (for releases) + +### Setup + +Clone the repository and install dependencies: + +```bash +git clone https://github.com/BetterDiscord/cli.git +cd cli +task setup # Or: go mod download +``` + +### Available Tasks + +Run `task --list-all` to see all available tasks: + +```bash +# Development +task run # Run the CLI (pass args with: task run -- install stable) + +# Building +task build # Build for current platform +task build:all # Build for all platforms (GoReleaser) + +# Testing +task test # Run tests +task test:verbose # Run tests with verbose output +task coverage # Run tests with coverage summary +task coverage:html # Generate HTML coverage report + +# Code Quality +task fmt # Format Go files +task vet # Run go vet +task lint # Run golangci-lint +task check # Run fix, fmt, vet, lint, test + +# Release +task release:snapshot # Test release build +task release # Create release (requires tag) + +# Cleaning +task clean # Remove build and debug artifacts +``` + +### Running Locally + +```bash +# Run directly +go run main.go install stable + +# Or use Task +task run -- install stable +``` + +### Building + +```bash +# Build for current platform +task build + +# Build for all platforms +task build:all + +# Output will be in ./dist/ +``` + +### Testing + +```bash +# Run all tests +task test + +# Run with coverage +task coverage +``` + +### Releasing + +1. Create and push a new tag: + + ```bash + git tag -a v0.2.0 -m "Release v0.2.0" + git push origin v0.2.0 + ``` + +2. GitHub Actions will automatically build and create a draft release + +3. Edit the release notes and publish + +4. Publish to npm: + + ```bash + npm publish + ``` + +## Project Structure + +```py +. +├── cmd/ # Cobra commands +│ ├── install.go # Install command +│ ├── uninstall.go # Uninstall command +│ ├── version.go # Version command +│ └── root.go # Root command +├── internal/ # Internal packages +│ ├── betterdiscord/ # BetterDiscord installation logic +│ ├── discord/ # Discord path resolution and injection +│ ├── models/ # Data models +│ └── utils/ # Utility functions +├── main.go # Entry point +├── Taskfile.yml # Task automation +└── .goreleaser.yaml # Release configuration +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'feat: add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. + +## Links + +- [BetterDiscord Website](https://betterdiscord.app/) +- [BetterDiscord Documentation](https://docs.betterdiscord.app/) +- [Issue Tracker](https://github.com/BetterDiscord/cli/issues) +- [npm Package](https://www.npmjs.com/package/@betterdiscord/cli) + +## Acknowledgments + +Built with: + +- [Cobra](https://github.com/spf13/cobra) - CLI framework +- [GoReleaser](https://goreleaser.com/) - Release automation +- [Task](https://taskfile.dev/) - Task runner + +--- + +Made with ❤️ by the BetterDiscord Team diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..56a4156 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,245 @@ +# https://taskfile.dev +version: '3' + +vars: + BINARY_NAME: bdcli + MAIN_PACKAGE: ./main.go + BUILD_DIR: ./dist + VERSION: + sh: git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev" + COMMIT: + sh: git rev-parse --short HEAD 2>/dev/null || echo "none" + BUILD_DATE: + sh: date -u +%Y-%m-%dT%H:%M:%SZ + + +tasks: + default: + desc: List all available tasks + cmds: + - task --list-all + silent: true + + # Development tasks + run: + desc: Run the CLI application + vars: + LDFLAGS: '-X main.version={{.VERSION}}-dev -X main.commit={{.COMMIT}} -X main.date={{.BUILD_DATE}}' + cmds: + - go run {{.MAIN_PACKAGE}} {{.CLI_ARGS}} + + # Build tasks + build: + desc: Build the binary for current platform + vars: + LDFLAGS: '-X main.version={{.VERSION}}-dev -X main.commit={{.COMMIT}} -X main.date={{.BUILD_DATE}}' + cmds: + - go build -ldflags "{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}}{{if eq OS "windows"}}.exe{{end}} {{.MAIN_PACKAGE}} + sources: + - '**/*.go' + - go.mod + - go.sum + generates: + - '{{.BUILD_DIR}}/{{.BINARY_NAME}}{{if eq OS "windows"}}.exe{{end}}' + + build:all: + desc: Build binaries for all platforms using GoReleaser + cmds: + - goreleaser build --snapshot --clean --skip=publish + sources: + - '**/*.go' + - go.mod + - go.sum + - .goreleaser.yaml + + # Testing tasks + test: + desc: Run all tests + cmds: + - go test ./... + + test:verbose: + desc: Run all tests with verbose output + cmds: + - go test -v ./... + + # Benchmarking tasks + bench: + desc: Run all benchmarks + cmds: + - go test -bench=. -benchmem ./... + + bench:cpu: + desc: Run benchmarks with CPU profiling + cmds: + - go test -bench=. -benchmem -cpuprofile=debug/cpu.prof + - echo "CPU profile saved to debug/cpu.prof" + - "echo 'View with: go tool pprof debug/cpu.prof'" + + bench:mem: + desc: Run benchmarks with memory profiling + cmds: + - go test -bench=. -benchmem -memprofile=debug/mem.prof + - echo "Memory profile saved to debug/mem.prof" + - "echo 'View with: go tool pprof debug/mem.prof'" + + # Coverage tasks + coverage: + desc: Run tests with coverage report + cmds: + - go test -cover ./... + + coverage:html: + desc: Generate HTML coverage report + cmds: + - go test -coverprofile=debug/coverage.out ./... + - go tool cover -html=debug/coverage.out -o debug/coverage.html + - echo "Coverage report generated at debug/coverage.html" + + coverage:func: + desc: Show coverage by function + cmds: + - go test -coverprofile=debug/coverage.out ./... + - go tool cover -func=debug/coverage.out + + coverage:detailed: + desc: Run coverage with detailed output and open HTML report + cmds: + - go test -coverprofile=debug/coverage.out -covermode=atomic ./... + - go tool cover -func=debug/coverage.out + - go tool cover -html=debug/coverage.out -o debug/coverage.html + - echo "Coverage report generated at debug/coverage.html" + + # Code quality tasks + lint: + desc: Run golangci-lint + cmds: + - golangci-lint run ./... + + lint:fix: + desc: Run golangci-lint with auto-fix + cmds: + - golangci-lint run --fix ./... + + fix: + desc: Fix and modernize all Go files + cmds: + - go fix ./... + + fmt: + desc: Format all Go files + cmds: + - go fmt ./... + + vet: + desc: Run go vet + cmds: + - go vet ./... + + # Release tasks + release: + desc: Create a new release (runs GoReleaser) + cmds: + - goreleaser release --clean + preconditions: + - sh: command -v goreleaser + msg: 'goreleaser not installed. See: https://goreleaser.com/install/' + - sh: git diff-index --quiet HEAD + msg: 'Git working directory is not clean. Commit or stash changes first.' + + release:snapshot: + desc: Create a snapshot release (no publish) + cmds: + - goreleaser release --snapshot --clean --skip=publish + + release:test: + desc: Test the release process without publishing + cmds: + - goreleaser release --skip=publish --clean + + release:npm: + desc: Publish to npm after GitHub release + cmds: + - npm publish + preconditions: + - sh: test -f package.json + msg: 'package.json not found' + + # CI/CD tasks + ci: + desc: Run CI checks (used in CI/CD pipelines) + cmds: + - task: deps + - task: fix + - task: fmt + - task: vet + - task: coverage + - task: build + + # Dependency management + deps: + desc: Tidy, download, and verify Go dependencies + cmds: + - go mod tidy + - go mod download + - go mod verify + + deps:upgrade: + desc: Upgrade all Go dependencies + cmds: + - go get -u ./... + - go mod tidy + - go mod verify + + # Setup and validation tasks + setup: + desc: Setup development environment + cmds: + - task: deps + - task: tools + + tools: + desc: Install development tools + cmds: + - brew install golangci-lint + - brew install goreleaser + + check: + desc: Run all checks (fix, fmt, vet, lint, test) + cmds: + - task: fix + - task: fmt + - task: vet + - task: lint + - task: test + + validate: + desc: Validate goreleaser configuration + cmds: + - goreleaser check + preconditions: + - sh: command -v goreleaser + msg: 'goreleaser not installed. See: https://goreleaser.com/install/' + + # Clean tasks + clean: + desc: Clean all generated files + cmds: + - task: clean:build + - task: clean:coverage + - task: clean:profiles + + clean:build: + desc: Remove build directory + cmds: + - rm -rf {{.BUILD_DIR}} + + clean:coverage: + desc: Remove coverage files + cmds: + - rm -f debug/coverage.out debug/coverage.html + + clean:profiles: + desc: Remove profiling files + cmds: + - rm -f debug/cpu.prof debug/mem.prof diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..9c627b1 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(completionCmd) +} + +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completions", + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return rootCmd.GenBashCompletion(os.Stdout) + case "zsh": + return rootCmd.GenZshCompletion(os.Stdout) + case "fish": + return rootCmd.GenFishCompletion(os.Stdout, true) + case "powershell": + return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout) + } + return nil + }, +} diff --git a/cmd/install.go b/cmd/install.go index acd4399..cb84d8c 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -2,110 +2,56 @@ package cmd import ( "fmt" - "os" - "os/exec" "path" - "strings" "github.com/spf13/cobra" - utils "github.com/betterdiscord/cli/utils" + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" ) func init() { + installCmd.Flags().StringP("path", "p", "", "Path to a Discord installation") + installCmd.Flags().StringP("channel", "c", "stable", "Discord release channel (stable|ptb|canary)") rootCmd.AddCommand(installCmd) } var installCmd = &cobra.Command{ - Use: "install ", - Short: "Installs BetterDiscord to your Discord", - Long: "This can install BetterDiscord to multiple versions and paths of Discord at once. Options for channel are: stable, canary, ptb", - ValidArgs: []string{"canary", "stable", "ptb"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { - var releaseChannel = args[0] - var targetExe = "" - switch releaseChannel { - case "stable": - targetExe = "Discord.exe" - break - case "canary": - targetExe = "DiscordCanary.exe" - break - case "ptb": - targetExe = "DiscordPTB.exe" - break - default: - targetExe = "" - } - - // Kill Discord if it's running - var exe = utils.GetProcessExe(targetExe) - if len(exe) > 0 { - if err := utils.KillProcess(targetExe); err != nil { - fmt.Println("Could not kill Discord") - return - } - } + Use: "install", + Short: "Installs BetterDiscord to your Discord", + Long: "Install BetterDiscord by specifying either --path to a Discord install or --channel to auto-detect (default: stable).", + RunE: func(cmd *cobra.Command, args []string) error { + pathFlag, _ := cmd.Flags().GetString("path") + channelFlag, _ := cmd.Flags().GetString("channel") - // Make BD directories - if err := os.MkdirAll(utils.Data, 0755); err != nil { - fmt.Println("Could not create BetterDiscord folder") - return - } - - if err := os.MkdirAll(utils.Plugins, 0755); err != nil { - fmt.Println("Could not create plugins folder") - return - } + pathProvided := pathFlag != "" + channelProvided := cmd.Flags().Changed("channel") - if err := os.MkdirAll(utils.Themes, 0755); err != nil { - fmt.Println("Could not create theme folder") - return + if pathProvided && channelProvided { + return fmt.Errorf("--path and --channel are mutually exclusive") } - // Get download URL from GitHub API - var apiData, err = utils.DownloadJSON[utils.Release]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") - if err != nil { - fmt.Println("Could not get API response") - fmt.Println(err) - return - } + var install *discord.DiscordInstall - var index = 0 - for i, asset := range apiData.Assets { - if asset.Name == "betterdiscord.asar" { - index = i - break + if pathProvided { + install = discord.ResolvePath(pathFlag) + if install == nil { + return fmt.Errorf("could not find a valid Discord installation at %s", pathFlag) + } + } else { + channel := models.ParseChannel(channelFlag) + corePath := discord.GetSuggestedPath(channel) + install = discord.ResolvePath(corePath) + if install == nil { + return fmt.Errorf("could not find a valid %s installation to install to", channelFlag) } } - var downloadUrl = apiData.Assets[index].URL - - // Download asar into the BD folder - var asarPath = path.Join(utils.Data, "betterdiscord.asar") - err = utils.DownloadFile(downloadUrl, asarPath) - if err != nil { - fmt.Println("Could not download asar") - return + if err := install.InstallBD(); err != nil { + return fmt.Errorf("installation failed: %w", err) } - // Inject shim loader - var corePath = utils.DiscordPath(releaseChannel) - - var indString = `require("` + asarPath + `");` - indString = strings.ReplaceAll(indString, `\`, "/") - indString = indString + "\nmodule.exports = require(\"./core.asar\");" - - if err := os.WriteFile(path.Join(corePath, "index.js"), []byte(indString), 0755); err != nil { - fmt.Println("Could not write index.js in discord_desktop_core!") - return - } - - // Launch Discord if we killed it - if len(exe) > 0 { - var cmd = exec.Command(exe) - cmd.Start() - } + fmt.Printf("✅ BetterDiscord installed to %s\n", path.Dir(install.CorePath)) + return nil }, } diff --git a/cmd/root.go b/cmd/root.go index 57d70c6..1096285 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,9 +14,10 @@ func init() { var rootCmd = &cobra.Command{ Use: "bdcli", Short: "CLI for managing BetterDiscord", - Long: `A cross-platform CLI for installing, updating, and managing BetterDiscord.`, + Long: `A cross-platform CLI for installing, updating, and managing BetterDiscord.`, Run: func(cmd *cobra.Command, args []string) { // Do Stuff Here + fmt.Println("You should probably use a subcommand") }, } diff --git a/cmd/uninstall.go b/cmd/uninstall.go index e3084dc..3456681 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -2,63 +2,55 @@ package cmd import ( "fmt" - "os" - "os/exec" "path" + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" "github.com/spf13/cobra" - - utils "github.com/betterdiscord/cli/utils" ) func init() { - rootCmd.AddCommand(uninstallCmd) + uninstallCmd.Flags().StringP("path", "p", "", "Path to a Discord installation") + uninstallCmd.Flags().StringP("channel", "c", "stable", "Discord release channel (stable|ptb|canary)") + rootCmd.AddCommand(uninstallCmd) } var uninstallCmd = &cobra.Command{ - Use: "uninstall ", - Short: "Uninstalls BetterDiscord from your Discord", - Long: "This can uninstall BetterDiscord to multiple versions and paths of Discord at once. Options for channel are: stable, canary, ptb", - ValidArgs: []string{"canary", "stable", "ptb"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { - var releaseChannel = args[0] - var corePath = utils.DiscordPath(releaseChannel) - var indString = "module.exports = require(\"./core.asar\");" - - if err := os.WriteFile(path.Join(corePath, "index.js"), []byte(indString), 0755); err != nil { - fmt.Println("Could not write index.js in discord_desktop_core!") - return - } - - var targetExe = "" - switch releaseChannel { - case "stable": - targetExe = "Discord.exe" - break - case "canary": - targetExe = "DiscordCanary.exe" - break - case "ptb": - targetExe = "DiscordPTB.exe" - break - default: - targetExe = "" - } - - // Kill Discord if it's running - var exe = utils.GetProcessExe(targetExe) - if len(exe) > 0 { - if err := utils.KillProcess(targetExe); err != nil { - fmt.Println("Could not kill Discord") - return - } - } - - // Launch Discord if we killed it - if len(exe) > 0 { - var cmd = exec.Command(exe) - cmd.Start() - } - }, + Use: "uninstall", + Short: "Uninstalls BetterDiscord from your Discord", + Long: "Uninstall BetterDiscord by specifying either --path to a Discord install or --channel to auto-detect (default: stable).", + RunE: func(cmd *cobra.Command, args []string) error { + pathFlag, _ := cmd.Flags().GetString("path") + channelFlag, _ := cmd.Flags().GetString("channel") + + pathProvided := pathFlag != "" + channelProvided := cmd.Flags().Changed("channel") + + if pathProvided && channelProvided { + return fmt.Errorf("--path and --channel are mutually exclusive") + } + + var install *discord.DiscordInstall + + if pathProvided { + install = discord.ResolvePath(pathFlag) + if install == nil { + return fmt.Errorf("could not find a valid Discord installation at %s", pathFlag) + } + } else { + channel := models.ParseChannel(channelFlag) + resolvedPath := discord.GetSuggestedPath(channel) + install = discord.ResolvePath(resolvedPath) + if install == nil { + return fmt.Errorf("could not find a valid %s installation to uninstall", channelFlag) + } + } + + if err := install.UninstallBD(); err != nil { + return fmt.Errorf("uninstallation failed: %w", err) + } + + fmt.Printf("✅ BetterDiscord uninstalled from %s\n", path.Dir(install.CorePath)) + return nil + }, } diff --git a/go.mod b/go.mod index 35487de..a087e18 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,21 @@ module github.com/betterdiscord/cli -go 1.19 +go 1.24.0 require ( - github.com/shirou/gopsutil/v3 v3.22.10 - github.com/spf13/cobra v1.6.1 + github.com/shirou/gopsutil/v3 v3.24.5 + github.com/spf13/cobra v1.10.2 ) require ( - github.com/go-ole/go-ole v1.2.6 // indirect - github.com/inconshreveable/mousetrap v1.0.1 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/tklauser/go-sysconf v0.3.10 // indirect - github.com/tklauser/numcpus v0.4.0 // indirect - github.com/yusufpapurcu/wmi v1.2.2 // indirect - golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shoenig/go-m1cpu v0.1.7 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 612f5b6..8cc13f0 100644 --- a/go.sum +++ b/go.sum @@ -1,47 +1,45 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= -github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shirou/gopsutil/v3 v3.22.10 h1:4KMHdfBRYXGF9skjDWiL4RA2N+E8dRdodU/bOZpPoVg= -github.com/shirou/gopsutil/v3 v3.22.10/go.mod h1:QNza6r4YQoydyCfo6rH0blGfKahgibh4dQmV5xdFkQk= -github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= -github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/tklauser/go-sysconf v0.3.10 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw= -github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk= -github.com/tklauser/numcpus v0.4.0 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o= -github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ= -github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= -github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0= +github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/betterdiscord/download.go b/internal/betterdiscord/download.go new file mode 100644 index 0000000..9d8e1f2 --- /dev/null +++ b/internal/betterdiscord/download.go @@ -0,0 +1,70 @@ +package betterdiscord + +import ( + "fmt" + "log" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func (i *BDInstall) download() error { + if i.hasDownloaded { + log.Printf("✅ Already downloaded to %s", i.asar) + return nil + } + + resp, err := utils.DownloadFile("https://betterdiscord.app/Download/betterdiscord.asar", i.asar) + if err == nil { + version := resp.Header.Get("x-bd-version") + log.Printf("✅ Downloaded BetterDiscord version %s from the official website", version) + i.hasDownloaded = true + return nil + } else { + log.Printf("❌ Failed to download BetterDiscord from official website") + log.Printf("❌ %s", err.Error()) + log.Printf("") + log.Printf("#### Falling back to GitHub...") + } + + // Get download URL from GitHub API + apiData, err := utils.DownloadJSON[models.GitHubRelease]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") + if err != nil { + log.Printf("❌ Failed to get asset url from GitHub") + log.Printf("❌ %s", err.Error()) + return err + } + + var index = -1 + for idx, asset := range apiData.Assets { + if asset.Name == "betterdiscord.asar" { + index = idx + break + } + } + + if index == -1 { + log.Printf("❌ Failed to find the BetterDiscord asar on GitHub") + return fmt.Errorf("failed to find betterdiscord.asar asset in GitHub release") + } + + var downloadUrl = apiData.Assets[index].URL + var version = apiData.TagName + + if downloadUrl != "" { + log.Printf("✅ Found BetterDiscord: %s", downloadUrl) + } + + // Download asar into the BD folder + _, err = utils.DownloadFile(downloadUrl, i.asar) + if err != nil { + log.Printf("❌ Failed to download BetterDiscord from GitHub") + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Downloaded BetterDiscord version %s from GitHub", version) + i.hasDownloaded = true + + return nil +} diff --git a/internal/betterdiscord/install.go b/internal/betterdiscord/install.go new file mode 100644 index 0000000..9e2fbb9 --- /dev/null +++ b/internal/betterdiscord/install.go @@ -0,0 +1,104 @@ +package betterdiscord + +import ( + "os" + "path/filepath" + "sync" + + "github.com/betterdiscord/cli/internal/models" +) + +type BDInstall struct { + root string + data string + asar string + plugins string + themes string + hasDownloaded bool +} + +// Root returns the root directory path of the BetterDiscord installation +func (i *BDInstall) Root() string { + return i.root +} + +// Data returns the data directory path +func (i *BDInstall) Data() string { + return i.data +} + +// Asar returns the path to the BetterDiscord asar file +func (i *BDInstall) Asar() string { + return i.asar +} + +// Plugins returns the plugins directory path +func (i *BDInstall) Plugins() string { + return i.plugins +} + +// Themes returns the themes directory path +func (i *BDInstall) Themes() string { + return i.themes +} + +// HasDownloaded returns whether BetterDiscord has been downloaded +func (i *BDInstall) HasDownloaded() bool { + return i.hasDownloaded +} + +// Download downloads the BetterDiscord asar file +func (i *BDInstall) Download() error { + return i.download() +} + +// Prepare creates all necessary directories for BetterDiscord +func (i *BDInstall) Prepare() error { + return i.prepare() +} + +// Repair disables plugins for a specific Discord channel +func (i *BDInstall) Repair(channel models.DiscordChannel) error { + return i.repair(channel) +} + +var lock = &sync.Mutex{} +var globalInstance *BDInstall + +func GetInstallation(base ...string) *BDInstall { + if len(base) == 0 { + if globalInstance != nil { + return globalInstance + } + + lock.Lock() + defer lock.Unlock() + if globalInstance != nil { + return globalInstance + } + + // Default to user config directory + configDir, _ := os.UserConfigDir() + + // Handle WSL with Windows home directory + if os.Getenv("WSL_DISTRO_NAME") != "" && os.Getenv("WIN_HOME") != "" { + configDir = filepath.Join(os.Getenv("WIN_HOME"), "AppData", "Roaming") + } + + globalInstance = GetInstallation(configDir) + + return globalInstance + } + + return New(filepath.Join(base[0], "BetterDiscord")) +} + +func New(root string) *BDInstall { + return &BDInstall{ + root: root, + data: filepath.Join(root, "data"), + asar: filepath.Join(root, "data", "betterdiscord.asar"), + plugins: filepath.Join(root, "plugins"), + themes: filepath.Join(root, "themes"), + } +} diff --git a/internal/betterdiscord/install_test.go b/internal/betterdiscord/install_test.go new file mode 100644 index 0000000..9704982 --- /dev/null +++ b/internal/betterdiscord/install_test.go @@ -0,0 +1,322 @@ +package betterdiscord + +import ( + "os" + "path/filepath" + "testing" + + "github.com/betterdiscord/cli/internal/models" +) + +func TestNew(t *testing.T) { + rootPath := "/test/root/BetterDiscord" + install := New(rootPath) + + if install.Root() != rootPath { + t.Errorf("Root() = %s, expected %s", install.Root(), rootPath) + } + + expectedData := filepath.Join(rootPath, "data") + if install.Data() != expectedData { + t.Errorf("Data() = %s, expected %s", install.Data(), expectedData) + } + + expectedAsar := filepath.Join(rootPath, "data", "betterdiscord.asar") + if install.Asar() != expectedAsar { + t.Errorf("Asar() = %s, expected %s", install.Asar(), expectedAsar) + } + + expectedPlugins := filepath.Join(rootPath, "plugins") + if install.Plugins() != expectedPlugins { + t.Errorf("Plugins() = %s, expected %s", install.Plugins(), expectedPlugins) + } + + expectedThemes := filepath.Join(rootPath, "themes") + if install.Themes() != expectedThemes { + t.Errorf("Themes() = %s, expected %s", install.Themes(), expectedThemes) + } + + if install.HasDownloaded() { + t.Error("HasDownloaded() should be false for new install") + } +} + +func TestBDInstall_GettersSetters(t *testing.T) { + rootPath := "/test/path" + install := New(rootPath) + + // Test all getters return expected paths + tests := []struct { + name string + getter func() string + expected string + }{ + {"Root", install.Root, rootPath}, + {"Data", install.Data, filepath.Join(rootPath, "data")}, + {"Asar", install.Asar, filepath.Join(rootPath, "data", "betterdiscord.asar")}, + {"Plugins", install.Plugins, filepath.Join(rootPath, "plugins")}, + {"Themes", install.Themes, filepath.Join(rootPath, "themes")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.getter() + if result != tt.expected { + t.Errorf("%s() = %s, expected %s", tt.name, result, tt.expected) + } + }) + } +} + +func TestBDInstall_Prepare(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + bdRoot := filepath.Join(tmpDir, "BetterDiscord") + + install := New(bdRoot) + + // Prepare should create all necessary directories + err := install.Prepare() + if err != nil { + t.Fatalf("Prepare() failed: %v", err) + } + + // Verify directories were created + dirs := []string{ + install.Data(), + install.Plugins(), + install.Themes(), + } + + for _, dir := range dirs { + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Errorf("Directory not created: %s", dir) + } + } +} + +func TestBDInstall_Prepare_AlreadyExists(t *testing.T) { + // Create temporary directory with existing structure + tmpDir := t.TempDir() + bdRoot := filepath.Join(tmpDir, "BetterDiscord") + + install := New(bdRoot) + + // Create directories manually first + os.MkdirAll(install.Data(), 0755) //nolint:errcheck + os.MkdirAll(install.Plugins(), 0755) //nolint:errcheck + os.MkdirAll(install.Themes(), 0755) //nolint:errcheck + + // Prepare should succeed even if directories already exist + err := install.Prepare() + if err != nil { + t.Fatalf("Prepare() failed when directories already exist: %v", err) + } + + // Verify directories still exist + dirs := []string{ + install.Data(), + install.Plugins(), + install.Themes(), + } + + for _, dir := range dirs { + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Errorf("Directory should exist: %s", dir) + } + } +} + +func TestBDInstall_Repair(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + bdRoot := filepath.Join(tmpDir, "BetterDiscord") + + install := New(bdRoot) + + // Create the data directory and a test plugins.json file + channelFolder := filepath.Join(install.Data(), models.Stable.String()) + os.MkdirAll(channelFolder, 0755) //nolint:errcheck + + pluginsJson := filepath.Join(channelFolder, "plugins.json") + err := os.WriteFile(pluginsJson, []byte(`{"test": "data"}`), 0644) + if err != nil { + t.Fatalf("Failed to create test plugins.json: %v", err) + } + + // Verify file exists before repair + if _, err := os.Stat(pluginsJson); os.IsNotExist(err) { + t.Fatal("plugins.json should exist before repair") + } + + // Run repair + err = install.Repair(models.Stable) + if err != nil { + t.Fatalf("Repair() failed: %v", err) + } + + // Verify file was removed + if _, err := os.Stat(pluginsJson); !os.IsNotExist(err) { + t.Error("plugins.json should be removed after repair") + } +} + +func TestBDInstall_Repair_NoPluginsFile(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + bdRoot := filepath.Join(tmpDir, "BetterDiscord") + + install := New(bdRoot) + + // Don't create any files - repair should succeed without error + err := install.Repair(models.Stable) + if err != nil { + t.Fatalf("Repair() should succeed when plugins.json doesn't exist: %v", err) + } +} + +func TestBDInstall_Repair_MultipleChannels(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + bdRoot := filepath.Join(tmpDir, "BetterDiscord") + + install := New(bdRoot) + + // Create plugins.json for multiple channels + channels := []models.DiscordChannel{models.Stable, models.Canary, models.PTB} + pluginsFiles := make(map[models.DiscordChannel]string) + + for _, channel := range channels { + channelFolder := filepath.Join(install.Data(), channel.String()) + os.MkdirAll(channelFolder, 0755) //nolint:errcheck + + pluginsJson := filepath.Join(channelFolder, "plugins.json") + os.WriteFile(pluginsJson, []byte(`{}`), 0644) //nolint:errcheck + pluginsFiles[channel] = pluginsJson + } + + // Repair only Stable channel + err := install.Repair(models.Stable) + if err != nil { + t.Fatalf("Repair(Stable) failed: %v", err) + } + + // Verify only Stable's plugins.json was removed + if _, err := os.Stat(pluginsFiles[models.Stable]); !os.IsNotExist(err) { + t.Error("Stable plugins.json should be removed") + } + + // Verify other channels' files still exist + if _, err := os.Stat(pluginsFiles[models.Canary]); os.IsNotExist(err) { + t.Error("Canary plugins.json should still exist") + } + if _, err := os.Stat(pluginsFiles[models.PTB]); os.IsNotExist(err) { + t.Error("PTB plugins.json should still exist") + } +} + +func TestGetInstallation_WithBase(t *testing.T) { + basePath := "/test/config" + install := GetInstallation(basePath) + + expectedRoot := filepath.Join(basePath, "BetterDiscord") + if install.Root() != expectedRoot { + t.Errorf("GetInstallation(%s).Root() = %s, expected %s", basePath, install.Root(), expectedRoot) + } +} + +func TestGetInstallation_Singleton(t *testing.T) { + // Reset the global instance to ensure clean test + // Note: In a real test, you might want to add a reset function + // For now, we'll just test that multiple calls work + + install1 := GetInstallation() + install2 := GetInstallation() + + // Both should return the same instance (singleton pattern) + if install1 != install2 { + t.Error("GetInstallation() should return the same instance (singleton)") + } + + // Both should have the same root path + if install1.Root() != install2.Root() { + t.Errorf("Singleton instances have different roots: %s vs %s", install1.Root(), install2.Root()) + } +} + +func TestMakeDirectory(t *testing.T) { + tmpDir := t.TempDir() + testDir := filepath.Join(tmpDir, "test", "nested", "directory") + + // Make the directory + err := makeDirectory(testDir) + if err != nil { + t.Fatalf("makeDirectory() failed: %v", err) + } + + // Verify it exists + if _, err := os.Stat(testDir); os.IsNotExist(err) { + t.Error("Directory was not created") + } + + // Test making the same directory again (should not error) + err = makeDirectory(testDir) + if err != nil { + t.Errorf("makeDirectory() should succeed when directory already exists: %v", err) + } +} + +func TestBDInstall_HasDownloaded(t *testing.T) { + install := New("/test/path") + + // Initially should be false + if install.HasDownloaded() { + t.Error("HasDownloaded() should initially be false") + } + + // After setting hasDownloaded (internal state) + install.hasDownloaded = true + if !install.HasDownloaded() { + t.Error("HasDownloaded() should be true after download") + } +} + +func TestBDInstall_PathStructure(t *testing.T) { + // Test with different root paths + tests := []struct { + name string + rootPath string + }{ + {"Unix absolute path", "/home/user/.config/BetterDiscord"}, + {"Windows absolute path", "C:\\Users\\User\\AppData\\Roaming\\BetterDiscord"}, + {"Relative path", "BetterDiscord"}, + {"Path with spaces", "/path with spaces/BetterDiscord"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + install := New(tt.rootPath) + + // Verify all paths are constructed correctly relative to root + if install.Root() != tt.rootPath { + t.Errorf("Root() incorrect") + } + + if install.Data() != filepath.Join(tt.rootPath, "data") { + t.Errorf("Data() path incorrect: %s", install.Data()) + } + + if install.Asar() != filepath.Join(tt.rootPath, "data", "betterdiscord.asar") { + t.Errorf("Asar() path incorrect: %s", install.Asar()) + } + + if install.Plugins() != filepath.Join(tt.rootPath, "plugins") { + t.Errorf("Plugins() path incorrect: %s", install.Plugins()) + } + + if install.Themes() != filepath.Join(tt.rootPath, "themes") { + t.Errorf("Themes() path incorrect: %s", install.Themes()) + } + }) + } +} diff --git a/internal/betterdiscord/setup.go b/internal/betterdiscord/setup.go new file mode 100644 index 0000000..774843a --- /dev/null +++ b/internal/betterdiscord/setup.go @@ -0,0 +1,60 @@ +package betterdiscord + +import ( + "log" + "os" + "path/filepath" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func makeDirectory(folder string) error { + exists := utils.Exists(folder) + + if exists { + log.Printf("✅ Directory exists: %s", folder) + return nil + } + + if err := os.MkdirAll(folder, 0755); err != nil { + log.Printf("❌ Failed to create directory: %s", folder) + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Directory created: %s", folder) + return nil +} + +func (i *BDInstall) prepare() error { + if err := makeDirectory(i.data); err != nil { + return err + } + if err := makeDirectory(i.plugins); err != nil { + return err + } + if err := makeDirectory(i.themes); err != nil { + return err + } + return nil +} + +func (i *BDInstall) repair(channel models.DiscordChannel) error { + channelFolder := filepath.Join(i.data, channel.String()) + pluginsJson := filepath.Join(channelFolder, "plugins.json") + + if !utils.Exists(pluginsJson) { + log.Printf("✅ No plugins enabled for %s", channel.Name()) + return nil + } + + if err := os.Remove(pluginsJson); err != nil { + log.Printf("❌ Unable to remove file %s", pluginsJson) + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Plugins disabled for %s", channel.Name()) + return nil +} diff --git a/internal/discord/assets/injection.js b/internal/discord/assets/injection.js new file mode 100644 index 0000000..3d176be --- /dev/null +++ b/internal/discord/assets/injection.js @@ -0,0 +1,21 @@ +// BetterDiscord's Injection Script +const path = require("path"); +const electron = require("electron"); + +// Windows and macOS both use the fixed global BetterDiscord folder but +// Electron gives the postfixed version of userData, so go up a directory +let userConfig = path.join(electron.app.getPath("userData"), ".."); + +// If we're on Linux there are a couple cases to deal with +if (process.platform !== "win32" && process.platform !== "darwin") { + // Use || instead of ?? because a falsey value of "" is invalid per XDG spec + userConfig = process.env.XDG_CONFIG_HOME || path.join(process.env.HOME, ".config"); + + // HOST_XDG_CONFIG_HOME is set by flatpak, so use without validation if set + if (process.env.HOST_XDG_CONFIG_HOME) userConfig = process.env.HOST_XDG_CONFIG_HOME; +} + +require(path.join(userConfig, "BetterDiscord", "data", "betterdiscord.asar")); + +// Discord's Default Export +module.exports = require("./core.asar"); \ No newline at end of file diff --git a/internal/discord/injection.go b/internal/discord/injection.go new file mode 100644 index 0000000..bea517b --- /dev/null +++ b/internal/discord/injection.go @@ -0,0 +1,69 @@ +package discord + +import ( + _ "embed" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/betterdiscord/cli/internal/betterdiscord" +) + +//go:embed assets/injection.js +var injectionScript string + +func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { + if discord.IsFlatpak { + cmd := exec.Command("flatpak", "--user", "override", "com.discordapp."+discord.Channel.Exe(), "--filesystem="+bd.Root()) + if err := cmd.Run(); err != nil { + log.Printf("❌ Could not give flatpak access to %s", bd.Root()) + log.Printf("❌ %s", err.Error()) + return err + } + } + + if err := os.WriteFile(filepath.Join(discord.CorePath, "index.js"), []byte(injectionScript), 0755); err != nil { + log.Printf("❌ Unable to write index.js in %s", discord.CorePath) + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Injected into %s", discord.CorePath) + return nil +} + +func (discord *DiscordInstall) uninject() error { + indexFile := filepath.Join(discord.CorePath, "index.js") + + contents, err := os.ReadFile(indexFile) + + // First try to check the file, but if there's an issue we try to blindly overwrite below + if err == nil { + if !strings.Contains(strings.ToLower(string(contents)), "betterdiscord") { + log.Printf("✅ No injection found for %s", discord.Channel.Name()) + return nil + } + } + + if err := os.WriteFile(indexFile, []byte(`module.exports = require("./core.asar");`), 0o644); err != nil { + log.Printf("❌ Unable to write file %s", indexFile) + log.Printf("❌ %s", err.Error()) + return err + } + log.Printf("✅ Removed from %s", discord.Channel.Name()) + + return nil +} + +// TODO: consider putting this in the betterdiscord package +func (discord *DiscordInstall) IsInjected() bool { + indexFile := filepath.Join(discord.CorePath, "index.js") + contents, err := os.ReadFile(indexFile) + if err != nil { + return false + } + lower := strings.ToLower(string(contents)) + return strings.Contains(lower, "betterdiscord") +} diff --git a/internal/discord/install.go b/internal/discord/install.go new file mode 100644 index 0000000..4126862 --- /dev/null +++ b/internal/discord/install.go @@ -0,0 +1,99 @@ +package discord + +import ( + "log" + "path/filepath" + + "github.com/betterdiscord/cli/internal/betterdiscord" + "github.com/betterdiscord/cli/internal/models" +) + +type DiscordInstall struct { + CorePath string `json:"corePath"` + Channel models.DiscordChannel `json:"channel"` + Version string `json:"version"` + IsFlatpak bool `json:"isFlatpak"` + IsSnap bool `json:"isSnap"` +} + +// InstallBD installs BetterDiscord into this Discord installation +func (discord *DiscordInstall) InstallBD() error { + // Gets the global BetterDiscord install + bd := betterdiscord.GetInstallation() + + // Snaps get their own local BD install + if discord.IsSnap { + bd = betterdiscord.GetInstallation(filepath.Clean(filepath.Join(discord.CorePath, "..", "..", "..", ".."))) + } + + // Make BetterDiscord folders + log.Printf("## Preparing BetterDiscord...") + if err := bd.Prepare(); err != nil { + return err + } + log.Printf("✅ BetterDiscord prepared for install") + log.Printf("") + + // Download and write betterdiscord.asar + log.Printf("## Downloading BetterDiscord...") + if err := bd.Download(); err != nil { + return err + } + log.Printf("✅ BetterDiscord downloaded") + log.Printf("") + + // Write injection script to discord_desktop_core/index.js + log.Printf("## Injecting into Discord...") + if err := discord.inject(bd); err != nil { + return err + } + log.Printf("✅ Injection successful") + log.Printf("") + + // Terminate and restart Discord if possible + log.Printf("## Restarting %s...", discord.Channel.Name()) + if err := discord.restart(); err != nil { + return err + } + log.Printf("") + + return nil +} + +// UninstallBD removes BetterDiscord from this Discord installation +func (discord *DiscordInstall) UninstallBD() error { + log.Printf("## Removing injection...") + if err := discord.uninject(); err != nil { + return err + } + log.Printf("") + + log.Printf("## Restarting %s...", discord.Channel.Name()) + if err := discord.restart(); err != nil { + return err + } + log.Printf("") + + return nil +} + +// RepairBD repairs BetterDiscord for this Discord installation +func (discord *DiscordInstall) RepairBD() error { + if err := discord.UninstallBD(); err != nil { + return err + } + + // Gets the global BetterDiscord install + bd := betterdiscord.GetInstallation() + + // Snaps get their own local BD install + if discord.IsSnap { + bd = betterdiscord.GetInstallation(filepath.Clean(filepath.Join(discord.CorePath, "..", "..", "..", ".."))) + } + + if err := bd.Repair(discord.Channel); err != nil { + return err + } + + return nil +} diff --git a/internal/discord/paths.go b/internal/discord/paths.go new file mode 100644 index 0000000..870af2b --- /dev/null +++ b/internal/discord/paths.go @@ -0,0 +1,100 @@ +package discord + +import ( + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/betterdiscord/cli/internal/models" +) + +var searchPaths []string +var versionRegex = regexp.MustCompile(`[0-9]+\.[0-9]+\.[0-9]+`) +var allDiscordInstalls map[models.DiscordChannel][]*DiscordInstall + +func GetAllInstalls() map[models.DiscordChannel][]*DiscordInstall { + allDiscordInstalls = map[models.DiscordChannel][]*DiscordInstall{} + + for _, path := range searchPaths { + if result := Validate(path); result != nil { + allDiscordInstalls[result.Channel] = append(allDiscordInstalls[result.Channel], result) + } + } + + sortInstalls() + + return allDiscordInstalls +} + +func GetVersion(proposed string) string { + for folder := range strings.SplitSeq(proposed, string(filepath.Separator)) { + if version := versionRegex.FindString(folder); version != "" { + return version + } + } + return "" +} + +func GetChannel(proposed string) models.DiscordChannel { + for folder := range strings.SplitSeq(proposed, string(filepath.Separator)) { + for _, channel := range models.Channels { + if strings.ToLower(folder) == strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") { + return channel + } + } + } + return models.Stable +} + +func GetSuggestedPath(channel models.DiscordChannel) string { + if len(allDiscordInstalls[channel]) > 0 { + return allDiscordInstalls[channel][0].CorePath + } + return "" +} + +func AddCustomPath(proposed string) *DiscordInstall { + result := Validate(proposed) + if result == nil { + return nil + } + + // Check if this already exists in our list and return reference + index := slices.IndexFunc(allDiscordInstalls[result.Channel], func(d *DiscordInstall) bool { return d.CorePath == result.CorePath }) + if index >= 0 { + return allDiscordInstalls[result.Channel][index] + } + + allDiscordInstalls[result.Channel] = append(allDiscordInstalls[result.Channel], result) + + sortInstalls() + + return result +} + +func ResolvePath(proposed string) *DiscordInstall { + for channel := range allDiscordInstalls { + index := slices.IndexFunc(allDiscordInstalls[channel], func(d *DiscordInstall) bool { return d.CorePath == proposed }) + if index >= 0 { + return allDiscordInstalls[channel][index] + } + } + + // If it wasn't found as an existing install, try to add it + return AddCustomPath(proposed) +} + +func sortInstalls() { + for channel := range allDiscordInstalls { + slices.SortFunc(allDiscordInstalls[channel], func(a, b *DiscordInstall) int { + switch { + case a.Version > b.Version: + return -1 + case b.Version > a.Version: + return 1 + } + return 0 + }) + } +} diff --git a/internal/discord/paths_darwin.go b/internal/discord/paths_darwin.go new file mode 100644 index 0000000..217ad3a --- /dev/null +++ b/internal/discord/paths_darwin.go @@ -0,0 +1,82 @@ +package discord + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func init() { + config, _ := os.UserConfigDir() + paths := []string{ + filepath.Join(config, "{channel}"), + } + + for _, channel := range models.Channels { + for _, path := range paths { + folder := strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") + searchPaths = append( + searchPaths, + strings.ReplaceAll(path, "{channel}", folder), + ) + } + } + + allDiscordInstalls = GetAllInstalls() +} + +/** + * Currently nearly the same as linux validation however + * it is kept separate in case of future changes to + * either system, it is likely that linux will require + * more advanced validation for snap and flatpak. + */ +func Validate(proposed string) *DiscordInstall { + var finalPath = "" + var selected = filepath.Base(proposed) + if strings.HasPrefix(selected, "discord") { + // Get version dir like 1.0.9002 + var dFiles, err = os.ReadDir(proposed) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) + if len(candidates) == 0 { + return nil + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) + var versionDir = candidates[len(candidates)-1].Name() + finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") + } + + if len(strings.Split(selected, ".")) == 3 { + finalPath = filepath.Join(proposed, "modules", "discord_desktop_core") + } + + if selected == "modules" { + finalPath = filepath.Join(proposed, "discord_desktop_core") + } + + if selected == "discord_desktop_core" { + finalPath = proposed + } + + // If the path and the asar exist, all good + if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { + return &DiscordInstall{ + CorePath: finalPath, + Channel: GetChannel(finalPath), + Version: GetVersion(finalPath), + IsFlatpak: false, + IsSnap: false, + } + } + + return nil +} diff --git a/internal/discord/paths_linux.go b/internal/discord/paths_linux.go new file mode 100644 index 0000000..dc31b27 --- /dev/null +++ b/internal/discord/paths_linux.go @@ -0,0 +1,110 @@ +package discord + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func init() { + config, _ := os.UserConfigDir() + home, _ := os.UserHomeDir() + paths := []string{ + // Native. Data is stored under `~/.config`. + // Example: `~/.config/discordcanary`. + // Core: `~/.config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar`. + filepath.Join(config, "{channel}"), + + // Flatpak. These user data paths are universal for all Flatpak installations on all machines. + // Example: `.var/app/com.discordapp.DiscordCanary/config/discordcanary`. + // Core: `.var/app/com.discordapp.DiscordCanary/config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar` + filepath.Join(home, ".var", "app", "com.discordapp.{CHANNEL}", "config", "{channel}"), + + // Snap. Just like with Flatpaks, these paths are universal for all Snap installations. + // Example: `snap/discord/current/.config/discord`. + // Example: `snap/discord-canary/current/.config/discordcanary`. + // Core: `snap/discord-canary/current/.config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar`. + // NOTE: Snap user data always exists, even when the Snap isn't mounted/running. + filepath.Join(home, "snap", "{channel-}", "current", ".config", "{channel}"), + + // WSL. Data is stored under the Windows user's AppData folder. + // Example: `/mnt/c/Users/Username/AppData/Local/DiscordCanary`. + // Core: `/mnt/c/Users/Username/AppData/Local/DiscordCanary/app-1.0.9218/modules/discord_desktop_core-1/discord_desktop_core core.asar`. + filepath.Join(os.Getenv("WIN_HOME"), "AppData", "Local", "{CHANNEL}"), + } + + for _, channel := range models.Channels { + for _, path := range paths { + upper := strings.ReplaceAll(channel.Name(), " ", "") + lower := strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") + dash := strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "-") + folder := strings.ReplaceAll(path, "{CHANNEL}", upper) + folder = strings.ReplaceAll(folder, "{channel}", lower) + folder = strings.ReplaceAll(folder, "{channel-}", dash) + searchPaths = append(searchPaths, folder) + } + } + + allDiscordInstalls = GetAllInstalls() +} + +/** + * Currently nearly the same as darwin validation however + * it is kept separate in case of future changes to + * either system, it is likely that linux will require + * more advanced validation for snap and flatpak. + */ +func Validate(proposed string) *DiscordInstall { + var finalPath = "" + var selected = filepath.Base(proposed) + if strings.HasPrefix(strings.ToLower(selected), "discord") { + // Get version dir like 1.0.9002 + var dFiles, err = os.ReadDir(proposed) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) + if len(candidates) == 0 { + return nil + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) + var versionDir = candidates[len(candidates)-1].Name() + finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") + + // WSL installs have an extra layer + if (os.Getenv("WSL_DISTRO_NAME") != "" && os.Getenv("WIN_HOME") != "") && strings.Contains(proposed, "AppData") { + finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core-1", "discord_desktop_core") + } + } + + if len(strings.Split(selected, ".")) == 3 { + finalPath = filepath.Join(proposed, "modules", "discord_desktop_core") + } + + if selected == "modules" { + finalPath = filepath.Join(proposed, "discord_desktop_core") + } + + if selected == "discord_desktop_core" { + finalPath = proposed + } + + // If the path and the asar exist, all good + if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { + return &DiscordInstall{ + CorePath: finalPath, + Channel: GetChannel(finalPath), + Version: GetVersion(finalPath), + IsFlatpak: strings.Contains(finalPath, "com.discordapp."), + IsSnap: strings.Contains(finalPath, "snap/"), + } + } + + return nil +} diff --git a/internal/discord/paths_test.go b/internal/discord/paths_test.go new file mode 100644 index 0000000..6a252cd --- /dev/null +++ b/internal/discord/paths_test.go @@ -0,0 +1,298 @@ +package discord + +import ( + "path/filepath" + "testing" + + "github.com/betterdiscord/cli/internal/models" +) + +func TestGetVersion(t *testing.T) { + tests := []struct { + name string + path string + expected string + }{ + { + name: "Version in middle of path", + path: "/usr/share/discord/0.0.35/modules", + expected: "0.0.35", + }, + { + name: "Version at end of path", + path: "/home/user/.config/discord/0.0.36", + expected: "0.0.36", + }, + { + name: "Multiple versions (should return first)", + path: "/usr/share/1.2.3/discord/0.0.35/modules", + expected: "1.2.3", + }, + { + name: "No version in path", + path: "/usr/share/discord/modules", + expected: "", + }, + { + name: "Windows-style path with version", + path: "C:\\Users\\User\\AppData\\Local\\Discord\\app-1.0.9012", + expected: "1.0.9012", + }, + { + name: "Version with many digits", + path: "/opt/discord/123.456.789/core", + expected: "123.456.789", + }, + { + name: "Empty path", + path: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetVersion(tt.path) + if result != tt.expected { + t.Errorf("GetVersion(%s) = %s, expected %s", tt.path, result, tt.expected) + } + }) + } +} + +func TestGetChannel(t *testing.T) { + tests := []struct { + name string + path string + expected models.DiscordChannel + }{ + { + name: "Stable in path (lowercase)", + path: "/usr/share/discord/modules", + expected: models.Stable, + }, + { + name: "Canary in path (lowercase)", + path: "/usr/share/discordcanary/modules", + expected: models.Canary, + }, + { + name: "PTB in path (lowercase)", + path: "/usr/share/discordptb/modules", + expected: models.PTB, + }, + + { + name: "DiscordCanary without space", + path: "/home/user/.config/DiscordCanary/modules", + expected: models.Canary, + }, + { + name: "DiscordPTB without space", + path: "/home/user/.config/DiscordPTB/modules", + expected: models.PTB, + }, + { + name: "No channel identifier defaults to Stable", + path: "/some/random/path/modules", + expected: models.Stable, + }, + { + name: "Multiple Discord mentions (first wins)", + path: filepath.Join("discordcanary", "discord", "modules"), + expected: models.Canary, + }, + { + name: "Empty path defaults to Stable", + path: "", + expected: models.Stable, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetChannel(tt.path) + if result != tt.expected { + t.Errorf("GetChannel(%s) = %v (%s), expected %v (%s)", + tt.path, result, result.String(), tt.expected, tt.expected.String()) + } + }) + } +} + +func TestGetChannel_CaseInsensitive(t *testing.T) { + tests := []struct { + path string + expected models.DiscordChannel + }{ + {"/usr/share/DISCORD/modules", models.Stable}, + {"/usr/share/Discord/modules", models.Stable}, + {"/usr/share/DISCORDCANARY/modules", models.Canary}, + {"/usr/share/DiscordCanary/modules", models.Canary}, + {"/usr/share/DISCORDPTB/modules", models.PTB}, + {"/usr/share/DiscordPTB/modules", models.PTB}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := GetChannel(tt.path) + if result != tt.expected { + t.Errorf("GetChannel(%s) = %v, expected %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestGetSuggestedPath(t *testing.T) { + // Reset allDiscordInstalls for testing + allDiscordInstalls = make(map[models.DiscordChannel][]*DiscordInstall) + + // Test empty installs + result := GetSuggestedPath(models.Stable) + if result != "" { + t.Errorf("GetSuggestedPath with no installs should return empty string, got %s", result) + } + + // Add some test installs + allDiscordInstalls[models.Stable] = []*DiscordInstall{ + {CorePath: "/usr/share/discord/0.0.35", Version: "0.0.35"}, + {CorePath: "/usr/share/discord/0.0.34", Version: "0.0.34"}, + } + + allDiscordInstalls[models.Canary] = []*DiscordInstall{ + {CorePath: "/usr/share/discord-canary/0.0.200", Version: "0.0.200"}, + } + + // Test that it returns the first install + stableResult := GetSuggestedPath(models.Stable) + if stableResult != "/usr/share/discord/0.0.35" { + t.Errorf("GetSuggestedPath(Stable) = %s, expected /usr/share/discord/0.0.35", stableResult) + } + + canaryResult := GetSuggestedPath(models.Canary) + if canaryResult != "/usr/share/discord-canary/0.0.200" { + t.Errorf("GetSuggestedPath(Canary) = %s, expected /usr/share/discord-canary/0.0.200", canaryResult) + } + + // Test channel with no installs + ptbResult := GetSuggestedPath(models.PTB) + if ptbResult != "" { + t.Errorf("GetSuggestedPath(PTB) with no PTB installs should return empty string, got %s", ptbResult) + } +} + +func TestAddCustomPath(t *testing.T) { + // This test is limited because Validate() depends on OS-specific paths + // We're mainly testing the logic around adding and deduplication + + // Reset for testing + allDiscordInstalls = make(map[models.DiscordChannel][]*DiscordInstall) + + // Test with invalid path (will return nil since Validate will fail) + result := AddCustomPath("/nonexistent/invalid/path") + if result != nil { + t.Error("AddCustomPath with invalid path should return nil") + } + + // Further testing would require mocking the Validate function + // or setting up actual Discord installation directories +} + +func TestResolvePath(t *testing.T) { + // Reset for testing + allDiscordInstalls = make(map[models.DiscordChannel][]*DiscordInstall) + + // Add a test install + testInstall := &DiscordInstall{ + CorePath: "/test/discord/path", + Channel: models.Stable, + Version: "1.0.0", + } + allDiscordInstalls[models.Stable] = []*DiscordInstall{testInstall} + + // Test resolving existing path + result := ResolvePath("/test/discord/path") + if result != testInstall { + t.Error("ResolvePath should return the existing install") + } + + // Test resolving non-existent path (will try AddCustomPath and likely return nil) + result2 := ResolvePath("/nonexistent/path") + if result2 != nil { + // This might succeed or fail depending on whether Validate passes + // In most test environments, it should return nil + t.Log("ResolvePath returned non-nil for non-existent path (may be valid in some environments)") + } +} + +func TestSortInstalls(t *testing.T) { + // Reset for testing + allDiscordInstalls = make(map[models.DiscordChannel][]*DiscordInstall) + + // Add unsorted installs + allDiscordInstalls[models.Stable] = []*DiscordInstall{ + {CorePath: "/path1", Version: "0.0.34", Channel: models.Stable}, + {CorePath: "/path2", Version: "0.0.36", Channel: models.Stable}, + {CorePath: "/path3", Version: "0.0.35", Channel: models.Stable}, + } + + // Sort them + sortInstalls() + + // Verify sorted in descending order by version + installs := allDiscordInstalls[models.Stable] + if len(installs) != 3 { + t.Fatalf("Expected 3 installs, got %d", len(installs)) + } + + if installs[0].Version != "0.0.36" { + t.Errorf("First install should have version 0.0.36, got %s", installs[0].Version) + } + if installs[1].Version != "0.0.35" { + t.Errorf("Second install should have version 0.0.35, got %s", installs[1].Version) + } + if installs[2].Version != "0.0.34" { + t.Errorf("Third install should have version 0.0.34, got %s", installs[2].Version) + } +} + +func TestSortInstalls_MultipleChannels(t *testing.T) { + // Reset for testing + allDiscordInstalls = make(map[models.DiscordChannel][]*DiscordInstall) + + // Add unsorted installs for multiple channels + allDiscordInstalls[models.Stable] = []*DiscordInstall{ + {CorePath: "/stable1", Version: "1.0.0", Channel: models.Stable}, + {CorePath: "/stable2", Version: "1.0.2", Channel: models.Stable}, + } + + allDiscordInstalls[models.Canary] = []*DiscordInstall{ + {CorePath: "/canary1", Version: "0.0.100", Channel: models.Canary}, + {CorePath: "/canary2", Version: "0.0.150", Channel: models.Canary}, + {CorePath: "/canary3", Version: "0.0.125", Channel: models.Canary}, + } + + // Sort them + sortInstalls() + + // Verify Stable channel is sorted + stableInstalls := allDiscordInstalls[models.Stable] + if stableInstalls[0].Version != "1.0.2" { + t.Errorf("Stable: First version should be 1.0.2, got %s", stableInstalls[0].Version) + } + if stableInstalls[1].Version != "1.0.0" { + t.Errorf("Stable: Second version should be 1.0.0, got %s", stableInstalls[1].Version) + } + + // Verify Canary channel is sorted + canaryInstalls := allDiscordInstalls[models.Canary] + if canaryInstalls[0].Version != "0.0.150" { + t.Errorf("Canary: First version should be 0.0.150, got %s", canaryInstalls[0].Version) + } + if canaryInstalls[1].Version != "0.0.125" { + t.Errorf("Canary: Second version should be 0.0.125, got %s", canaryInstalls[1].Version) + } + if canaryInstalls[2].Version != "0.0.100" { + t.Errorf("Canary: Third version should be 0.0.100, got %s", canaryInstalls[2].Version) + } +} diff --git a/internal/discord/paths_windows.go b/internal/discord/paths_windows.go new file mode 100644 index 0000000..0160637 --- /dev/null +++ b/internal/discord/paths_windows.go @@ -0,0 +1,100 @@ +package discord + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func init() { + paths := []string{ + filepath.Join(os.Getenv("LOCALAPPDATA"), "{channel}"), + filepath.Join(os.Getenv("PROGRAMDATA"), os.Getenv("USERNAME"), "{channel}"), + } + + for _, channel := range models.Channels { + for _, path := range paths { + searchPaths = append( + searchPaths, + strings.ReplaceAll(path, "{channel}", strings.ReplaceAll(channel.Name(), " ", "")), + ) + } + } + + allDiscordInstalls = GetAllInstalls() +} + +func Validate(proposed string) *DiscordInstall { + var finalPath = "" + var selected = filepath.Base(proposed) + + if strings.HasPrefix(selected, "Discord") { + + // Get version dir like 1.0.9002 + var dFiles, err = os.ReadDir(proposed) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) + if len(candidates) == 0 { + return nil + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) + var versionDir = candidates[len(candidates)-1].Name() + + // Get core wrap like discord_desktop_core-1 + dFiles, err = os.ReadDir(filepath.Join(proposed, versionDir, "modules")) + if err != nil { + return nil + } + candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { + return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") + }) + if len(candidates) == 0 { + return nil + } + var coreWrap = candidates[len(candidates)-1].Name() + + finalPath = filepath.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") + } + + // Use a separate if statement because forcing same-line } else if { is gross + if strings.HasPrefix(selected, "app-") { + var dFiles, err = os.ReadDir(filepath.Join(proposed, "modules")) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { + return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") + }) + if len(candidates) == 0 { + return nil + } + var coreWrap = candidates[len(candidates)-1].Name() + finalPath = filepath.Join(proposed, "modules", coreWrap, "discord_desktop_core") + } + + if selected == "discord_desktop_core" { + finalPath = proposed + } + + // If the path and the asar exist, all good + if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { + return &DiscordInstall{ + CorePath: finalPath, + Channel: GetChannel(finalPath), + Version: GetVersion(finalPath), + IsFlatpak: false, + IsSnap: false, + } + } + + return nil +} diff --git a/internal/discord/process.go b/internal/discord/process.go new file mode 100644 index 0000000..435195f --- /dev/null +++ b/internal/discord/process.go @@ -0,0 +1,134 @@ +package discord + +import ( + "fmt" + "log" + "os" + "os/exec" + + "github.com/shirou/gopsutil/v3/process" +) + +func (discord *DiscordInstall) restart() error { + exeName := discord.getFullExe() + + if running, _ := discord.isRunning(); !running { + log.Printf("✅ %s not running", discord.Channel.Name()) + return nil + } + + if err := discord.kill(); err != nil { + log.Printf("❌ Unable to restart %s, please do so manually!", discord.Channel.Name()) + log.Printf("❌ %s", err.Error()) + return err + } + + // Determine command based on installation type + var cmd *exec.Cmd + if discord.IsFlatpak { + cmd = exec.Command("flatpak", "run", "com.discordapp."+discord.Channel.Exe()) + } else if discord.IsSnap { + cmd = exec.Command("snap", "run", discord.Channel.Exe()) + } else { + // Use binary found in killing process for non-Flatpak/Snap installs + if exeName == "" { + log.Printf("❌ Unable to restart %s, please do so manually!", discord.Channel.Name()) + return fmt.Errorf("could not determine executable path for %s", discord.Channel.Name()) + } + cmd = exec.Command(exeName) + } + + // Set working directory to user home + cmd.Dir, _ = os.UserHomeDir() + + if err := cmd.Start(); err != nil { + log.Printf("❌ Unable to restart %s, please do so manually!", discord.Channel.Name()) + log.Printf("❌ %s", err.Error()) + return err + } + log.Printf("✅ Restarted %s", discord.Channel.Name()) + return nil +} + +func (discord *DiscordInstall) isRunning() (bool, error) { + name := discord.Channel.Exe() + processes, err := process.Processes() + + // If we can't even list processes, bail out + if err != nil { + return false, fmt.Errorf("could not list processes") + } + + // Search for desired process(es) + for _, p := range processes { + n, err := p.Name() + + // Ignore processes requiring Admin/Sudo + if err != nil { + continue + } + + // We found our target return + if n == name { + return true, nil + } + } + + // If we got here, process was not found + return false, nil +} + +func (discord *DiscordInstall) kill() error { + name := discord.Channel.Exe() + processes, err := process.Processes() + + // If we can't even list processes, bail out + if err != nil { + return fmt.Errorf("could not list processes") + } + + // Search for desired process(es) + for _, p := range processes { + n, err := p.Name() + + // Ignore processes requiring Admin/Sudo + if err != nil { + continue + } + + // We found our target, kill it + if n == name { + var killErr = p.Kill() + + // We found it but can't kill it, bail out + if killErr != nil { + return killErr + } + } + } + + // If we got here, everything was killed without error + return nil +} + +func (discord *DiscordInstall) getFullExe() string { + name := discord.Channel.Exe() + + var exe = "" + processes, err := process.Processes() + if err != nil { + return exe + } + for _, p := range processes { + n, err := p.Name() + if err != nil { + continue + } + if n == name { + if len(exe) == 0 { + exe, _ = p.Exe() + } + } + } + return exe +} diff --git a/internal/models/channel.go b/internal/models/channel.go new file mode 100644 index 0000000..34b4fc7 --- /dev/null +++ b/internal/models/channel.go @@ -0,0 +1,77 @@ +package models + +import ( + "runtime" + "strings" +) + +// DiscordChannel represents a Discord release channel (Stable, PTB, Canary) +type DiscordChannel int + +const ( + Stable DiscordChannel = iota + Canary + PTB +) + +// All available Discord channels +var Channels = []DiscordChannel{Stable, Canary, PTB} + +// Used for logging, etc +func (channel DiscordChannel) String() string { + switch channel { + case Stable: + return "stable" + case Canary: + return "canary" + case PTB: + return "ptb" + } + return "" +} + +// Used for user display +func (channel DiscordChannel) Name() string { + switch channel { + case Stable: + return "Discord" + case Canary: + return "Discord Canary" + case PTB: + return "Discord PTB" + } + return "" +} + +// Exe returns the executable name for the release channel +func (channel DiscordChannel) Exe() string { + name := channel.Name() + + if runtime.GOOS != "darwin" { + name = strings.ReplaceAll(name, " ", "") + } + + if runtime.GOOS == "windows" { + name = name + ".exe" + } + + return name +} + +// ParseChannel converts a string input to a DiscordChannel type +func ParseChannel(input string) DiscordChannel { + switch strings.ToLower(input) { + case "stable": + return Stable + case "canary": + return Canary + case "ptb": + return PTB + } + return Stable +} + +// Used by Wails for type serialization +func (channel DiscordChannel) TSName() string { + return strings.ToUpper(channel.String()) +} diff --git a/internal/models/channel_test.go b/internal/models/channel_test.go new file mode 100644 index 0000000..351f308 --- /dev/null +++ b/internal/models/channel_test.go @@ -0,0 +1,251 @@ +package models + +import ( + "runtime" + "testing" +) + +func TestDiscordChannel_String(t *testing.T) { + tests := []struct { + channel DiscordChannel + expected string + }{ + {Stable, "stable"}, + {Canary, "canary"}, + {PTB, "ptb"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.channel.String() + if result != tt.expected { + t.Errorf("String() = %s, expected %s", result, tt.expected) + } + }) + } +} + +func TestDiscordChannel_Name(t *testing.T) { + tests := []struct { + channel DiscordChannel + expected string + }{ + {Stable, "Discord"}, + {Canary, "Discord Canary"}, + {PTB, "Discord PTB"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.channel.Name() + if result != tt.expected { + t.Errorf("Name() = %s, expected %s", result, tt.expected) + } + }) + } +} + +func TestDiscordChannel_Exe(t *testing.T) { + tests := []struct { + name string + channel DiscordChannel + goos string + expected string + }{ + { + name: "Stable on Linux", + channel: Stable, + goos: "linux", + expected: "Discord", + }, + { + name: "Canary on Linux", + channel: Canary, + goos: "linux", + expected: "DiscordCanary", + }, + { + name: "PTB on Linux", + channel: PTB, + goos: "linux", + expected: "DiscordPTB", + }, + { + name: "Stable on Darwin", + channel: Stable, + goos: "darwin", + expected: "Discord", + }, + { + name: "Canary on Darwin", + channel: Canary, + goos: "darwin", + expected: "Discord Canary", + }, + { + name: "PTB on Darwin", + channel: PTB, + goos: "darwin", + expected: "Discord PTB", + }, + { + name: "Stable on Windows", + channel: Stable, + goos: "windows", + expected: "Discord.exe", + }, + { + name: "Canary on Windows", + channel: Canary, + goos: "windows", + expected: "DiscordCanary.exe", + }, + { + name: "PTB on Windows", + channel: PTB, + goos: "windows", + expected: "DiscordPTB.exe", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: This test will check the actual runtime.GOOS + // In a real test environment, you might want to mock this + // For now, we'll just test the actual platform + if runtime.GOOS != tt.goos { + t.Skipf("Skipping test for %s on %s", tt.goos, runtime.GOOS) + } + + result := tt.channel.Exe() + if result != tt.expected { + t.Errorf("Exe() = %s, expected %s", result, tt.expected) + } + }) + } +} + +func TestDiscordChannel_Exe_CurrentPlatform(t *testing.T) { + // Test that Exe() returns something reasonable for the current platform + channels := []DiscordChannel{Stable, Canary, PTB} + + for _, channel := range channels { + result := channel.Exe() + + // Basic validation + if result == "" { + t.Errorf("Exe() returned empty string for %s", channel.Name()) + } + + // Platform-specific checks + switch runtime.GOOS { + case "windows": + if result[len(result)-4:] != ".exe" { + t.Errorf("Exe() on Windows should end with .exe, got %s", result) + } + case "darwin": + // On macOS, names should contain spaces for Canary and PTB + if channel == Canary && result != "Discord Canary" { + t.Errorf("Exe() on macOS for Canary = %s, expected 'Discord Canary'", result) + } + if channel == PTB && result != "Discord PTB" { + t.Errorf("Exe() on macOS for PTB = %s, expected 'Discord PTB'", result) + } + default: + // On Linux, names should not contain spaces + if channel == Canary && result != "DiscordCanary" { + t.Errorf("Exe() on Linux for Canary = %s, expected 'DiscordCanary'", result) + } + if channel == PTB && result != "DiscordPTB" { + t.Errorf("Exe() on Linux for PTB = %s, expected 'DiscordPTB'", result) + } + } + } +} + +func TestParseChannel(t *testing.T) { + tests := []struct { + input string + expected DiscordChannel + }{ + {"stable", Stable}, + {"Stable", Stable}, + {"STABLE", Stable}, + {"canary", Canary}, + {"Canary", Canary}, + {"CANARY", Canary}, + {"ptb", PTB}, + {"PTB", PTB}, + {"Ptb", PTB}, + {"invalid", Stable}, // Default to Stable + {"", Stable}, // Default to Stable + {"discord", Stable}, // Unknown input defaults to Stable + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := ParseChannel(tt.input) + if result != tt.expected { + t.Errorf("ParseChannel(%s) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestDiscordChannel_TSName(t *testing.T) { + tests := []struct { + channel DiscordChannel + expected string + }{ + {Stable, "STABLE"}, + {Canary, "CANARY"}, + {PTB, "PTB"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.channel.TSName() + if result != tt.expected { + t.Errorf("TSName() = %s, expected %s", result, tt.expected) + } + }) + } +} + +func TestChannelsConstant(t *testing.T) { + // Verify that Channels contains all expected channels + if len(Channels) != 3 { + t.Errorf("Channels should contain 3 channels, got %d", len(Channels)) + } + + expectedChannels := []DiscordChannel{Stable, Canary, PTB} + for i, expected := range expectedChannels { + if Channels[i] != expected { + t.Errorf("Channels[%d] = %v, expected %v", i, Channels[i], expected) + } + } +} + +func TestDiscordChannel_EnumValues(t *testing.T) { + // Test that the enum values are distinct + if Stable == Canary { + t.Error("Stable and Canary should have different values") + } + if Stable == PTB { + t.Error("Stable and PTB should have different values") + } + if Canary == PTB { + t.Error("Canary and PTB should have different values") + } + + // Test that values are sequential starting from 0 + if Stable != 0 { + t.Errorf("Stable should be 0, got %d", Stable) + } + if Canary != 1 { + t.Errorf("Canary should be 1, got %d", Canary) + } + if PTB != 2 { + t.Errorf("PTB should be 2, got %d", PTB) + } +} diff --git a/utils/api.go b/internal/models/github.go similarity index 93% rename from utils/api.go rename to internal/models/github.go index 4fb9c2f..6ef2529 100644 --- a/utils/api.go +++ b/internal/models/github.go @@ -1,10 +1,10 @@ -package utils +package models import ( "time" ) -type Release struct { +type GitHubRelease struct { URL string `json:"url"` AssetsURL string `json:"assets_url"` UploadURL string `json:"upload_url"` @@ -39,11 +39,11 @@ type Release struct { CreatedAt time.Time `json:"created_at"` PublishedAt time.Time `json:"published_at"` Assets []struct { - URL string `json:"url"` - ID int `json:"id"` - NodeID string `json:"node_id"` - Name string `json:"name"` - Label interface{} `json:"label"` + URL string `json:"url"` + ID int `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + Label any `json:"label"` Uploader struct { Login string `json:"login"` ID int `json:"id"` diff --git a/utils/download.go b/internal/utils/download.go similarity index 59% rename from utils/download.go rename to internal/utils/download.go index 2297aa9..dc0a6f1 100644 --- a/utils/download.go +++ b/internal/utils/download.go @@ -13,19 +13,23 @@ var client = &http.Client{ Timeout: 10 * time.Second, } -func DownloadFile(url string, filepath string) (err error) { +func DownloadFile(url string, filepath string) (response *http.Response, err error) { // Create the file out, err := os.Create(filepath) if err != nil { - return err + return nil, err } - defer out.Close() + defer func() { + if cerr := out.Close(); cerr != nil && err == nil { + err = cerr + } + }() // Setup the request req, err := http.NewRequest("GET", url, nil) if err != nil { - return err + return nil, err } req.Header.Add("User-Agent", "BetterDiscord/cli") req.Header.Add("Accept", "application/octet-stream") @@ -33,22 +37,26 @@ func DownloadFile(url string, filepath string) (err error) { // Get the data resp, err := client.Do(req) if err != nil { - return err + return resp, err } - defer resp.Body.Close() + defer func() { + if cerr := resp.Body.Close(); cerr != nil && err == nil { + err = cerr + } + }() // Check server response if resp.StatusCode != http.StatusOK { - return fmt.Errorf("Bad status: %s", resp.Status) + return resp, fmt.Errorf("bad status code: %s", resp.Status) } // Writer the body to file _, err = io.Copy(out, resp.Body) if err != nil { - return err + return resp, err } - return nil + return resp, nil } func DownloadJSON[T any](url string) (T, error) { @@ -66,14 +74,22 @@ func DownloadJSON[T any](url string) (T, error) { if err != nil { return data, err } - defer resp.Body.Close() + defer func() { + if cerr := resp.Body.Close(); cerr != nil && err == nil { + err = cerr + } + }() // Check server response if resp.StatusCode != http.StatusOK { - return data, fmt.Errorf("Bad status: %s", resp.Status) + return data, fmt.Errorf("bad status: %s", resp.Status) } - json.NewDecoder(resp.Body).Decode(&data) + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(&data) + if err != nil { + return data, err + } return data, nil } diff --git a/internal/utils/download_test.go b/internal/utils/download_test.go new file mode 100644 index 0000000..c67f348 --- /dev/null +++ b/internal/utils/download_test.go @@ -0,0 +1,230 @@ +package utils + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +func TestDownloadFile(t *testing.T) { + // Create a test server + testData := []byte("test file content") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request headers + if r.Header.Get("User-Agent") != "BetterDiscord/cli" { + t.Errorf("Expected User-Agent header 'BetterDiscord/cli', got '%s'", r.Header.Get("User-Agent")) + } + if r.Header.Get("Accept") != "application/octet-stream" { + t.Errorf("Expected Accept header 'application/octet-stream', got '%s'", r.Header.Get("Accept")) + } + + w.WriteHeader(http.StatusOK) + w.Write(testData) //nolint:errcheck + })) + defer server.Close() + + // Create temp directory for test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "downloaded.txt") + + // Download the file + resp, err := DownloadFile(server.URL, testFile) + if err != nil { + t.Fatalf("DownloadFile() failed: %v", err) + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode) + } + + // Verify file was created + if !Exists(testFile) { + t.Errorf("Downloaded file does not exist: %s", testFile) + } + + // Verify file content + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read downloaded file: %v", err) + } + + if string(content) != string(testData) { + t.Errorf("File content mismatch. Expected '%s', got '%s'", string(testData), string(content)) + } +} + +func TestDownloadFile_BadStatusCode(t *testing.T) { + // Create a test server that returns 404 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "downloaded.txt") + + _, err := DownloadFile(server.URL, testFile) + if err == nil { + t.Error("DownloadFile() should have returned an error for 404 status") + } +} + +func TestDownloadFile_InvalidURL(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "downloaded.txt") + + _, err := DownloadFile("http://invalid.test.nonexistent.domain", testFile) + if err == nil { + t.Error("DownloadFile() should have returned an error for invalid URL") + } +} + +func TestDownloadFile_InvalidPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("test")) //nolint:errcheck + })) + defer server.Close() + + // Try to write to an invalid path (directory doesn't exist) + invalidPath := "/nonexistent/directory/file.txt" + _, err := DownloadFile(server.URL, invalidPath) + if err == nil { + t.Error("DownloadFile() should have returned an error for invalid file path") + } +} + +func TestDownloadJSON(t *testing.T) { + // Define a test struct + type TestData struct { + Name string `json:"name"` + Value int `json:"value"` + } + + expectedData := TestData{ + Name: "test", + Value: 42, + } + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request headers + if r.Header.Get("User-Agent") != "BetterDiscord/cli" { + t.Errorf("Expected User-Agent header 'BetterDiscord/cli', got '%s'", r.Header.Get("User-Agent")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(expectedData) //nolint:errcheck + })) + defer server.Close() + + // Download and parse JSON + result, err := DownloadJSON[TestData](server.URL) + if err != nil { + t.Fatalf("DownloadJSON() failed: %v", err) + } + + if result.Name != expectedData.Name { + t.Errorf("Expected Name '%s', got '%s'", expectedData.Name, result.Name) + } + + if result.Value != expectedData.Value { + t.Errorf("Expected Value %d, got %d", expectedData.Value, result.Value) + } +} + +func TestDownloadJSON_BadStatusCode(t *testing.T) { + type TestData struct { + Name string `json:"name"` + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + _, err := DownloadJSON[TestData](server.URL) + if err == nil { + t.Error("DownloadJSON() should have returned an error for 500 status") + } +} + +func TestDownloadJSON_InvalidJSON(t *testing.T) { + type TestData struct { + Name string `json:"name"` + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("invalid json {")) //nolint:errcheck + })) + defer server.Close() + + _, err := DownloadJSON[TestData](server.URL) + if err == nil { + t.Error("DownloadJSON() should have returned an error for invalid JSON") + } +} + +func TestDownloadJSON_ComplexStruct(t *testing.T) { + type NestedData struct { + ID int `json:"id"` + Name string `json:"name"` + } + + type ComplexData struct { + Items []NestedData `json:"items"` + Timestamp time.Time `json:"timestamp"` + Active bool `json:"active"` + } + + now := time.Now().UTC().Round(time.Second) + expectedData := ComplexData{ + Items: []NestedData{ + {ID: 1, Name: "first"}, + {ID: 2, Name: "second"}, + }, + Timestamp: now, + Active: true, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(expectedData) //nolint:errcheck + })) + defer server.Close() + + result, err := DownloadJSON[ComplexData](server.URL) + if err != nil { + t.Fatalf("DownloadJSON() failed: %v", err) + } + + if len(result.Items) != len(expectedData.Items) { + t.Errorf("Expected %d items, got %d", len(expectedData.Items), len(result.Items)) + } + + if result.Active != expectedData.Active { + t.Errorf("Expected Active %v, got %v", expectedData.Active, result.Active) + } + + if !result.Timestamp.Equal(expectedData.Timestamp) { + t.Errorf("Expected Timestamp %v, got %v", expectedData.Timestamp, result.Timestamp) + } +} + +func TestDownloadJSON_InvalidURL(t *testing.T) { + type TestData struct { + Name string `json:"name"` + } + + _, err := DownloadJSON[TestData]("http://invalid.test.nonexistent.domain") + if err == nil { + t.Error("DownloadJSON() should have returned an error for invalid URL") + } +} diff --git a/internal/utils/paths.go b/internal/utils/paths.go new file mode 100644 index 0000000..085b53b --- /dev/null +++ b/internal/utils/paths.go @@ -0,0 +1,20 @@ +package utils + +import ( + "os" +) + +func Exists(path string) bool { + var _, err = os.Stat(path) + return err == nil +} + +func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { + var returnArray = []T{} + for _, s := range source { + if filterFunc(s) { + returnArray = append(returnArray, s) + } + } + return returnArray +} diff --git a/internal/utils/paths_test.go b/internal/utils/paths_test.go new file mode 100644 index 0000000..e8bae75 --- /dev/null +++ b/internal/utils/paths_test.go @@ -0,0 +1,147 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExists(t *testing.T) { + // Create a temporary file for testing + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "testfile.txt") + + // Test non-existent file + if Exists(tmpFile) { + t.Errorf("Exists() returned true for non-existent file: %s", tmpFile) + } + + // Create the file + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Test existing file + if !Exists(tmpFile) { + t.Errorf("Exists() returned false for existing file: %s", tmpFile) + } + + // Test directory + if !Exists(tmpDir) { + t.Errorf("Exists() returned false for existing directory: %s", tmpDir) + } + + // Test non-existent directory + nonExistentDir := filepath.Join(tmpDir, "nonexistent", "directory") + if Exists(nonExistentDir) { + t.Errorf("Exists() returned true for non-existent directory: %s", nonExistentDir) + } +} + +func TestFilter(t *testing.T) { + tests := []struct { + name string + input []int + filterFunc func(int) bool + expected []int + }{ + { + name: "Filter even numbers", + input: []int{1, 2, 3, 4, 5, 6}, + filterFunc: func(n int) bool { return n%2 == 0 }, + expected: []int{2, 4, 6}, + }, + { + name: "Filter numbers greater than 5", + input: []int{1, 3, 5, 7, 9, 11}, + filterFunc: func(n int) bool { return n > 5 }, + expected: []int{7, 9, 11}, + }, + { + name: "Filter all items (return empty)", + input: []int{1, 2, 3}, + filterFunc: func(n int) bool { return false }, + expected: []int{}, + }, + { + name: "Filter none (return all)", + input: []int{1, 2, 3}, + filterFunc: func(n int) bool { return true }, + expected: []int{1, 2, 3}, + }, + { + name: "Empty input", + input: []int{}, + filterFunc: func(n int) bool { return true }, + expected: []int{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Filter(tt.input, tt.filterFunc) + + if len(result) != len(tt.expected) { + t.Errorf("Filter() returned slice of length %d, expected %d", len(result), len(tt.expected)) + return + } + + for i, v := range result { + if v != tt.expected[i] { + t.Errorf("Filter() result[%d] = %v, expected %v", i, v, tt.expected[i]) + } + } + }) + } +} + +func TestFilterWithStrings(t *testing.T) { + input := []string{"apple", "banana", "cherry", "date"} + filterFunc := func(s string) bool { + return len(s) > 5 + } + expected := []string{"banana", "cherry"} + + result := Filter(input, filterFunc) + + if len(result) != len(expected) { + t.Errorf("Filter() returned slice of length %d, expected %d", len(result), len(expected)) + return + } + + for i, v := range result { + if v != expected[i] { + t.Errorf("Filter() result[%d] = %v, expected %v", i, v, expected[i]) + } + } +} + +func TestFilterWithStructs(t *testing.T) { + type Person struct { + Name string + Age int + } + + input := []Person{ + {Name: "Alice", Age: 25}, + {Name: "Bob", Age: 17}, + {Name: "Charlie", Age: 30}, + {Name: "Diana", Age: 16}, + } + + // Filter adults (age >= 18) + filterFunc := func(p Person) bool { + return p.Age >= 18 + } + + result := Filter(input, filterFunc) + + if len(result) != 2 { + t.Errorf("Filter() returned %d persons, expected 2 adults", len(result)) + return + } + + if result[0].Name != "Alice" || result[1].Name != "Charlie" { + t.Errorf("Filter() returned unexpected persons: %v", result) + } +} diff --git a/main.go b/main.go index fd486d0..f34436d 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,12 @@ package main import ( + "log" + "github.com/betterdiscord/cli/cmd" ) func main() { + log.SetFlags(0) cmd.Execute() } diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 6d86453..0000000 --- a/package-lock.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@betterdiscord/cli", - "version": "0.1.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "@betterdiscord/cli", - "version": "0.1.0", - "license": "Apache-2.0", - "dependencies": { - "@go-task/go-npm": "^0.1.17" - } - }, - "node_modules/@go-task/go-npm": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/@go-task/go-npm/-/go-npm-0.1.17.tgz", - "integrity": "sha512-j+xydQWrAxsqLYjweok1fWzDmBAA1g/gmFbPyG8kRI/d/+rzXGGLlro8zdS6mJ3Is+8BrIy2ZBmQkoONhowh7A==", - "bin": { - "go-npm": "bin/index.js" - } - } - }, - "dependencies": { - "@go-task/go-npm": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/@go-task/go-npm/-/go-npm-0.1.17.tgz", - "integrity": "sha512-j+xydQWrAxsqLYjweok1fWzDmBAA1g/gmFbPyG8kRI/d/+rzXGGLlro8zdS6mJ3Is+8BrIy2ZBmQkoONhowh7A==" - } - } -} diff --git a/package.json b/package.json index 2a50de9..f9dee95 100644 --- a/package.json +++ b/package.json @@ -1,32 +1,32 @@ -{ - "name": "@betterdiscord/cli", - "version": "0.1.0", - "description": "A cross-platform CLI for managing BetterDiscord", - "main": "index.js", - "publishConfig": { - "access": "public" - }, - "files": [], - "scripts": { - "postinstall": "go-npm install", - "preuninstall": "go-npm uninstall" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/BetterDiscord/cli.git" - }, - "author": "BetterDiscord Team", - "license": "Apache-2.0", - "bugs": { - "url": "https://github.com/BetterDiscord/cli/issues" - }, - "homepage": "https://github.com/BetterDiscord/cli#readme", - "dependencies": { - "@go-task/go-npm": "^0.1.17" - }, - "goBinary": { - "name": "bdcli", - "path": "./bin", - "url": "https://github.com/BetterDiscord/cli/releases/download/v{{version}}/bdcli_{{platform}}_{{arch}}{{archive_ext}}" - } -} +{ + "name": "@betterdiscord/cli", + "version": "0.1.0", + "description": "A cross-platform CLI for managing BetterDiscord", + "main": "index.js", + "publishConfig": { + "access": "public" + }, + "files": [], + "scripts": { + "postinstall": "go-npm install", + "preuninstall": "go-npm uninstall" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/BetterDiscord/cli.git" + }, + "author": "BetterDiscord Team", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/BetterDiscord/cli/issues" + }, + "homepage": "https://github.com/BetterDiscord/cli#readme", + "dependencies": { + "@go-task/go-npm": "^0.2.0" + }, + "goBinary": { + "name": "bdcli", + "path": "./bin", + "url": "https://github.com/BetterDiscord/cli/releases/download/v{{version}}/bdcli_{{platform}}_{{arch}}{{archive_ext}}" + } +} diff --git a/scripts/completions.sh b/scripts/completions.sh new file mode 100644 index 0000000..9b0d070 --- /dev/null +++ b/scripts/completions.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e +rm -rf completions +mkdir completions +for sh in bash zsh fish; do + go run main.go completion "$sh" >"completions/bdcli.$sh" +done \ No newline at end of file diff --git a/utils/channels.go b/utils/channels.go deleted file mode 100644 index 08b848c..0000000 --- a/utils/channels.go +++ /dev/null @@ -1,16 +0,0 @@ -package utils - -import "strings" - -func GetChannelName(channel string) string { - switch strings.ToLower(channel) { - case "stable": - return "Discord" - case "canary": - return "DiscordCanary" - case "ptb": - return "DiscordPTB" - default: - return "" - } -} diff --git a/utils/exe.go b/utils/exe.go deleted file mode 100644 index a51e6e1..0000000 --- a/utils/exe.go +++ /dev/null @@ -1,25 +0,0 @@ -package utils - -import ( - "github.com/shirou/gopsutil/v3/process" -) - -func GetProcessExe(name string) string { - var exe = "" - processes, err := process.Processes() - if err != nil { - return exe - } - for _, p := range processes { - n, err := p.Name() - if err != nil { - continue - } - if n == name { - if len(exe) == 0 { - exe, _ = p.Exe() - } - } - } - return exe -} diff --git a/utils/kill.go b/utils/kill.go deleted file mode 100644 index 3b15aec..0000000 --- a/utils/kill.go +++ /dev/null @@ -1,39 +0,0 @@ -package utils - -import ( - "fmt" - - "github.com/shirou/gopsutil/v3/process" -) - -func KillProcess(name string) error { - processes, err := process.Processes() - - // If we can't even list processes, bail out - if err != nil { - return fmt.Errorf("Could not list processes") - } - - // Search for desired processe(s) - for _, p := range processes { - n, err := p.Name() - - // Ignore processes requiring Admin/Sudo - if err != nil { - continue - } - - // We found our target, kill it - if n == name { - var killErr = p.Kill() - - // We found it but can't kill it, bail out - if killErr != nil { - return killErr - } - } - } - - // If we got here, everything was killed without error - return nil -} diff --git a/utils/paths.go b/utils/paths.go deleted file mode 100644 index 590a5c7..0000000 --- a/utils/paths.go +++ /dev/null @@ -1,160 +0,0 @@ -package utils - -import ( - "io/fs" - "os" - "path" - "runtime" - "sort" - "strings" -) - -var Roaming string -var BetterDiscord string -var Data string -var Plugins string -var Themes string - -func init() { - var configDir, err = os.UserConfigDir() - if err != nil { - return - } - Roaming = configDir - BetterDiscord = path.Join(configDir, "BetterDiscord") - Data = path.Join(BetterDiscord, "data") - Plugins = path.Join(BetterDiscord, "plugins") - Themes = path.Join(BetterDiscord, "themes") -} - -func Exists(path string) bool { - var _, err = os.Stat(path) - return err == nil -} - -func DiscordPath(channel string) string { - var channelName = GetChannelName(channel) - - switch op := runtime.GOOS; op { - case "windows": - return ValidatePath(path.Join(os.Getenv("LOCALAPPDATA"), channelName)) - case "darwin": - return ValidatePath(path.Join("/", "Applications", channelName+".app")) - case "linux": - return ValidatePath(path.Join(Roaming, strings.ToLower(channelName))) - default: - return "" - } -} - -func ValidatePath(proposed string) string { - switch op := runtime.GOOS; op { - case "windows": - return validateWindows(proposed) - case "darwin","linux": - return validateMacLinux(proposed) - default: - return "" - } -} - - -func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { - var returnArray = []T{} - for _, s := range source { - if filterFunc(s) { - returnArray = append(ret, s) - } - } - return returnArray -} - - -func validateWindows(proposed string) string { - var finalPath = "" - var selected = path.Base(proposed) - if strings.HasPrefix(selected, "Discord") { - - // Get version dir like 1.0.9002 - var dFiles, err = os.ReadDir(proposed) - if err != nil { - return "" - } - - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) - sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) - var versionDir = candidates[len(candidates)-1].Name() - - // Get core wrap like discord_desktop_core-1 - dFiles, err = os.ReadDir(path.Join(proposed, versionDir, "modules")) - if err != nil { - return "" - } - candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") }) - var coreWrap = candidates[len(candidates)-1].Name() - - finalPath = path.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") - } - - // Use a separate if statement because forcing same-line } else if { is gross - if strings.HasPrefix(proposed, "app-") { - var dFiles, err = os.ReadDir(path.Join(proposed, "modules")) - if err != nil { - return "" - } - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") }) - var coreWrap = candidates[len(candidates)-1].Name() - finalPath = path.Join(proposed, coreWrap, "discord_desktop_core") - } - - if selected == "discord_desktop_core" { - finalPath = proposed - } - - // If the path and the asar exist, all good - if Exists(finalPath) && Exists(path.Join(finalPath, "core.asar")) { - return finalPath - } - - return "" -} - - -func validateMacLinux(proposed string) string { - if strings.Contains(proposed, "/snap") { - return "" - } - - var finalPath = "" - var selected = path.Base(proposed) - if strings.HasPrefix(selected, "discord") { - // Get version dir like 1.0.9002 - var dFiles, err = os.ReadDir(proposed) - if err != nil { - return "" - } - - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) - sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) - var versionDir = candidates[len(candidates)-1].Name() - finalPath = path.Join(proposed, versionDir, "modules", "discord_desktop_core") - } - - if len(strings.Split(selected, ".")) == 3 { - finalPath = path.Join(proposed, "modules", "discord_desktop_core") - } - - if selected == "modules" { - finalPath = path.Join(proposed, "discord_desktop_core") - } - - if selected == "discord_desktop_core" { - finalPath = proposed - } - - if Exists(finalPath) && Exists(path.Join(finalPath, "core.asar")) { - return finalPath - } - - return "" -}