diff --git a/Makefile b/Makefile index 865d1a5..3849bee 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ BUILD_DIR=build BUILD_DIR_MACOS=$(BUILD_DIR)/macos BUILD_DIR_LINUX=$(BUILD_DIR)/linux +BUILD_DIR_WINDOWS=$(BUILD_DIR)/windows DIST_PATH=$(BUILD_DIR)/dist BUNDLE_PATH=$(BUILD_DIR)/bundle.js @@ -15,11 +16,15 @@ EXE_PATH_MACOS=$(BUILD_DIR_MACOS)/$(EXE_NAME) SERVER_PATH_LINUX=$(BUILD_DIR_LINUX)/$(SERVER_NAME) EXE_PATH_LINUX=$(BUILD_DIR_LINUX)/$(EXE_NAME) +SERVER_PATH_WINDOWS=$(BUILD_DIR_WINDOWS)/$(SERVER_NAME).exe +EXE_PATH_WINDOWS_PS1=$(BUILD_DIR_WINDOWS)/$(EXE_NAME).ps1 +EXE_PATH_WINDOWS_CMD=$(BUILD_DIR_WINDOWS)/$(EXE_NAME).cmd + VIV_VERSION ?= $(shell git describe --tags --always --dirty) .PHONY: instruct-build instruct-build: - @ echo 'Please run `make macos` or `make linux` to build the project' + @ echo 'Please run `make macos`, `make linux`, or `make windows` to build the project' # ------------------------------------------------------------------------------ # MARK: platform-independent build items --------------------------------------- @@ -88,6 +93,35 @@ $(EXE_PATH_LINUX): viv mkdir -p $(BUILD_DIR_LINUX) cp viv $(EXE_PATH_LINUX) +# ------------------------------------------------------------------------------ +# MARK: windows ---------------------------------------------------------------- + +.PHONY: windows +windows: $(SERVER_PATH_WINDOWS) $(EXE_PATH_WINDOWS_PS1) $(EXE_PATH_WINDOWS_CMD) + +$(SERVER_PATH_WINDOWS): $(BUNDLE_PATH) sea-config.json + if not exist $(BUILD_DIR_WINDOWS) mkdir $(BUILD_DIR_WINDOWS) + if exist $(SERVER_PATH_WINDOWS) del /f $(SERVER_PATH_WINDOWS) + node --experimental-sea-config sea-config.json + copy "$(shell where node)" $(SERVER_PATH_WINDOWS) + node_modules\.bin\postject $(SERVER_PATH_WINDOWS) NODE_SEA_BLOB $(BUILD_DIR)\sea-prep.blob ^ + --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 + @echo Windows build complete: $(SERVER_PATH_WINDOWS) + +$(EXE_PATH_WINDOWS_PS1): viv.ps1 + if not exist $(BUILD_DIR_WINDOWS) mkdir $(BUILD_DIR_WINDOWS) + copy viv.ps1 $(EXE_PATH_WINDOWS_PS1) + +$(EXE_PATH_WINDOWS_CMD): viv.cmd + if not exist $(BUILD_DIR_WINDOWS) mkdir $(BUILD_DIR_WINDOWS) + copy viv.cmd $(EXE_PATH_WINDOWS_CMD) + +# Windows build using PowerShell (alternative method) +.PHONY: windows-ps +windows-ps: + @echo Building for Windows using PowerShell... + @powershell -ExecutionPolicy Bypass -File scripts/build-windows.ps1 + # ------------------------------------------------------------------------------ # MARK: configured installation ------------------------------------------------ diff --git a/README.md b/README.md index 2955ab2..60cafbb 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ features! - [produce nice looking PDFs](docs/pdfs.md) from Markdown - automatic parsing of front matter allowing for [custom usage](docs/front-matter.md) - +- **Windows support** - works natively on Windows, macOS, and Linux + If you need any additional features, feel free to [open an issue](https://github.com/jannis-baum/vivify/issues/new/choose) or [contribute](docs/CONTRIBUTING.md)! @@ -97,23 +98,58 @@ directory as an argument! See below for installation options. ### Manual +#### macOS and Linux + - download & unpack the [latest release](https://github.com/jannis-baum/vivify/releases) for your system (macOS or Linux) - add the two executables to your `$PATH` +#### Windows + +- download & unpack the [latest + release](https://github.com/jannis-baum/vivify/releases) for Windows +- add the folder containing `vivify-server.exe` and `viv.ps1`/`viv.cmd` to your `PATH` +- use PowerShell: `viv.ps1 myfile.md` or Command Prompt: `viv myfile.md` + +> [!TIP] +> For PowerShell, you can create an alias: `Set-Alias viv "C:\path\to\viv.ps1"` + ### Compile yourself +#### macOS and Linux + - make sure you have [`yarn`](https://yarnpkg.com), `make` and `zip` installed - clone the repository - run `yarn` - run `./configure ` - run `make install` +#### Windows + +- make sure you have [Node.js](https://nodejs.org/) (v20+) and npm installed +- clone the repository +- run `npm install` +- run the build script: `powershell -ExecutionPolicy Bypass -File scripts\build-windows.ps1` +- the executables will be in `build\windows\` +- add the `build\windows\` folder to your PATH or copy the files to a location in your PATH + > [!TIP] > If you are having trouble building Vivify, or you'd like more detailed build > instructions, see our [CONTRIBUTING](docs/CONTRIBUTING.md) page +## Configuration on Windows + +Vivify looks for configuration files in the following locations (in order): + +1. `%APPDATA%\vivify\config.json` +2. `%USERPROFILE%\.config\vivify\config.json` +3. `%USERPROFILE%\.config\vivify.json` +4. `%USERPROFILE%\.vivify\config.json` +5. `%USERPROFILE%\.vivify.json` + +See [customization](docs/customization.md) for available options. + ## Get help Is something not working or do you have any questions? [Start a diff --git a/build/windows/viv-debug.cmd b/build/windows/viv-debug.cmd new file mode 100644 index 0000000..ad3cdf5 --- /dev/null +++ b/build/windows/viv-debug.cmd @@ -0,0 +1,44 @@ +@echo off +setlocal EnableDelayedExpansion + +rem Debug launcher for Vivify using ts-node (runs src directly) +rem Logs WebSocket events to %TEMP%\vivify-server.log + +set "INSTALL_DIR=%~dp0..\..\" +set "LOG_PATH=%TEMP%\vivify-server.log" +set "OUT_LOG=%TEMP%\vivify-server.out.log" +set "ERR_LOG=%TEMP%\vivify-server.err.log" +set "TS_NODE_BIN=%INSTALL_DIR%node_modules\ts-node\dist\bin-esm.js" + +echo [%DATE% %TIME%] viv-debug starting > "%LOG_PATH%" + +where node >nul 2>&1 +if errorlevel 1 ( + echo [%DATE% %TIME%] ERROR: node.exe not found in PATH >> "%LOG_PATH%" + exit /b 1 +) + +if not exist "%INSTALL_DIR%src\app.ts" ( + echo [%DATE% %TIME%] ERROR: src\app.ts not found in %INSTALL_DIR% >> "%LOG_PATH%" + exit /b 1 +) + +if not exist "%TS_NODE_BIN%" ( + echo [%DATE% %TIME%] ERROR: ts-node bin not found at %TS_NODE_BIN% >> "%LOG_PATH%" + echo [%DATE% %TIME%] Run: yarn install (or npm install) in %INSTALL_DIR% >> "%LOG_PATH%" + exit /b 1 +) + +set "VIV_LOG_PATH=%LOG_PATH%" +set "VIV_TIMEOUT=0" +set "NODE_ENV=development" + +pushd "%INSTALL_DIR%" + +rem Run server from source (ts-node CLI) and capture stdout/stderr +start /b "" node "%TS_NODE_BIN%" "%INSTALL_DIR%src\app.ts" %* > "%OUT_LOG%" 2> "%ERR_LOG%" + +echo [%DATE% %TIME%] viv-debug launched ts-node server >> "%LOG_PATH%" + +popd +endlocal diff --git a/package.json b/package.json index 74d8775..bb335ea 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "author": "Jannis Baum", "scripts": { "dev": "VIV_TIMEOUT=0 VIV_PORT=3000 NODE_ENV=development nodemon --ignore tests/rendering/symlinks --exec node --loader ts-node/esm src/app.ts", + "dev:win": "cross-env VIV_TIMEOUT=0 VIV_PORT=3000 NODE_ENV=development nodemon --ignore tests/rendering/symlinks --exec node --loader ts-node/esm src/app.ts", "viv": "VIV_PORT=3000 node --loader ts-node/esm src/app.ts", + "viv:win": "cross-env VIV_PORT=3000 node --loader ts-node/esm src/app.ts", "lint": "eslint src static", "lint-markdown": "markdownlint-cli2 --config .github/.markdownlint-cli2.yaml", "test": "node --loader ts-node/esm --test tests/unit/cli.ts tests/unit/alerts.ts", @@ -59,6 +61,7 @@ "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.52.0", "@typescript-eslint/parser": "^8.52.0", + "cross-env": "^7.0.3", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 0000000..1600361 --- /dev/null +++ b/scripts/build-windows.ps1 @@ -0,0 +1,135 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Build Vivify for Windows. + +.DESCRIPTION + This script builds the vivify-server executable for Windows using Node.js + Single Executable Applications (SEA) feature. + +.PARAMETER Clean + Clean the build directory before building. + +.EXAMPLE + .\scripts\build-windows.ps1 + Builds Vivify for Windows. + +.EXAMPLE + .\scripts\build-windows.ps1 -Clean + Cleans and rebuilds Vivify for Windows. +#> + +[CmdletBinding()] +param( + [switch]$Clean +) + +$ErrorActionPreference = "Stop" + +# Configuration +$BuildDir = "build" +$WindowsBuildDir = "$BuildDir\windows" +$ServerName = "vivify-server.exe" +$BundlePath = "$BuildDir\bundle.js" +$StaticPath = "$BuildDir\static.zip" +$SeaConfigPath = "sea-config.json" +$SeaBlobPath = "$BuildDir\sea-prep.blob" + +function Write-Step { + param([string]$Message) + Write-Host "`n[$([DateTime]::Now.ToString('HH:mm:ss'))] $Message" -ForegroundColor Cyan +} + +function Test-Command { + param([string]$Command) + $null = Get-Command $Command -ErrorAction SilentlyContinue + return $? +} + +# Check prerequisites +Write-Step "Checking prerequisites..." + +if (-not (Test-Command "node")) { + Write-Error "Node.js is not installed or not in PATH" + exit 1 +} + +if (-not (Test-Command "npm")) { + Write-Error "npm is not installed or not in PATH" + exit 1 +} + +$nodeVersion = node --version +Write-Host " Node.js version: $nodeVersion" + +# Check if Node.js version supports SEA (>= 20.0.0) +$versionMatch = $nodeVersion -match 'v(\d+)' +if ($matches[1] -lt 20) { + Write-Warning "Node.js 20+ is recommended for SEA support. Current: $nodeVersion" +} + +# Clean if requested +if ($Clean) { + Write-Step "Cleaning build directory..." + if (Test-Path $BuildDir) { + Remove-Item -Recurse -Force $BuildDir + } +} + +# Create build directories +Write-Step "Creating build directories..." +New-Item -ItemType Directory -Force -Path $WindowsBuildDir | Out-Null + +# Install dependencies if needed +if (-not (Test-Path "node_modules")) { + Write-Step "Installing dependencies..." + npm install +} + +# Build TypeScript +Write-Step "Compiling TypeScript..." +npx tsc + +# Create static.zip +Write-Step "Creating static.zip..." +if (Test-Path $StaticPath) { + Remove-Item $StaticPath +} +Compress-Archive -Path "static\*" -DestinationPath $StaticPath + +# Build with webpack +Write-Step "Building with webpack..." +$env:VIV_VERSION = git describe --tags --always --dirty 2>$null +if (-not $env:VIV_VERSION) { + $env:VIV_VERSION = "dev" +} +npx webpack + +# Generate SEA blob +Write-Step "Generating SEA blob..." +node --experimental-sea-config $SeaConfigPath + +# Copy node.exe +Write-Step "Creating executable..." +$nodeExe = (Get-Command node).Source +$serverPath = "$WindowsBuildDir\$ServerName" +Copy-Item $nodeExe $serverPath -Force + +# Inject SEA blob +Write-Step "Injecting SEA blob..." +npx postject $serverPath NODE_SEA_BLOB $SeaBlobPath ` + --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 + +# Copy scripts +Write-Step "Copying launcher scripts..." +Copy-Item "viv.ps1" "$WindowsBuildDir\viv.ps1" -Force +Copy-Item "viv.cmd" "$WindowsBuildDir\viv.cmd" -Force + +Write-Host "`n" -NoNewline +Write-Host "Build complete!" -ForegroundColor Green +Write-Host "Output directory: $WindowsBuildDir" +Write-Host "Files:" +Get-ChildItem $WindowsBuildDir | ForEach-Object { + $size = if ($_.Length -gt 1MB) { "{0:N2} MB" -f ($_.Length / 1MB) } else { "{0:N2} KB" -f ($_.Length / 1KB) } + Write-Host " $($_.Name) ($size)" +} diff --git a/src/config.ts b/src/config.ts index a3f14c5..c075207 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,18 @@ import { globSync } from 'glob'; import { homedir } from 'os'; import path from 'path'; +// Platform detection +const isWindows = process.platform === 'win32'; + +// Check if a path is absolute on any platform +const isAbsolutePath = (p: string): boolean => { + if (isWindows) { + // Windows: C:\ or C:/ or \\ (UNC) + return /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith('\\\\'); + } + return p.startsWith('/'); +}; + // NOTE: this type does not directly correspond to the config file: see // defaultConfig, envConfigs and configFileBlocked type Config = { @@ -47,6 +59,7 @@ const envConfigs: [string, keyof Config][] = [ // configs that can't be set through the config file const configFileBlocked: (keyof Config)[] = ['port']; +// Build config paths - on Windows use AppData as well const configPaths = [ path.join(homedir(), '.config', 'vivify', 'config.json'), path.join(homedir(), '.config', 'vivify.json'), @@ -54,6 +67,11 @@ const configPaths = [ path.join(homedir(), '.vivify.json'), ]; +// On Windows, also check AppData/Roaming +if (isWindows && process.env.APPDATA) { + configPaths.unshift(path.join(process.env.APPDATA, 'vivify', 'config.json')); +} + // read contents of file at paths or files at paths const getFileContents = ( paths: string[] | string | undefined, @@ -66,7 +84,8 @@ const getFileContents = ( if (resolved[0] === '~') { resolved = path.join(homedir(), p.slice(1)); } - if (resolved[0] !== '/' && baseDir !== undefined) { + // Check for relative paths - use isAbsolutePath for cross-platform support + if (!isAbsolutePath(resolved) && baseDir !== undefined) { resolved = path.join(baseDir, resolved); } return globSync(resolved) diff --git a/src/parser/alerts.ts b/src/parser/alerts.ts index e366248..e1aefd7 100644 --- a/src/parser/alerts.ts +++ b/src/parser/alerts.ts @@ -16,6 +16,18 @@ import { existsSync, readFileSync } from 'fs'; import { homedir } from 'os'; import path from 'path'; +// Platform detection +const isWindows = process.platform === 'win32'; + +// Check if a path is absolute on any platform +const isAbsolutePath = (p: string): boolean => { + if (isWindows) { + // Windows: C:\ or C:/ or \\ (UNC) + return /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith('\\\\'); + } + return p.startsWith('/'); +}; + const icons: Record = { // GitHub default alerts note: 'info', @@ -52,7 +64,17 @@ function resolveIcon(iconOpt: string): string { } // Case 2: svg file path - const prefix = ['/', './', '../', '~/'].find((p) => iconOpt.startsWith(p)); + // Check for path prefixes (Unix) or absolute Windows paths + const isPathPrefix = (p: string): string | undefined => { + const unixPrefixes = ['/', './', '../', '~/']; + const prefix = unixPrefixes.find((pref) => p.startsWith(pref)); + if (prefix) return prefix; + // Check for Windows absolute path (C:\ or C:/) + if (isWindows && isAbsolutePath(p)) return 'win-absolute'; + return undefined; + }; + + const prefix = isPathPrefix(iconOpt); if (prefix && iconOpt.endsWith('.svg')) { let iconPath = iconOpt; @@ -64,6 +86,7 @@ function resolveIcon(iconOpt: string): string { } iconPath = path.join(configBaseDir, iconPath); } + // For absolute paths (Unix '/' or Windows 'C:\'), use as-is if (!existsSync(iconPath)) { return warnAndFallback(`Icon file not found: ${iconPath}`); diff --git a/src/sockets.ts b/src/sockets.ts index 73fc490..3d8c2e9 100644 --- a/src/sockets.ts +++ b/src/sockets.ts @@ -1,6 +1,8 @@ import { WebSocketServer, WebSocket } from 'ws'; import { v4 as uuidv4 } from 'uuid'; import { Server } from 'http'; +import { tmpdir } from 'os'; +import path from 'path'; import { openFileAt } from './cli.js'; import fs from 'fs'; @@ -24,6 +26,15 @@ export function setupSockets( // queue of messages to be sent to clients after they have connected const openQueue = new Map(); + const logPath = process.env.VIV_LOG_PATH ?? path.join(tmpdir(), 'vivify-server.log'); + const log = (message: string) => { + try { + fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`); + } catch {} + }; + + log(`WS server started (log=${logPath})`); + const terminateSocket = (id: string) => { const socket = sockets.get(id); if (!socket) return; @@ -37,6 +48,7 @@ export function setupSockets( if (sockets.size === 0) onFirstClient(); const id = uuidv4(); sockets.set(id, { socket, alive: true }); + log(`WS client connected id=${id}`); socket.on('pong', () => { if (sockets.has(id)) { @@ -55,6 +67,7 @@ export function setupSockets( switch (key) { case 'PATH': sockets.get(id)!.path = value; + log(`WS client path id=${id} path=${value}`); // watch path (fails if path doesn't exist) try { sockets.get(id)!.watcher = fs.watch(value, (eventType) => { @@ -80,6 +93,7 @@ export function setupSockets( }); socket.on('close', () => { + log(`WS client closed id=${id}`); terminateSocket(id); }); }); diff --git a/src/utils/path.ts b/src/utils/path.ts index 091c4f4..66a4ad4 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,10 +1,13 @@ import { homedir } from 'os'; -import { basename as pbasename, dirname as pdirname, parse as pparse, extname } from 'path'; +import { basename as pbasename, dirname as pdirname, parse as pparse, extname, sep } from 'path'; import config from '../config.js'; import { isText } from 'istextorbinary'; import { readFileSync } from 'fs'; import { fileTypeFromBuffer } from 'file-type'; +// Platform detection +export const isWindows = process.platform === 'win32'; + export const pextension = (path: string) => extname(path).slice(1); export const isMarkdown = (path: string) => { @@ -39,32 +42,73 @@ export const pmime = async (path: string) => { return (await fileTypeFromBuffer(content))?.mime; }; +// Check if a path is absolute on any platform +export const isAbsolutePath = (path: string): boolean => { + if (isWindows) { + // Windows: C:\ or C:/ or \\ (UNC) + return /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith('\\\\'); + } + return path.startsWith('/'); +}; + export const pcomponents = (path: string) => { const parsed = pparse(path); const components = new Array(); // directory let dir = parsed.dir; - while (dir !== '/' && dir !== '.') { + // Handle both Unix and Windows root detection + while (dir !== '/' && dir !== '.' && dir !== '' && !(/^[a-zA-Z]:[\\/]?$/.test(dir))) { components.unshift(pbasename(dir)); dir = pdirname(dir); } // root - if (parsed.root !== '') components.unshift(parsed.root); + if (parsed.root !== '') { + // On Windows, normalize root to use forward slash for consistency in URLs + components.unshift(parsed.root.replace(/\\$/, '/')); + } // base if (parsed.base !== '') components.push(parsed.base); return components; }; export const urlToPath = (url: string) => { - const path = decodeURIComponent(url.replace(/^\/(viewer|health)/, '')) - .replace(/^\/~/, homedir()) - .replace(/\/+$/, ''); - return path === '' ? '/' : path; + // First, decode the URL and remove the route prefix + let path = decodeURIComponent(url.replace(/^\/(viewer|health)/, '')); + + // Handle tilde home directory shortcut + path = path.replace(/^\/~/, homedir()); + + // Remove trailing slashes + path = path.replace(/\/+$/, ''); + + // On Windows, URLs come in as /C:/path/to/file + // We need to remove the leading slash before the drive letter + if (isWindows) { + // Match /C:/ or /c:/ pattern at the start + const windowsDriveMatch = path.match(/^\/([a-zA-Z]:)(.*)$/); + if (windowsDriveMatch) { + // Return C:/path/to/file (with forward slashes, Node.js handles both) + path = windowsDriveMatch[1] + windowsDriveMatch[2]; + } + } + + return path === '' ? (isWindows ? 'C:/' : '/') : path; }; export const pathToURL = (path: string, route: string = 'viewer') => { - const withoutPrefix = path.startsWith('/') ? path.slice(1) : path; - return `/${route}/${encodeURIComponent(withoutPrefix).replaceAll('%2F', '/')}`; + let normalizedPath = path; + + // On Windows, convert backslashes to forward slashes for URLs + if (isWindows) { + normalizedPath = path.replace(/\\/g, '/'); + } + + // Remove leading slash for POSIX paths + const withoutPrefix = normalizedPath.startsWith('/') ? normalizedPath.slice(1) : normalizedPath; + + // Encode the path, but keep forward slashes readable + // Also keep colons unencoded for Windows drive letters + return `/${route}/${encodeURIComponent(withoutPrefix).replaceAll('%2F', '/').replaceAll('%3A', ':')}`; }; export const preferredPath = (path: string): string => diff --git a/viv.cmd b/viv.cmd new file mode 100644 index 0000000..1ee696c --- /dev/null +++ b/viv.cmd @@ -0,0 +1,81 @@ +@echo off +setlocal EnableDelayedExpansion + +rem Get the directory where this script is located +set "INSTALL_DIR=%~dp0" +set "VIVIFY_SERVER=%INSTALL_DIR%vivify-server.exe" + +rem Handle help flag +if "%~1"=="" goto :usage +if /i "%~1"=="-h" goto :usage +if /i "%~1"=="--help" goto :usage + +rem Handle version flag +if /i "%~1"=="-v" goto :version +if /i "%~1"=="--version" goto :version + +rem Check if vivify-server exists +if not exist "%VIVIFY_SERVER%" ( + echo Fatal: "%VIVIFY_SERVER%" not found. + echo. + echo Please make sure that the "vivify-server.exe" binary is located in the same + echo directory as the "viv.cmd" script. + exit /b 1 +) + +rem Create a temporary file for output +set "TEMP_OUTPUT=%TEMP%\vivify_output_%RANDOM%.txt" + +rem Start vivify-server and wait for startup +start /b "" "%VIVIFY_SERVER%" %* > "%TEMP_OUTPUT%" 2>&1 + +rem Wait for STARTUP COMPLETE or timeout +set TIMEOUT_COUNTER=0 +set MAX_TIMEOUT=300 + +:wait_loop +if !TIMEOUT_COUNTER! geq !MAX_TIMEOUT! goto :timeout + +rem Check if output file contains STARTUP COMPLETE +findstr /c:"STARTUP COMPLETE" "%TEMP_OUTPUT%" >nul 2>&1 +if !errorlevel! equ 0 goto :success + +rem Small delay +ping -n 1 -w 100 127.0.0.1 >nul 2>&1 +set /a TIMEOUT_COUNTER+=1 +goto :wait_loop + +:success +rem Print any output before STARTUP COMPLETE +for /f "delims=" %%a in ('findstr /v /c:"STARTUP COMPLETE" "%TEMP_OUTPUT%"') do ( + echo %%a +) +del "%TEMP_OUTPUT%" 2>nul +exit /b 0 + +:timeout +echo Warning: Startup took longer than expected, but server may still be running. +del "%TEMP_OUTPUT%" 2>nul +exit /b 0 + +:usage +echo usage: viv target [target ...] +echo. +echo View file/directory in your browser with Vivify. +echo. +echo arguments: +echo target Path to file or directory to view. For Markdown, you can +echo suffix with :n to scroll to the content at line n in the +echo source file +echo options: +echo --help, -h show this help message and exit +echo --version, -v show version information and exit +exit /b 1 + +:version +if exist "%VIVIFY_SERVER%" ( + "%VIVIFY_SERVER%" --version +) else ( + echo vivify-server not found +) +exit /b 0 diff --git a/viv.ps1 b/viv.ps1 new file mode 100644 index 0000000..ff9bb06 --- /dev/null +++ b/viv.ps1 @@ -0,0 +1,185 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + View file/directory in your browser with Vivify. + +.DESCRIPTION + Vivify brings your files to life in the browser! View Markdown, Jupyter Notebooks, + directories, and code files with syntax highlighting. + +.PARAMETER Target + Path to file or directory to view. For Markdown, you can suffix with :n to scroll + to the content at line n in the source file. + +.PARAMETER Help + Show help message and exit. + +.PARAMETER Version + Show version information and exit. + +.EXAMPLE + viv README.md + Opens README.md in the browser with Vivify rendering. + +.EXAMPLE + viv ./docs + Opens the docs directory listing in the browser. + +.EXAMPLE + viv README.md:42 + Opens README.md and scrolls to line 42. +#> + +[CmdletBinding()] +param( + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$Target, + + [Alias('h')] + [switch]$Help, + + [Alias('v')] + [switch]$Version +) + +# Get the directory where this script is located +$installDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$vivifyServer = Join-Path $installDir "vivify-server.exe" + +function Print-Usage { + @" +usage: viv target [target ...] + +View file/directory in your browser with Vivify. + +arguments: + target Path to file or directory to view. For Markdown, you can + suffix with :n to scroll to the content at line n in the + source file +options: + --help, -h show this help message and exit + --version, -v show version information and exit +"@ +} + +function Print-BugReport { + @" +Fatal: "$vivifyServer" crashed. +Please use the link below to submit a bug report. + +The bug report template will help you provide the necessary information and +maybe even find a solution yourself. + +https://github.com/jannis-baum/Vivify/issues/new?labels=type%3Abug&template=bug-report.md + +"@ +} + +function Print-ServerFileError { + param([string]$ErrorType) + @" +Fatal: "$vivifyServer" $ErrorType. + +Please make sure that the "vivify-server.exe" binary is located in the same +directory as the "viv.ps1" script. + +"@ +} + +# Handle help flag +if ($Help -or $Target.Count -eq 0) { + Print-Usage + exit 1 +} + +# Handle version flag +if ($Version) { + # Try to get version from server + if (Test-Path $vivifyServer) { + & $vivifyServer --version + } else { + Write-Host "vivify-server not found" + } + exit 0 +} + +# Check if vivify-server exists +if (-not (Test-Path $vivifyServer)) { + Print-ServerFileError "not found" + exit 1 +} + +# Create a temporary file for output +$outputFile = [System.IO.Path]::GetTempFileName() + +try { + # Start the vivify-server process + $processInfo = New-Object System.Diagnostics.ProcessStartInfo + $processInfo.FileName = $vivifyServer + $processInfo.Arguments = ($Target | ForEach-Object { "`"$_`"" }) -join ' ' + $processInfo.RedirectStandardOutput = $true + $processInfo.RedirectStandardError = $true + $processInfo.UseShellExecute = $false + $processInfo.CreateNoWindow = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $processInfo + + # Set up output handling + $outputBuilder = New-Object System.Text.StringBuilder + $startupComplete = $false + + $outputHandler = { + if ($EventArgs.Data) { + # Check for startup complete signal + if ($EventArgs.Data -match "STARTUP COMPLETE") { + $script:startupComplete = $true + } else { + # Print other output to console + Write-Host $EventArgs.Data + } + $outputBuilder.AppendLine($EventArgs.Data) | Out-Null + } + } + + $process.add_OutputDataReceived($outputHandler) + $process.add_ErrorDataReceived($outputHandler) + + # Start the process + $process.Start() | Out-Null + $process.BeginOutputReadLine() + $process.BeginErrorReadLine() + + # Wait for startup completion or process exit + $timeout = 30000 # 30 seconds timeout + $elapsed = 0 + $checkInterval = 100 # Check every 100ms + + while (-not $startupComplete -and -not $process.HasExited -and $elapsed -lt $timeout) { + Start-Sleep -Milliseconds $checkInterval + $elapsed += $checkInterval + } + + # Check if process crashed during startup + if ($process.HasExited -and -not $startupComplete) { + Print-BugReport + exit 1 + } + + # If we reached timeout, still exit cleanly as server might be running in background + if ($elapsed -ge $timeout -and -not $startupComplete) { + Write-Host "Warning: Startup took longer than expected, but server may still be running." + } + +} catch { + Write-Host "Error starting vivify-server: $_" + Print-BugReport + exit 1 +} finally { + # Clean up temp file + if (Test-Path $outputFile) { + Remove-Item $outputFile -Force -ErrorAction SilentlyContinue + } +} + +exit 0