Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ on:
paths:
- "rust/**"
- "src/jsHelper/**"
- "install.sh"
- "install.ps1"
- "scripts/test-install-completions.*"
- "scripts/build-payload.mjs"
- ".github/workflows/rust.yml"
push:
Expand All @@ -19,6 +22,9 @@ on:
paths:
- "rust/**"
- "src/jsHelper/**"
- "install.sh"
- "install.ps1"
- "scripts/test-install-completions.*"
- "scripts/build-payload.mjs"
- ".github/workflows/rust.yml"

Expand All @@ -43,6 +49,17 @@ jobs:
- name: Show toolchain
run: rustup show active-toolchain

- name: Test POSIX shell completion installation
if: runner.os != 'Windows'
working-directory: .
run: sh scripts/test-install-completions.sh

- name: Test PowerShell completion installation
if: runner.os == 'Windows'
working-directory: .
shell: pwsh
run: ./scripts/test-install-completions.ps1

- name: Setup Node and pnpm
uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4

Expand Down
35 changes: 35 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,38 @@ function Add-SpicetifyToPath {
}
}

function Add-SpicetifyCompletion {
[CmdletBinding()]
param (
[string] $ProfilePath = $PROFILE
)
begin {
Write-Host -Object 'Adding spicetify shell completion...' -NoNewline
$completion = '$env:COMPLETE = "powershell"; spicetify | Out-String | Invoke-Expression; Remove-Item Env:\COMPLETE'
}
process {
$profileDirectory = Split-Path -Parent $ProfilePath
if (-not (Test-Path -LiteralPath $profileDirectory)) {
New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null
}
if (-not (Test-Path -LiteralPath $ProfilePath)) {
New-Item -ItemType File -Path $ProfilePath -Force | Out-Null
}

$alreadyInstalled = Select-String -LiteralPath $ProfilePath -SimpleMatch $completion -Quiet
if (-not $alreadyInstalled) {
$profileContent = Get-Content -LiteralPath $ProfilePath -Raw
if ($profileContent.Length -gt 0 -and -not $profileContent.EndsWith("`n")) {
Add-Content -LiteralPath $ProfilePath -Value ([Environment]::NewLine) -NoNewline
}
Add-Content -LiteralPath $ProfilePath -Value $completion
}
}
end {
Write-Success
}
}

function Install-Spicetify {
[CmdletBinding()]
param ()
Expand Down Expand Up @@ -214,6 +246,9 @@ else {
#region Spicetify
Move-OldSpicetifyFolder
Install-Spicetify
if ($v3) {
Add-SpicetifyCompletion
}
Write-Host -Object "`nRun" -NoNewline
Write-Host -Object ' spicetify -h ' -NoNewline -ForegroundColor 'Cyan'
Write-Host -Object 'to get started'
Expand Down
67 changes: 65 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,67 @@ export PATH="\$PATH:$spicetify_install"
EOINFO
}

# BEGIN shell completion helpers
endswith_newline() {
[ "$(od -An -c "$1" | tail -1 | grep -o '.$')" = "\n" ]
[ ! -s "$1" ] || [ "$(od -An -c "$1" | tail -1 | grep -o '.$')" = "\n" ]
}

append_completion() {
shellrc=$1
completion=$2

if ! mkdir -p "$(dirname "$shellrc")" || ! touch "$shellrc"; then
log "Could not update $shellrc. Add this line manually:"
log "$completion"
return
fi

if grep -Fq "$completion" "$shellrc"; then
log "spicetify completion already set in $shellrc, continuing..."
return
fi

log "ADDING spicetify completion to $shellrc"
if ! endswith_newline "$shellrc"; then
echo >> "$shellrc"
fi
echo "$completion" >> "$shellrc"
}

install_shell_completion() {
if [ "$channel" != "v3" ]; then
return 0
fi

case ${SHELL:-} in
*zsh)
shellrc=${ZDOTDIR:-$HOME}/.zshrc
append_completion "$shellrc" "source <(COMPLETE=zsh spicetify)"
;;
*bash)
found_bashrc=0
for shellrc in "$HOME/.bashrc" "$HOME/.bash_profile"; do
if [ -f "$shellrc" ]; then
append_completion "$shellrc" "source <(COMPLETE=bash spicetify)"
found_bashrc=1
fi
done
if [ "$found_bashrc" -eq 0 ]; then
append_completion "$HOME/.bashrc" "source <(COMPLETE=bash spicetify)"
fi
;;
*fish)
shellrc=${XDG_CONFIG_HOME:-$HOME/.config}/fish/completions/spicetify.fish
append_completion "$shellrc" "COMPLETE=fish spicetify | source"
;;
*elvish)
append_completion "$HOME/.elvish/rc.elv" "eval (E:COMPLETE=elvish spicetify | slurp)"
;;
*) log "Shell completion was not configured for ${SHELL:-the current shell}." ;;
esac
}
# END shell completion helpers

