diff --git a/README.md b/README.md index a21381c..9316b95 100644 --- a/README.md +++ b/README.md @@ -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) frontend/ Vite + React + TypeScript SPA 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 @@ -75,8 +75,11 @@ curl -H "Authorization: Token " \ the bundled nginx those aliases negotiate on `Accept`: browsers get the SPA page, requesters like curl/wget/fastfetch get the JSON. `/api/random/` is the unambiguous path for scripts; 404 when nothing matches the filters. -- A ready-made shell greeting that uses this endpoint lives in - [`extras/fish_greeting/`](extras/fish_greeting/README.md). +- Ready-made shell greetings that use this endpoint live in + [`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 (also `/tokens`) issues `j621r_…` tokens that only authenticate `/api/random/` — the rest of the API rejects them. They are stored as diff --git a/extras/install-windows.ps1 b/extras/install-windows.ps1 new file mode 100644 index 0000000..7f74c91 --- /dev/null +++ b/extras/install-windows.ps1 @@ -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 +# .\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 diff --git a/extras/powershell_greeting/J621Greeting.ps1 b/extras/powershell_greeting/J621Greeting.ps1 new file mode 100644 index 0000000..2bfbb13 --- /dev/null +++ b/extras/powershell_greeting/J621Greeting.ps1 @@ -0,0 +1,190 @@ +# 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) + +#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 = 'kitty-direct' } + } + $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) { + & fastfetch @ffArgs --logo-type $logoType --logo $tempFile --logo-width $logoWidth + } 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 +} diff --git a/extras/powershell_greeting/README.md b/extras/powershell_greeting/README.md new file mode 100644 index 0000000..2e24c61 --- /dev/null +++ b/extras/powershell_greeting/README.md @@ -0,0 +1,121 @@ +# 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, `kitty-direct` 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 +.\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 = '' } # 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`. If the default looks wrong, try + `J621_LOGO_TYPE=kitty`, `sixel`, or `iterm`. +- **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:///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). diff --git a/extras/powershell_greeting/config.example.ps1 b/extras/powershell_greeting/config.example.ps1 new file mode 100644 index 0000000..8e5a857 --- /dev/null +++ b/extras/powershell_greeting/config.example.ps1 @@ -0,0 +1,24 @@ +# 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 -> kitty-direct, others -> no image). +# if (-not $env:J621_LOGO_TYPE) { $env:J621_LOGO_TYPE = 'kitty-direct' } diff --git a/extras/powershell_greeting/install.ps1 b/extras/powershell_greeting/install.ps1 new file mode 100644 index 0000000..51ac1f4 --- /dev/null +++ b/extras/powershell_greeting/install.ps1 @@ -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 +# .\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 diff --git a/extras/win-gm-switch/.gitignore b/extras/win-gm-switch/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/extras/win-gm-switch/.gitignore @@ -0,0 +1 @@ +/target diff --git a/extras/win-gm-switch/Cargo.lock b/extras/win-gm-switch/Cargo.lock new file mode 100644 index 0000000..92793cc --- /dev/null +++ b/extras/win-gm-switch/Cargo.lock @@ -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" diff --git a/extras/win-gm-switch/Cargo.toml b/extras/win-gm-switch/Cargo.toml new file mode 100644 index 0000000..1c58b39 --- /dev/null +++ b/extras/win-gm-switch/Cargo.toml @@ -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 diff --git a/extras/win-gm-switch/README.md b/extras/win-gm-switch/README.md new file mode 100644 index 0000000..22485c7 --- /dev/null +++ b/extras/win-gm-switch/README.md @@ -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 ` 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. diff --git a/extras/win-gm-switch/install.ps1 b/extras/win-gm-switch/install.ps1 new file mode 100644 index 0000000..4b9ed4a --- /dev/null +++ b/extras/win-gm-switch/install.ps1 @@ -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 diff --git a/extras/win-gm-switch/src/main.rs b/extras/win-gm-switch/src/main.rs new file mode 100644 index 0000000..27e5106 --- /dev/null +++ b/extras/win-gm-switch/src/main.rs @@ -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 ] +//! gm-switch --status [--path ] + +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 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 = env::args().skip(1).collect(); + + let mut mode: Option = None; + let mut status = false; + let mut path_override: Option = 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); + } +}