Compare commits

...
2 Commits
Author SHA1 Message Date
JakeBreath 4889c5c355 extras/powershell_greeting: fix WezTerm logo stretch (iterm + preserveAspectRatio)
CI / Backend tests (push) Successful in 2m12s
CI / Frontend build & lint (push) Successful in 22s
kitty-direct sends c= but no rows through ConPTY (the terminal guesses the height and stretches); the plain kitty type needs tty pixel queries that ConPTY drops. The iterm type with preserveAspectRatio=1 letterboxes any aspect ratio.
2026-09-30 09:44:36 -05:00
JakeBreath d2f857d3ee extras: add PowerShell greeting port, Rust gm-switch and Windows installers
- powershell_greeting: PowerShell port of fish_greeting (fastfetch random logo, sixel/kitty-direct per terminal, logs, modes)

- win-gm-switch: Rust port of gm-switch.cpp (cross-platform mode file, --status, Start Menu shortcuts installer)

- install-windows.ps1 umbrella installer; README pointers
2026-09-30 09:34:01 -05:00
12 changed files with 990 additions and 3 deletions
+6 -3
View File
@@ -8,7 +8,7 @@ Self-hosted media library and e621 archive manager, rebuilt as a **React SPA + D
backend/ Django 6 + DRF API (MariaDB + Redis via docker compose) backend/ Django 6 + DRF API (MariaDB + Redis via docker compose)
frontend/ Vite + React + TypeScript SPA frontend/ Vite + React + TypeScript SPA
deploy/ Docker images, compose variants and Tailscale serve configs deploy/ Docker images, compose variants and Tailscale serve configs
extras/ shell integrations (fish_greeting with fastfetch) extras/ shell integrations (fish + PowerShell greetings with fastfetch, gm-switch)
``` ```
## Development ## Development
@@ -75,8 +75,11 @@ curl -H "Authorization: Token <token>" \
the bundled nginx those aliases negotiate on `Accept`: browsers get the SPA the bundled nginx those aliases negotiate on `Accept`: browsers get the SPA
page, requesters like curl/wget/fastfetch get the JSON. `/api/random/` is page, requesters like curl/wget/fastfetch get the JSON. `/api/random/` is
the unambiguous path for scripts; 404 when nothing matches the filters. the unambiguous path for scripts; 404 when nothing matches the filters.
- A ready-made shell greeting that uses this endpoint lives in - Ready-made shell greetings that use this endpoint live in
[`extras/fish_greeting/`](extras/fish_greeting/README.md). [`extras/fish_greeting/`](extras/fish_greeting/README.md) (Linux/fish) and
[`extras/powershell_greeting/`](extras/powershell_greeting/README.md)
(Windows/PowerShell); [`extras/win-gm-switch/`](extras/win-gm-switch/)
(Rust) switches the rating mode on both.
- **Scoped tokens for scripts**: the Account page's *Shell tokens* section - **Scoped tokens for scripts**: the Account page's *Shell tokens* section
(also `/tokens`) issues `j621r_…` tokens that only authenticate (also `/tokens`) issues `j621r_…` tokens that only authenticate
`/api/random/` — the rest of the API rejects them. They are stored as `/api/random/` — the rest of the API rejects them. They are stored as
+59
View File
@@ -0,0 +1,59 @@
# Install the J621 Windows extras (PowerShell greeting + Rust gm-switch).
#
# Usage:
# .\install-windows.ps1
# .\install-windows.ps1 -Url https://j621.example.ts.net -Token <token>
# .\install-windows.ps1 -NoPrompt
# .\install-windows.ps1 -Uninstall
#
# Options are forwarded to the component installers (see
# powershell_greeting\install.ps1 and win-gm-switch\install.ps1).
[CmdletBinding()]
param(
[string]$Url,
[string]$Token,
[switch]$NoPrompt,
[switch]$Force,
[switch]$NoProfilePatch,
[switch]$SkipGreeting,
[switch]$SkipGmSwitch,
[switch]$Uninstall
)
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
$failures = New-Object System.Collections.Generic.List[string]
if (-not $SkipGmSwitch) {
Write-Host ''
Write-Host '=== gm-switch ==='
$params = @{}
if ($Uninstall) { $params.Uninstall = $true }
& (Join-Path $here 'win-gm-switch\install.ps1') @params
if ($LASTEXITCODE -ne 0) { $failures.Add('gm-switch installer failed') }
}
if (-not $SkipGreeting) {
Write-Host ''
Write-Host '=== PowerShell greeting ==='
$params = @{}
if ($Uninstall) { $params.Uninstall = $true }
if ($Url) { $params.Url = $Url }
if ($Token) { $params.Token = $Token }
if ($NoPrompt) { $params.NoPrompt = $true }
if ($Force) { $params.Force = $true }
if ($NoProfilePatch) { $params.NoProfilePatch = $true }
& (Join-Path $here 'powershell_greeting\install.ps1') @params
if ($LASTEXITCODE -ne 0) { $failures.Add('greeting installer failed') }
}
Write-Host ''
if ($failures.Count -eq 0) {
Write-Host 'All done.'
exit 0
}
foreach ($failure in $failures) { Write-Host $failure }
exit 1
+200
View File
@@ -0,0 +1,200 @@
# J621Greeting.ps1 - show a random J621 library image as the shell greeting.
#
# PowerShell port of extras/fish_greeting/fish_greeting.fish (the J621 REST
# rewrite): asks GET /api/random/?fastfetch=1&rating=X for a random
# png/jpg/gif, downloads the signed url and runs fastfetch with the image as
# its logo. Based on the original script from
# J621-Django/extras/fish_greeting system/fish_greeting.fish.
#
# Requires: fastfetch and curl.exe (ships with Windows 10 1803+).
#
# Rating modes (same file name as the fish version):
# %APPDATA%\j621Greeting\greeting_mode 0 = NSFW (explicit)
# 1 = SFW (safe)
# 2 = Questionable (default)
#
# Configuration: %APPDATA%\j621Greeting\config.ps1 (sets environment
# variables; see config.example.ps1):
# J621_BASE API origin (default https://j621.rainbow-herring.ts.net)
# J621_TOKEN API / scoped greeting token, sent as "Authorization: Token ..."
# J621_WEB web origin used in the printed link (defaults to J621_BASE)
# J621_FASTFETCH_CONFIG fastfetch config name (default "jake")
# J621_MODE_FILE mode file override
# J621_LOGO_TYPE force a fastfetch logo type (sixel, kitty-direct, ...)
# J621_ALWAYS_FETCH=1 fetch even in terminals without image support
# J621_LOGO_WIDTH logo width in columns (default 35)
# J621_LOGO_HEIGHT iterm box height in rows (default 25)
#Requires -Version 5.1
function Get-J621FileKind {
param([string]$Path)
try {
$stream = [IO.File]::OpenRead($Path)
try {
$buffer = New-Object byte[] 8
$read = $stream.Read($buffer, 0, 8)
if ($read -ge 4 -and $buffer[0] -eq 0x89 -and $buffer[1] -eq 0x50 -and $buffer[2] -eq 0x4E -and $buffer[3] -eq 0x47) { return 'png' }
if ($read -ge 2 -and $buffer[0] -eq 0xFF -and $buffer[1] -eq 0xD8) { return 'jpeg' }
if ($read -ge 3 -and $buffer[0] -eq 0x47 -and $buffer[1] -eq 0x49 -and $buffer[2] -eq 0x46) { return 'gif' }
return 'unknown'
} finally { $stream.Dispose() }
} catch { return 'unknown' }
}
function Format-J621Accent {
param([string]$Text)
return "$([char]27)[4;2;38;2;138;0;222;49m$Text$([char]27)[0m"
}
function Format-J621Error {
param([string]$Text)
return "$([char]27)[31m$Text$([char]27)[0m"
}
function Get-J621FastfetchArgs {
$configName = if ($env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG } else { 'jake' }
$candidates = @(
(Join-Path $env:APPDATA "fastfetch\$configName.jsonc"),
(Join-Path $env:APPDATA "fastfetch\$configName\config.jsonc")
)
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) { return @('--config', $configName) }
}
return @()
}
function Show-J621Greeting {
param([int]$Depth = 0)
# --- Configuration ---
$configFile = Join-Path $env:APPDATA 'j621Greeting\config.ps1'
if (Test-Path -LiteralPath $configFile) { . $configFile }
$base = if ($env:J621_BASE) { $env:J621_BASE.TrimEnd('/') } else { 'https://j621.rainbow-herring.ts.net' }
$webBase = if ($env:J621_WEB) { $env:J621_WEB.TrimEnd('/') } else { $base }
$token = $env:J621_TOKEN
$logoWidth = if ($env:J621_LOGO_WIDTH) { $env:J621_LOGO_WIDTH } else { '35' }
$modeFile = if ($env:J621_MODE_FILE) { $env:J621_MODE_FILE } else { Join-Path $env:APPDATA 'j621Greeting\greeting_mode' }
# --- Setup logging ---
$logDir = Join-Path $env:APPDATA 'j621Logos'
$logFile = Join-Path $logDir 'logs.log'
if (-not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
# --- Mode selection ---
$mode = '2'
if (Test-Path -LiteralPath $modeFile) {
$raw = Get-Content -LiteralPath $modeFile -Raw -ErrorAction SilentlyContinue
if ($null -ne $raw) { $mode = $raw.Trim() }
}
switch ($mode) {
'0' { $rating = 'e'; $modeName = 'NSFW' }
'1' { $rating = 's'; $modeName = 'SFW' }
'2' { $rating = 'q'; $modeName = 'Questionable' }
default { Write-Output 'Unknown mode, using default NSFW'; $rating = 'e'; $modeName = 'NSFW' }
}
# --- Pick the image protocol for this terminal ---
$logoType = $env:J621_LOGO_TYPE
if (-not $logoType) {
if ($env:WT_SESSION) { $logoType = 'sixel' }
elseif ($env:WEZTERM_PANE) { $logoType = 'iterm' }
}
$fetchImage = [bool]$logoType -or ($env:J621_ALWAYS_FETCH -eq '1')
$ffArgs = @(Get-J621FastfetchArgs)
if (-not $fetchImage) {
# Terminal without image support (e.g. VS Code): plain fastfetch.
& fastfetch @ffArgs
return
}
# --- Ask the API for a random image (JSON with a signed URL) ---
$apiUrl = "$base/api/random/?fastfetch=1&rating=$rating"
$curlArgs = @('-s', '-L', '--max-time', '20')
if ($token) { $curlArgs += @('-H', "Authorization: Token $token") }
$json = & curl.exe @curlArgs $apiUrl 2>$null | Out-String
$data = $null
try { $data = $json | ConvertFrom-Json } catch { $data = $null }
$imageUrl = $null; $jId = $null; $md5 = $null; $filename = $null; $detail = $null; $ext = 'img'
if ($data) {
if ($data.url) { $imageUrl = [string]$data.url }
if ($data.j_id) { $jId = [string]$data.j_id }
if ($data.md5) { $md5 = [string]$data.md5 }
if ($data.filename) { $filename = [string]$data.filename }
if ($data.detail) { $detail = [string]$data.detail }
if ($data.extension) { $ext = [string]$data.extension }
}
if ($ext -notmatch '^[A-Za-z0-9]+$') { $ext = 'img' }
if (-not $imageUrl) {
& fastfetch @ffArgs
if ($detail) { Write-Host (Format-J621Accent "No logo ($detail)") } else { Write-Host (Format-J621Accent 'No logo (API error)') }
Add-Content -LiteralPath $logFile -Value "$stamp No logo for rating=${rating}: $detail"
return
}
# --- Download the signed image ---
$tempFile = Join-Path ([IO.Path]::GetTempPath()) ('j621_logo_' + [guid]::NewGuid().ToString('N') + '.' + $ext)
$downloadSuccess = $false
& curl.exe -s -L --max-time 60 -o $tempFile $imageUrl 2>$null
if ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $tempFile) -and (Get-Item -LiteralPath $tempFile).Length -gt 0) { $downloadSuccess = $true }
if (-not $downloadSuccess) {
& fastfetch @ffArgs
Write-Host (Format-J621Accent 'No logo (download failed)')
Add-Content -LiteralPath $logFile -Value "$stamp Download failed for $jId"
Remove-Item -LiteralPath $tempFile -ErrorAction SilentlyContinue
return
}
# --- Accept only PNG, JPEG, GIF; anything else causes one more roll ---
$kind = Get-J621FileKind -Path $tempFile
if ($kind -eq 'unknown') {
if ($Depth -lt 3) {
Show-J621Greeting -Depth ($Depth + 1)
} else {
Write-Host (Format-J621Error 'Max recursion depth reached, skipping unsupported file type: unknown')
}
if ($Depth -eq 0) {
Write-Host (Format-J621Error ("Forked (Got unsupported type: unknown - $filename)"))
if ($md5) { Write-Output "Link: $webBase/view/$md5" }
}
Remove-Item -LiteralPath $tempFile -ErrorAction SilentlyContinue
return
}
# --- Display result ---
if ($logoType) {
$logoArgs = @('--logo-type', $logoType, '--logo', $tempFile, '--logo-width', $logoWidth)
if ($logoType -eq 'iterm') {
# Box + preserveAspectRatio: never stretched, whatever the aspect ratio
# (kitty-direct cannot do this through ConPTY: its payload has no rows).
$logoHeight = if ($env:J621_LOGO_HEIGHT) { $env:J621_LOGO_HEIGHT } else { '25' }
$logoArgs += @('--logo-height', $logoHeight, '--logo-preserve-aspect-ratio', 'true')
} elseif ($env:J621_LOGO_HEIGHT) {
$logoArgs += @('--logo-height', $env:J621_LOGO_HEIGHT)
}
& fastfetch @ffArgs @logoArgs
} else {
& fastfetch @ffArgs
}
Add-Content -LiteralPath $logFile -Value "$stamp Downloaded: $jId $filename [$modeName]"
if ($filename) {
Write-Host (Format-J621Accent "Logo from API: $filename ($modeName, $jId)")
Write-Output "Link: $webBase/detail/$jId"
} else {
Write-Host (Format-J621Accent "Logo from API ($modeName)")
}
# --- Keep only the last 1000 lines of the log ---
$lines = @(Get-Content -LiteralPath $logFile -Tail 1000)
Set-Content -LiteralPath $logFile -Value $lines
# --- Cleanup ---
Remove-Item -LiteralPath $tempFile -ErrorAction SilentlyContinue
}
+123
View File
@@ -0,0 +1,123 @@
# PowerShell greeting (J621 Windows port)
Shows a random J621 library image as your shell greeting, using fastfetch.
This is the Windows/PowerShell port of [`../fish_greeting/`](../fish_greeting/) -
same API, same modes, same logs, adapted to PowerShell 5.1+ and Windows
terminals.
## What changed from the fish version
| | fish | PowerShell (this port) |
| --- | --- | --- |
| Entry point | `fish_greeting` function | `Show-J621Greeting` (loaded from your profile) |
| Endpoint | `GET /api/random/?fastfetch=1&rating=X` | same |
| Config | `~/.config/j621Greeting/config.fish` | `%APPDATA%\j621Greeting\config.ps1` |
| Mode file | `~/.config/fish/greeting_mode` | `%APPDATA%\j621Greeting\greeting_mode` (the file `gm-switch.exe` writes) |
| JSON parsing | `jq` / `python3` / grep | `ConvertFrom-Json` (built-in) |
| MIME detection | `file --mime-type` | PNG/JPEG/GIF magic bytes |
| GIFs | `gifsicle` + `kitten icat` | none: static first frame in Windows Terminal, animated in WezTerm (its `kitty-direct` plays the GIF) |
| fastfetch logo | `--logo-type kitty --logo ... --logo-width 35` | `sixel` in Windows Terminal, `iterm` (aspect-preserving box) in WezTerm, no image elsewhere |
| Logs | `~/.config/j621Logos/logs.log` | `%APPDATA%\j621Logos\logs.log` |
## Install
```powershell
cd extras\powershell_greeting
.\install.ps1
```
The installer copies `J621Greeting.ps1` into `%APPDATA%\j621Greeting\`, asks
for your API origin (and an optional scoped token), writes
`%APPDATA%\j621Greeting\config.ps1`, wires a marker block into your
PowerShell profile (it replaces the previous plain-`fastfetch` hook), checks
the tools it needs and probes the backend (`/health` plus a random roll). It
exits non-zero when the backend cannot be reached.
Non-interactive / re-install:
```powershell
.\install.ps1 -Url https://j621.example.ts.net -Token <api-token>
.\install.ps1 -NoPrompt # defaults, never asks
.\install.ps1 -Force # rewrite an existing config
.\install.ps1 -NoProfilePatch # do not touch your PowerShell profile
.\install.ps1 -Uninstall # remove the profile block + installed script
```
Test it manually any time with:
```powershell
Show-J621Greeting
```
Requirements: `fastfetch` and `curl.exe` (ships with Windows 10 1803+).
Image logos need Windows Terminal 1.22+ (sixel) or WezTerm with kitty
graphics enabled; other terminals fall back to a plain fastfetch.
## No token? That is fine
The greeting is meant to run **without** a token: it then behaves like a
guest of your instance - unsigned image links and only items that are
visible to guests. Adding a token to the config unlocks signed links and
items that are hidden from guests. The recommended value is a **scoped
greeting token**: open the app, go to *Account -> Shell tokens* (or `/tokens`),
create one and copy the `j621r_...` key - it only works with `/api/random/`,
so it is safe to keep in the config file.
## Configuration
`%APPDATA%\j621Greeting\config.ps1` is read on every run (environment
variables already set in your session win over it):
```powershell
if (-not $env:J621_BASE) { $env:J621_BASE = 'https://j621.rainbow-herring.ts.net' } # API origin
if (-not $env:J621_WEB) { $env:J621_WEB = 'https://j621.example.ts.net' } # link origin (split deploys)
if (-not $env:J621_TOKEN) { $env:J621_TOKEN = '<api token>' } # signed URLs + hidden items
if (-not $env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG = 'jake' } # fastfetch config name
```
Extra escapes (all optional):
- `J621_LOGO_TYPE` - force a fastfetch logo type (`sixel`, `kitty-direct`, ...)
- `J621_ALWAYS_FETCH=1` - fetch even in terminals without image support (prints the link, ASCII logo)
- `J621_LOGO_WIDTH` - logo width in columns (default 35)
- `J621_MODE_FILE` - mode file override (shared with `gm-switch`)
## Rating filters
| `greeting_mode` | Rating requested | Label |
| --- | --- | --- |
| `0` | `e` | NSFW |
| `1` | `s` | SFW |
| `2` (default) | `q` | Questionable |
The mode file is `%APPDATA%\j621Greeting\greeting_mode`. Use
[`../win-gm-switch/`](../win-gm-switch/) (or its Start Menu shortcuts) to
switch, or just edit the file.
## Troubleshooting
- **No image logo in Windows Terminal**: WT 1.22+ is required for sixel;
update with `winget upgrade Microsoft.WindowsTerminal`.
- **WezTerm**: kitty graphics must be on - `config.enable_kitty_graphics = true`
in `~/.wezterm.lua`. The default uses the `iterm` logo type with
`preserveAspectRatio`, which never stretches. If something looks off, try
`J621_LOGO_TYPE=kitty-direct`, `kitty`, or `sixel` (note: through ConPTY,
plain `kitty` falls back to ASCII and `kitty-direct` stretches tall images).
- **VS Code / other terminals**: no image protocol, so the greeting prints a
plain fastfetch on purpose (no fetch delay). `J621_ALWAYS_FETCH=1` still
fetches and prints the link.
- `No logo (No image matches those filters.)` - the library has no images
for that rating; try another mode or add files.
- `No logo (API error)` - check `J621_BASE` and that the deployment is up
(`https://<host>/health`).
- `No logo (download failed)` - the signed URL expired (they last 24 h) or
the item was deleted between the two requests; just run it again.
- **The greeting is slow** - the API and image fetch have `--max-time`
guards; a slow tailnet link is usually the cause.
- Log file: `%APPDATA%\j621Logos\logs.log` (only the last 1000 lines are kept).
## gm-switch
The Rust [`../win-gm-switch/`](../win-gm-switch/) tool writes the same mode
file, and on Linux it can replace the old C++ `gm-switch` (it writes
`~/.config/fish/greeting_mode` there, the same file the fish greeting reads).
@@ -0,0 +1,26 @@
# Copy to %APPDATA%\j621Greeting\config.ps1 and adjust.
# This file runs every time the greeting starts; an environment variable that
# is already set in your session wins over the values here.
# API origin (no trailing slash needed).
if (-not $env:J621_BASE) { $env:J621_BASE = 'https://j621.rainbow-herring.ts.net' }
# Web origin used in the printed link. Only needed when the SPA is served
# from a different host than the API (split deployment).
# if (-not $env:J621_WEB) { $env:J621_WEB = 'https://j621-frontend.rainbow-herring.ts.net' }
# API token. Optional: gives you signed image URLs and access to items that
# are hidden from guests. The recommended value is a scoped greeting token
# (Account -> Shell tokens, or /tokens in the app): those only work with
# /api/random/, so they are safe to keep in this file. The full API token
# works too, but grants everything.
# if (-not $env:J621_TOKEN) { $env:J621_TOKEN = 'j621r_paste-your-token-here' }
# fastfetch config name used for the greeting logo.
# if (-not $env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG = 'jake' }
# Force a fastfetch logo type instead of the per-terminal default
# (Windows Terminal -> sixel, WezTerm -> iterm with preserveAspectRatio,
# others -> no image).
# if (-not $env:J621_LOGO_TYPE) { $env:J621_LOGO_TYPE = 'iterm' }
# if (-not $env:J621_LOGO_HEIGHT) { $env:J621_LOGO_HEIGHT = '25' }
+211
View File
@@ -0,0 +1,211 @@
# Install the J621 PowerShell greeting:
# * copies J621Greeting.ps1 into %APPDATA%\j621Greeting\
# * writes %APPDATA%\j621Greeting\config.ps1 (asks for the API origin and an
# optional token, or takes them as parameters)
# * wires the greeting into your PowerShell profile (marker block, once)
# * checks the tools it needs and probes the configured backend
#
# Usage:
# .\install.ps1
# .\install.ps1 -Url https://j621.example.ts.net -Token <api-token>
# .\install.ps1 -NoPrompt # never ask, keep/derive defaults
# .\install.ps1 -Force # rewrite an existing config
# .\install.ps1 -NoProfilePatch # do not touch your PowerShell profile
# .\install.ps1 -Uninstall # remove the profile block + installed script
[CmdletBinding()]
param(
[string]$Url,
[string]$Token,
[switch]$NoPrompt,
[switch]$Force,
[switch]$NoProfilePatch,
[switch]$Uninstall
)
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
$configDir = Join-Path $env:APPDATA 'j621Greeting'
$configFile = Join-Path $configDir 'config.ps1'
$installedScript = Join-Path $configDir 'J621Greeting.ps1'
$defaultUrl = 'https://j621.rainbow-herring.ts.net'
$markerBegin = '# >>> J621 greeting >>>'
$markerEnd = '# <<< J621 greeting <<<'
$profilePath = $PROFILE
function Get-InstalledConfigValue {
param([string]$Name)
if (-not (Test-Path -LiteralPath $configFile)) { return $null }
$content = Get-Content -LiteralPath $configFile -Raw
$rx = '\$env:' + $Name + ' = ''([^'']*)'''
if ($content -match $rx) { return $Matches[1] }
return $null
}
function Remove-ProfileBlock {
if (-not (Test-Path -LiteralPath $profilePath)) { return }
$lines = @(Get-Content -LiteralPath $profilePath)
if (-not ($lines -contains $markerBegin)) { return }
$out = New-Object System.Collections.Generic.List[string]
$inside = $false
foreach ($line in $lines) {
if ($line.Trim() -eq $markerBegin) { $inside = $true; continue }
if ($line.Trim() -eq $markerEnd) { $inside = $false; continue }
if (-not $inside) { $out.Add($line) }
}
while ($out.Count -gt 0 -and [string]::IsNullOrWhiteSpace($out[$out.Count - 1])) { $out.RemoveAt($out.Count - 1) }
Set-Content -LiteralPath $profilePath -Value ($out -join "`r`n") -Encoding ASCII
}
# --- Uninstall -----------------------------------------------------------------
if ($Uninstall) {
Remove-ProfileBlock
if (Test-Path -LiteralPath $installedScript) { Remove-Item -LiteralPath $installedScript -Force }
Write-Host "Removed the J621 greeting from the profile and $installedScript"
Write-Host "Your config and logs were left in place: $configDir"
exit 0
}
# --- Install the function ------------------------------------------------------
New-Item -ItemType Directory -Path $configDir -Force | Out-Null
$source = Join-Path $here 'J621Greeting.ps1'
if (-not (Test-Path -LiteralPath $source)) { throw "J621Greeting.ps1 not found next to this installer ($source)" }
Copy-Item -LiteralPath $source -Destination $installedScript -Force
Write-Host "Installed $installedScript"
# --- Configuration -------------------------------------------------------------
$writeConfig = $false
if (-not (Test-Path -LiteralPath $configFile)) {
$writeConfig = $true
} elseif ($Force -or $PSBoundParameters.ContainsKey('Url')) {
$writeConfig = $true
} else {
Write-Host "Keeping existing $configFile (use -Force to rewrite it)"
$current = Get-InstalledConfigValue -Name 'J621_BASE'
if ($current) { $Url = $current }
if (-not $Token) { $Token = Get-InstalledConfigValue -Name 'J621_TOKEN' }
}
if ($writeConfig) {
if (-not $NoPrompt) {
if (-not $Url) {
$answer = Read-Host "API origin [$defaultUrl]"
if ($answer) { $Url = $answer }
}
if (-not $Token) {
$answer = Read-Host 'API token (optional, Enter to run as a guest)'
if ($answer) { $Token = $answer }
}
}
if (-not $Url) { $Url = $defaultUrl }
$Url = $Url.Trim().TrimEnd('/').Replace("'", "")
if ($Token) { $Token = $Token.Trim().Replace("'", "") }
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('# J621 greeting configuration - written by install.ps1 on ' + (Get-Date -Format 'yyyy-MM-dd'))
$lines.Add('# Values here are defaults; an environment variable already set in your')
$lines.Add('# session wins over them.')
$lines.Add('if (-not $env:J621_BASE) { $env:J621_BASE = ''' + $Url + ''' }')
if ($Token) {
$lines.Add('if (-not $env:J621_TOKEN) { $env:J621_TOKEN = ''' + $Token + ''' }')
} else {
$lines.Add('# if (-not $env:J621_TOKEN) { $env:J621_TOKEN = ''paste-a-token-here'' }')
}
$lines.Add('# if (-not $env:J621_WEB) { $env:J621_WEB = ''' + $Url + ''' }')
$lines.Add('# if (-not $env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG = ''jake'' }')
Set-Content -LiteralPath $configFile -Value $lines -Encoding ASCII
Write-Host "Wrote $configFile"
}
# --- Profile -------------------------------------------------------------------
if (-not $NoProfilePatch) {
$existing = ''
if (Test-Path -LiteralPath $profilePath) { $existing = Get-Content -LiteralPath $profilePath -Raw }
if ($existing -match [regex]::Escape($markerBegin)) {
Write-Host 'Profile already sources the greeting (marker present)'
} else {
$normalized = $existing -replace "`r`n", "`n"
$legacy = @(
'# Fastfetch greeting on interactive PowerShell start',
'if ([Environment]::UserInteractive -and -not [Console]::IsOutputRedirected) {',
' fastfetch',
'}'
) -join "`n"
if ($normalized.Contains($legacy)) {
$normalized = $normalized.Replace($legacy, '').TrimEnd() + "`n"
Write-Host 'Replaced the plain fastfetch block in your profile'
}
$block = @(
$markerBegin,
'# Random J621 library image as the fastfetch logo (extras/powershell_greeting).',
'$j621GreetingScript = "$env:APPDATA\j621Greeting\J621Greeting.ps1"',
'if ((Test-Path -LiteralPath $j621GreetingScript) -and [Environment]::UserInteractive -and -not [Console]::IsOutputRedirected) {',
' . $j621GreetingScript',
' Show-J621Greeting',
'}',
$markerEnd
) -join "`r`n"
$profileDir = Split-Path -Parent $profilePath
if (-not (Test-Path -LiteralPath $profileDir)) { New-Item -ItemType Directory -Path $profileDir -Force | Out-Null }
Set-Content -LiteralPath $profilePath -Value ($normalized.TrimEnd() + "`r`n`r`n" + $block + "`r`n") -Encoding ASCII
Write-Host "Added the J621 greeting to $profilePath"
}
}
# --- Dependencies --------------------------------------------------------------
$problems = 0
foreach ($tool in 'fastfetch', 'curl.exe') {
if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) {
$problems = 1
Write-Host "Missing required tool: $tool"
}
}
if (-not ($env:WT_SESSION -or $env:WEZTERM_PANE)) {
Write-Host 'Note: image logos need Windows Terminal 1.22+ (sixel) or WezTerm (kitty graphics);'
Write-Host 'other terminals fall back to a plain fastfetch.'
}
# --- Probe the configured backend -----------------------------------------------
Write-Host ''
$probeUrl = $Url
$probeToken = $Token
if (-not $probeUrl) { $probeUrl = $defaultUrl }
Write-Host "Checking $probeUrl ..."
$health = & curl.exe -s -m 10 ($probeUrl + '/health') 2>$null | Out-String
if ($health -match '"status"\s*:\s*"ok"') {
Write-Host ' backend: ok'
$probeArgs = @('-s', '-m', '10')
if ($probeToken) { $probeArgs += @('-H', "Authorization: Token $probeToken") }
$probe = & curl.exe @probeArgs ($probeUrl + '/api/random/?fastfetch=1') 2>$null | Out-String
$detail = $null
try {
$parsed = $probe | ConvertFrom-Json
if ($parsed.detail) { $detail = [string]$parsed.detail }
} catch { }
if ($detail) {
Write-Host " random: $detail"
if (-not $probeToken) { Write-Host ' (a token would also let you see items that are hidden from guests)' }
} else {
Write-Host ' random: ok'
}
} else {
$problems = 1
Write-Host " backend did not answer at $probeUrl/health"
Write-Host " got: $health"
Write-Host " Fix J621_BASE in $configFile (or pass -Url) and run again."
}
# --- Wrap up --------------------------------------------------------------------
Write-Host ''
if ($problems -eq 0) {
Write-Host 'Done. Test it now with: Show-J621Greeting'
} else {
Write-Host 'Finished with problems above; the greeting will report them too.'
}
Write-Host ('Rating mode lives in ' + (Join-Path $configDir 'greeting_mode') + ' (0=NSFW, 1=SFW, 2=Questionable).')
Write-Host 'gm-switch.exe and its Start Menu shortcuts write that same file.'
exit $problems
+1
View File
@@ -0,0 +1 @@
/target
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "gm-switch"
version = "0.1.0"
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "gm-switch"
version = "0.1.0"
edition = "2021"
description = "Set the J621 shell greeting rating mode (NSFW / SFW / Questionable)"
publish = false
[profile.release]
strip = true
lto = true
+54
View File
@@ -0,0 +1,54 @@
# win-gm-switch (Rust)
Rust port of the old `gm-switch.cpp` from
`J621-Django/extras/fish_greeting system/`: a tiny CLI that sets the J621
shell greeting rating mode (0 = NSFW, 1 = SFW, 2 = Questionable).
## Usage
```
gm-switch --mode 0 # NSFW (explicit)
gm-switch --mode 1 # SFW (safe)
gm-switch --mode 2 # Questionable
gm-switch --status # print the current mode
```
`--path <file>` overrides the mode file (also via the `J621_MODE_FILE`
environment variable). Invalid input prints the same usage/errors as the C++
tool and exits 1.
Mode file locations:
- **Windows**: `%APPDATA%\j621Greeting\greeting_mode` - read by
[`../powershell_greeting/`](../powershell_greeting/).
- **Linux/macOS**: `~/.config/fish/greeting_mode` - read by
[`../fish_greeting/`](../fish_greeting/), so this binary can stand in for
the C++ tool there.
## Install (Windows)
```powershell
cd extras\win-gm-switch
.\install.ps1
```
The installer builds with `cargo build --release`, installs `gm-switch.exe`
to `%LOCALAPPDATA%\J621\bin`, adds that folder to your user PATH (new
terminals pick it up) and creates Start Menu shortcuts under **J621
Greeting** (NSFW / SFW / Questionable). Each shortcut opens a small console
showing the confirmation; close it with `exit` or the window button.
Uninstall:
```powershell
.\install.ps1 -Uninstall
```
## Build and test without installing
```powershell
cargo build --release # -> target\release\gm-switch.exe
cargo test
```
Zero runtime dependencies (std only). The `target\` folder is gitignored.
+105
View File
@@ -0,0 +1,105 @@
# Build and install gm-switch:
# * cargo build --release (needs the Rust toolchain on PATH)
# * copies gm-switch.exe to %LOCALAPPDATA%\J621\bin
# * adds that folder to your user PATH (if missing)
# * creates Start Menu shortcuts for each mode under "J621 Greeting"
#
# Usage:
# .\install.ps1
# .\install.ps1 -Uninstall
[CmdletBinding()]
param(
[switch]$Uninstall
)
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
$binDir = Join-Path $env:LOCALAPPDATA 'J621\bin'
$exe = Join-Path $binDir 'gm-switch.exe'
$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\J621 Greeting'
if ($Uninstall) {
if (Test-Path -LiteralPath $startMenuDir) {
Remove-Item -LiteralPath $startMenuDir -Recurse -Force
Write-Host "Removed $startMenuDir"
}
if (Test-Path -LiteralPath $exe) {
Remove-Item -LiteralPath $exe -Force
Write-Host "Removed $exe"
}
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath) {
$parts = @($userPath -split ';' | Where-Object { $_ -and $_ -ne $binDir })
[Environment]::SetEnvironmentVariable('Path', ($parts -join ';'), 'User')
Write-Host "Removed $binDir from your user PATH"
}
exit 0
}
# --- Build ---------------------------------------------------------------------
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Write-Host 'cargo was not found on PATH - install the Rust toolchain (https://rustup.rs) first.'
exit 1
}
Push-Location $here
try {
Write-Host 'Building gm-switch (cargo build --release) ...'
# cargo writes progress to stderr; keep that from tripping $ErrorActionPreference.
$previousPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
cargo build --release 2>&1 | Write-Host
$buildExit = $LASTEXITCODE
$ErrorActionPreference = $previousPreference
if ($buildExit -ne 0) {
Write-Host 'cargo build failed.'
exit 1
}
} finally {
Pop-Location
}
$built = Join-Path $here 'target\release\gm-switch.exe'
if (-not (Test-Path -LiteralPath $built)) {
Write-Host "Build output not found: $built"
exit 1
}
# --- Install -------------------------------------------------------------------
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
Copy-Item -LiteralPath $built -Destination $exe -Force
Write-Host "Installed $exe"
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath) { $userPath = '' }
if (-not (($userPath -split ';') -contains $binDir)) {
$newPath = ($userPath.TrimEnd(';') + ';' + $binDir).TrimStart(';')
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Host "Added $binDir to your user PATH (open a new terminal to pick it up)"
}
# --- Shortcuts ------------------------------------------------------------------
New-Item -ItemType Directory -Path $startMenuDir -Force | Out-Null
$modes = @(
@{ Value = '0'; Name = 'NSFW' },
@{ Value = '1'; Name = 'SFW' },
@{ Value = '2'; Name = 'Questionable' }
)
$shell = New-Object -ComObject WScript.Shell
foreach ($m in $modes) {
$lnkPath = Join-Path $startMenuDir ('J621 Greeting - ' + $m.Name + '.lnk')
$shortcut = $shell.CreateShortcut($lnkPath)
$shortcut.TargetPath = "$env:SystemRoot\System32\cmd.exe"
$shortcut.Arguments = '/k ""' + $exe + '" --mode ' + $m.Value + '"'
$shortcut.WorkingDirectory = $env:USERPROFILE
$shortcut.Description = 'Set the J621 shell greeting to ' + $m.Name + ' mode'
$shortcut.Save()
Write-Host ('Created shortcut: ' + $lnkPath)
}
Write-Host ''
Write-Host 'Done. Try: gm-switch --status'
Write-Host 'The Start Menu shortcuts open a small console showing the confirmation.'
exit 0
+188
View File
@@ -0,0 +1,188 @@
//! gm-switch - set the J621 shell greeting rating mode.
//!
//! Rust port of the old gm-switch.cpp (J621-Django/extras/fish_greeting
//! system). On Windows it writes the mode file used by the PowerShell
//! greeting (%APPDATA%\j621Greeting\greeting_mode); on Linux/macOS it writes
//! ~/.config/fish/greeting_mode, so it can stand in for the C++ tool there.
//!
//! Usage:
//! gm-switch --mode <0|1|2> [--path <file>]
//! gm-switch --status [--path <file>]
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::exit;
const USAGE: &str = "\
Usage: gm-switch --mode <0|1|2>
--mode 0 Set to NSFW mode (explicit)
--mode 1 Set to SFW mode (safe)
--mode 2 Set to Questionable mode
--status Print the current greeting mode
--path <file> Override the mode file location
";
fn mode_name(mode: &str) -> Option<&'static str> {
match mode {
"0" => Some("NSFW"),
"1" => Some("SFW"),
"2" => Some("Questionable"),
_ => None,
}
}
fn default_mode_file() -> PathBuf {
if let Ok(path) = env::var("J621_MODE_FILE") {
if !path.is_empty() {
return PathBuf::from(path);
}
}
#[cfg(windows)]
{
if let Ok(appdata) = env::var("APPDATA") {
return Path::new(&appdata).join("j621Greeting").join("greeting_mode");
}
}
#[cfg(not(windows))]
{
if let Ok(home) = env::var("HOME") {
return Path::new(&home)
.join(".config")
.join("fish")
.join("greeting_mode");
}
}
PathBuf::from("greeting_mode")
}
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
let mut mode: Option<String> = None;
let mut status = false;
let mut path_override: Option<String> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--mode" => {
if i + 1 < args.len() {
mode = Some(args[i + 1].clone());
i += 2;
} else {
eprintln!("Error: --mode requires a value");
print!("{USAGE}");
exit(1);
}
}
"--status" => {
status = true;
i += 1;
}
"--path" => {
if i + 1 < args.len() {
path_override = Some(args[i + 1].clone());
i += 2;
} else {
eprintln!("Error: --path requires a value");
print!("{USAGE}");
exit(1);
}
}
"-h" | "--help" => {
print!("{USAGE}");
exit(0);
}
other => {
eprintln!("Error: unknown argument: {other}");
print!("{USAGE}");
exit(1);
}
}
}
let mode_file = match &path_override {
Some(p) => PathBuf::from(p),
None => default_mode_file(),
};
if status {
match fs::read_to_string(&mode_file) {
Ok(content) => {
let value = content.trim().to_string();
match mode_name(&value) {
Some(name) => println!("Current greeting mode: {name} ({value})"),
None => println!(
"Current greeting mode: {value} (unrecognized - the greeting falls back to NSFW)"
),
}
}
Err(_) => {
println!("Current greeting mode: 2 (Questionable, default - no mode file yet)")
}
}
return;
}
let value = match mode {
Some(v) => v,
None => {
print!("{USAGE}");
exit(1);
}
};
let name = match mode_name(&value) {
Some(name) => name,
None => {
eprintln!("Error: Mode must be 0, 1, or 2");
print!("{USAGE}");
exit(1);
}
};
if let Some(parent) = mode_file.parent() {
if !parent.as_os_str().is_empty() {
if let Err(err) = fs::create_dir_all(parent) {
eprintln!("Error: {err}");
exit(1);
}
}
}
if let Err(err) = fs::write(&mode_file, &value) {
eprintln!(
"Error: Could not open file for writing: {}",
mode_file.display()
);
eprintln!("Error: {err}");
exit(1);
}
println!("Successfully set greeting mode to: {name}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_modes_to_names() {
assert_eq!(mode_name("0"), Some("NSFW"));
assert_eq!(mode_name("1"), Some("SFW"));
assert_eq!(mode_name("2"), Some("Questionable"));
assert_eq!(mode_name("x"), None);
}
#[test]
fn writes_and_reads_mode_file() {
let dir = env::temp_dir().join("gm-switch-test");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let file = dir.join("greeting_mode");
fs::write(&file, "1").unwrap();
assert_eq!(fs::read_to_string(&file).unwrap().trim(), "1");
let _ = fs::remove_dir_all(&dir);
}
}