check() {
path="export PATH=\$PATH:$spicetify_install"
shellrc=$HOME/$1
Expand All @@ -154,7 +211,10 @@ check() {
# Create shellrc if it doesn't exist
if ! [ -f "$shellrc" ]; then
log "CREATING $shellrc"
touch "$shellrc"
if ! mkdir -p "$(dirname "$shellrc")" || ! touch "$shellrc"; then
notfound
return
fi
fi

# Still checking again, in case touch command failed
Expand Down Expand Up @@ -183,9 +243,12 @@ case $SHELL in
[ -f "$HOME/.bash_profile" ] && check ".bash_profile"
;;
*fish) check ".config/fish/config.fish" "fish_add_path $spicetify_install" ;;
*elvish) check ".elvish/rc.elv" "set E:PATH = '$spicetify_install:'\$E:PATH" ;;
*) notfound ;;
esac

install_shell_completion

case ":$PATH:" in
*":$spicetify_install:"*) ;;
*) export PATH="$spicetify_install:$PATH" ;;
Expand Down
22 changes: 22 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ i18n-embed = { version = "0.16", features = ["fluent-system", "desktop-requester
i18n-embed-fl = "0.10"
rust-embed = { version = "8.12", features = ["interpolate-folder-path"] }
clap = { version = "4.6", features = ["derive", "wrap_help"] }
clap_complete = { version = "4.6", features = ["unstable-dynamic"] }
ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
opener = "0.8"
Expand Down
1 change: 1 addition & 0 deletions rust/crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ ProductName = "Spicetify"
spicetify = { path = "../spicetify" }
tui = { path = "../tui" }
clap = { workspace = true, features = ["derive"] }
clap_complete = { workspace = true }
anyhow = { workspace = true }
i18n-embed-fl = { workspace = true }

Expand Down
8 changes: 7 additions & 1 deletion rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use std::io::{self, Write};

use anyhow::Result;
use clap::{Parser, Subcommand};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::CompleteEnv;
use i18n_embed_fl as _;
use spicetify::commands::{Command, ConfigAction, DaemonAction, PkgAction, UpdatesAction};
use spicetify::{fl, logging};
Expand Down Expand Up @@ -171,6 +172,11 @@ impl From<CliPkgAction> for PkgAction {
}

fn main() {
// Intercepts a `COMPLETE=<shell>` invocation to generate shell completions
// and exits, otherwise falls through to normal startup.
// Must run before locale::localize() to avoid unnecessary overhead on
// every tab-completion keystroke.
CompleteEnv::with_factory(SpicetifyCli::command).complete();
spicetify::locale::localize();
if let Err(err) = run() {
eprintln!("{} {err:#}", fl!("fatal-prefix"));
Expand Down
25 changes: 25 additions & 0 deletions rust/crates/cli/tests/completions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#![allow(unused_crate_dependencies)]

use std::process::Command;

#[test]
fn completion_intercepts_arguments_for_every_supported_shell() {
for shell in ["bash", "zsh", "fish", "powershell", "elvish"] {
let output = Command::new(env!("CARGO_BIN_EXE_spicetify"))
.env("COMPLETE", shell)
.arg("--completion-smoke-test")
.output()
.expect("spicetify should run");

assert!(
output.status.success(),
"{shell} registration failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8_lossy(&output.stdout).contains("spicetify"),
"{shell} registration did not name the command"
);
assert!(output.stderr.is_empty(), "{shell} registration wrote to stderr");
}
}
29 changes: 29 additions & 0 deletions scripts/test-install-completions.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
$ErrorActionPreference = 'Stop'

$RepoRoot = Split-Path -Parent $PSScriptRoot
$Installer = Get-Content -LiteralPath "$RepoRoot\install.ps1" -Raw
$Functions = $Installer.Substring(0, $Installer.IndexOf('#region Main'))
. ([scriptblock]::Create($Functions))

$TempDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "spicetify-completion-$([guid]::NewGuid())"
$ProfilePath = Join-Path $TempDirectory 'profile.ps1'

try {
New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null
[System.IO.File]::WriteAllText($ProfilePath, '# existing config')

Add-SpicetifyCompletion -ProfilePath $ProfilePath
Add-SpicetifyCompletion -ProfilePath $ProfilePath

$completion = '$env:COMPLETE = "powershell"; spicetify | Out-String | Invoke-Expression; Remove-Item Env:\COMPLETE'
$matches = @(Select-String -LiteralPath $ProfilePath -SimpleMatch $completion)
if ($matches.Count -ne 1) {
throw "Expected one completion line in $ProfilePath, found $($matches.Count)"
}
if ((Get-Content -LiteralPath $ProfilePath -First 1) -ne '# existing config') {
throw "Completion was not separated from existing content in $ProfilePath"
}
}
finally {
Remove-Item -LiteralPath $TempDirectory -Recurse -Force -ErrorAction SilentlyContinue
}
71 changes: 71 additions & 0 deletions scripts/test-install-completions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env sh
set -eu

repo_root=$(cd "$(dirname "$0")/.." && pwd)
temp_dir=$(mktemp -d)
trap 'rm -rf "$temp_dir"' EXIT

helpers="$temp_dir/helpers.sh"
sed -n '/^# BEGIN shell completion helpers$/,/^# END shell completion helpers$/p' \
"$repo_root/install.sh" > "$helpers"

log() { :; }

# shellcheck source=/dev/null
. "$helpers"

assert_one_line() {
file=$1
expected=$2
actual=$(grep -Fxc "$expected" "$file" || true)
if [ "$actual" -ne 1 ]; then
echo "Expected one completion line in $file, found $actual" >&2
exit 1
fi
}

run_case() {
shell_name=$1
expected_file=$2
expected_line=$3

case_home="$temp_dir/$shell_name"
config_file="$case_home/$expected_file"
mkdir -p "$(dirname "$config_file")"
printf '%s' '# existing config' > "$config_file"
(
HOME=$case_home
SHELL="/bin/$shell_name"
XDG_CONFIG_HOME="$case_home/.config"
ZDOTDIR=$case_home
channel=v3
export HOME SHELL XDG_CONFIG_HOME ZDOTDIR
install_shell_completion
install_shell_completion
)

assert_one_line "$case_home/$expected_file" "$expected_line"
if [ "$(sed -n '1p' "$config_file")" != '# existing config' ]; then
echo "Completion was not separated from existing content in $config_file" >&2
exit 1
fi
}

run_case bash .bashrc 'source <(COMPLETE=bash spicetify)'
run_case zsh .zshrc 'source <(COMPLETE=zsh spicetify)'
run_case fish .config/fish/completions/spicetify.fish 'COMPLETE=fish spicetify | source'
run_case elvish .elvish/rc.elv 'eval (E:COMPLETE=elvish spicetify | slurp)'

v2_home="$temp_dir/v2"
mkdir -p "$v2_home"
(
HOME=$v2_home
SHELL=/bin/zsh
channel=v2
export HOME SHELL channel
install_shell_completion
)
if [ -e "$v2_home/.zshrc" ]; then
echo "v2 install unexpectedly configured v3 completion" >&2
exit 1
fi