diff --git a/README.md b/README.md index 01bda5c..b869b5f 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ pre-built binary** or **compile from source** (controlled by `ghostel-module-auto-install`, default `ask`). You can also trigger these manually: -- `M-x ghostel-download-module` — download a pre-built binary from GitHub releases +- `M-x ghostel-download-module` — download the minimum supported pre-built binary +- `C-u M-x ghostel-download-module` — choose a specific release tag (leave blank for latest) - `M-x ghostel-module-compile` — build from source via `zig build` ## Building from source @@ -116,10 +117,10 @@ zig build -Doptimize=ReleaseFast When installed from MELPA, `M-x ghostel-module-compile` builds the native module from source using `zig build`. Zig's package manager fetches the -ghostty dependency automatically — no git submodule needed. +ghostty dependency automatically. -Alternatively, download a **pre-built binary** via -`M-x ghostel-download-module`. +Alternatively, download a **pre-built binary** via `M-x ghostel-download-module` +(or `C-u M-x ghostel-download-module` to pick a specific release). ## Shell Integration diff --git a/ghostel.el b/ghostel.el index ad71d6e..012e73f 100644 --- a/ghostel.el +++ b/ghostel.el @@ -374,9 +374,11 @@ before sending the input." ghostel-color-bright-white] "Color palette for the terminal (vector of 16 face names).") -(defvar ghostel-github-release-url +(defcustom ghostel-github-release-url "https://github.com/dakra/ghostel/releases" - "Base URL for ghostel GitHub releases.") + "Base URL for Ghostel GitHub releases. +Customize this when downloading pre-built modules from a fork or mirror." + :type 'string) (defconst ghostel--minimum-module-version "0.13.0" "Minimum native module version required by this Elisp version. @@ -423,22 +425,26 @@ Returns nil if the platform is not recognized." (when tag (format "ghostel-module-%s%s" tag module-file-suffix)))) -(defun ghostel--module-download-url () - "Return the download URL for the current platform's pre-built module." +(defun ghostel--module-download-url (&optional version) + "Return the download URL for the current platform's pre-built module. +When VERSION is nil, use the latest release download URL." (let ((asset-name (ghostel--module-asset-name))) (when asset-name - (let ((version (ghostel--package-version))) - (if version - (format "%s/download/v%s/%s" - ghostel-github-release-url version asset-name) - (format "%s/latest/download/%s" - ghostel-github-release-url asset-name)))))) - -(defun ghostel--download-module (dir) + (if version + (format "%s/download/v%s/%s" + ghostel-github-release-url version asset-name) + (format "%s/latest/download/%s" + ghostel-github-release-url asset-name))))) + +(defun ghostel--download-module (dir &optional version latest-release) "Download a pre-built module into DIR. +When VERSION is non-nil, download that release tag. +When LATEST-RELEASE is non-nil, use the latest release asset URL. Returns non-nil on success." (condition-case err - (let ((url (ghostel--module-download-url))) + (let* ((requested-version (unless latest-release + (or version ghostel--minimum-module-version))) + (url (ghostel--module-download-url requested-version))) (when url (unless (string-prefix-p "https://" url) (error "Refusing non-HTTPS download URL: %s" url)) @@ -484,16 +490,28 @@ Behavior is controlled by `ghostel-module-auto-install'." ('compile (ghostel--compile-module dir)) (_ nil)))) +(defun ghostel--read-module-download-version () + "Prompt for a release tag to download, or nil for the latest release." + (let ((version (read-string + (format "Ghostel module version (>= %s, empty for latest): " + ghostel--minimum-module-version)))) + (unless (string= version "") + (when (version< version ghostel--minimum-module-version) + (user-error "Version %s is older than minimum supported version %s" + version ghostel--minimum-module-version)) + version))) + (defun ghostel--ask-install-action (_dir) "Prompt the user to choose how to install the missing native module. Returns \\='download, \\='compile, or nil." - (let* ((url (or (ghostel--module-download-url) "GitHub releases")) + (let* ((url (or (ghostel--module-download-url ghostel--minimum-module-version) + "GitHub releases")) (choice (read-char-choice (format "Ghostel native module not found. [d] Download pre-built binary from: %s - [c] Compile from source (requires Zig) + [c] Compile from source via build.sh [s] Skip — install manually later Choice: " url) @@ -503,19 +521,6 @@ Choice: " url) (?c 'compile) (?s nil)))) -(defun ghostel--package-version () - "Return ghostel release version string, or nil. -Reads the Version header from ghostel.el so the download URL -matches the GitHub release tag even when MELPA rewrites the -version to a date-based string." - (require 'lisp-mnt nil t) - (when (fboundp 'lm-header) - (let ((lib (or load-file-name (locate-library "ghostel.el" t)))) - (when lib - (with-temp-buffer - (insert-file-contents lib nil 0 1024) - (lm-header "Version")))))) - (defun ghostel--download-file (url dest) "Download URL to DEST. Return non-nil on success." (condition-case nil @@ -539,18 +544,23 @@ version to a date-based string." (kill-buffer buf)))))) (error nil))) -(defun ghostel-download-module () - "Interactively download the pre-built native module for this platform." - (interactive) +(defun ghostel-download-module (&optional prompt-for-version) + "Interactively download the pre-built native module for this platform. +With PROMPT-FOR-VERSION, prompt for a release tag to download. +Leaving the prompt empty downloads the latest release." + (interactive "P") (let* ((dir (file-name-directory (or load-file-name (locate-library "ghostel") buffer-file-name))) (mod (expand-file-name - (concat "ghostel-module" module-file-suffix) dir))) + (concat "ghostel-module" module-file-suffix) dir)) + (version (when prompt-for-version + (ghostel--read-module-download-version))) + (latest-release (and prompt-for-version (null version)))) (when (and (file-exists-p mod) (not (yes-or-no-p "Module already exists. Re-download? "))) (user-error "Cancelled")) - (if (ghostel--download-module dir) + (if (ghostel--download-module dir version latest-release) (progn (module-load mod) (message "ghostel: module loaded successfully")) @@ -601,7 +611,6 @@ DIR is the module directory." (concat "Native module not found: " mod "\nRun M-x ghostel-download-module or M-x ghostel-module-compile"))))) - ;;; Internal variables (defvar-local ghostel--term nil diff --git a/test/ghostel-test.el b/test/ghostel-test.el index 9d8a9b1..0b4b659 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -1970,14 +1970,123 @@ buffer and hand nil to the native module." ;; ----------------------------------------------------------------------- ;; ----------------------------------------------------------------------- -;; Test: module version check -;; ----------------------------------------------------------------------- - -(ert-deftest ghostel-test-package-version () - "Test that `ghostel--package-version' returns a version string." - (let ((ver (ghostel--package-version))) - (should (stringp ver)) - (should (string-match-p "^[0-9]+\\.[0-9]+\\.[0-9]+" ver)))) +;; Test: module download version selection +;; ----------------------------------------------------------------------- + +(ert-deftest ghostel-test-module-download-url-uses-requested-version () + "Requested download versions are decoupled from the package version." + (let ((ghostel-github-release-url "https://example.invalid/releases")) + (cl-letf (((symbol-function 'ghostel--module-asset-name) + (lambda () "ghostel-module-x86_64-linux.so"))) + (should (equal "https://example.invalid/releases/download/v0.7.1/ghostel-module-x86_64-linux.so" + (ghostel--module-download-url "0.7.1")))))) + +(ert-deftest ghostel-test-module-download-url-uses-latest-release () + "A nil download version uses the latest release asset." + (let ((ghostel-github-release-url "https://example.invalid/releases")) + (cl-letf (((symbol-function 'ghostel--module-asset-name) + (lambda () "ghostel-module-x86_64-linux.so"))) + (should (equal "https://example.invalid/releases/latest/download/ghostel-module-x86_64-linux.so" + (ghostel--module-download-url nil)))))) + +(ert-deftest ghostel-test-download-module-defaults-to-minimum-version () + "Automatic downloads pin to the minimum supported native module version." + (let ((ghostel--minimum-module-version "0.7.1") + (captured-version :unset) + (download-dest nil)) + (cl-letf (((symbol-function 'ghostel--module-download-url) + (lambda (&optional version) + (setq captured-version version) + "https://example.invalid/releases/download/v0.7.1/ghostel-module-x86_64-linux.so")) + ((symbol-function 'ghostel--download-file) + (lambda (_url dest) + (setq download-dest dest) + t)) + ((symbol-function 'message) + (lambda (&rest _)))) + (should (ghostel--download-module "C:/ghostel/")) + (should (equal "0.7.1" captured-version)) + (should (equal (downcase (expand-file-name + (concat "ghostel-module" module-file-suffix) + "C:/ghostel/")) + (downcase download-dest)))))) + +(ert-deftest ghostel-test-download-module-prefix-uses-requested-version () + "Prefix downloads pass the requested release version through unchanged." + (let ((ghostel--minimum-module-version "0.7.1") + (captured-version :unset) + (captured-latest nil) + (loaded-module nil)) + (let ((comp-enable-subr-trampolines nil) + (native-comp-enable-subr-trampolines nil)) + (cl-letf (((symbol-function 'locate-library) + (lambda (_) "C:/ghostel/ghostel.el")) + ((symbol-function 'file-exists-p) + (lambda (_) nil)) + ((symbol-function 'read-string) + (lambda (&rest _) "0.8.0")) + ((symbol-function 'ghostel--download-module) + (lambda (_dir &optional version latest-release) + (setq captured-version version + captured-latest latest-release) + t)) + ((symbol-function 'module-load) + (lambda (path) + (setq loaded-module path))) + ((symbol-function 'message) + (lambda (&rest _)))) + (ghostel-download-module '(4)) + (should (equal "0.8.0" captured-version)) + (should-not captured-latest) + (should (equal (downcase (expand-file-name + (concat "ghostel-module" module-file-suffix) + "C:/ghostel/")) + (downcase loaded-module))))))) + +(ert-deftest ghostel-test-download-module-prefix-empty-uses-latest () + "Prefix download treats blank input as a request for the latest release." + (let ((captured-version :unset) + (captured-latest nil) + (loaded-module nil)) + (let ((comp-enable-subr-trampolines nil) + (native-comp-enable-subr-trampolines nil)) + (cl-letf (((symbol-function 'locate-library) + (lambda (_) "C:/ghostel/ghostel.el")) + ((symbol-function 'file-exists-p) + (lambda (_) nil)) + ((symbol-function 'read-string) + (lambda (&rest _) "")) + ((symbol-function 'ghostel--download-module) + (lambda (_dir &optional version latest-release) + (setq captured-version version + captured-latest latest-release) + t)) + ((symbol-function 'module-load) + (lambda (path) + (setq loaded-module path))) + ((symbol-function 'message) + (lambda (&rest _)))) + (ghostel-download-module '(4)) + (should (null captured-version)) + (should captured-latest) + (should (equal (downcase (expand-file-name + (concat "ghostel-module" module-file-suffix) + "C:/ghostel/")) + (downcase loaded-module))))))) + +(ert-deftest ghostel-test-download-module-prefix-rejects-too-old-version () + "Prefix download rejects versions below the minimum supported version." + (let ((ghostel--minimum-module-version "0.7.1")) + (let ((comp-enable-subr-trampolines nil) + (native-comp-enable-subr-trampolines nil)) + (cl-letf (((symbol-function 'locate-library) + (lambda (_) "C:/ghostel/ghostel.el")) + ((symbol-function 'file-exists-p) + (lambda (_) nil)) + ((symbol-function 'read-string) + (lambda (&rest _) "0.7.0"))) + (should-error (ghostel-download-module '(4)) + :type 'user-error))))) (ert-deftest ghostel-test-compile-module-invokes-zig-build () "Source compilation runs zig build directly." @@ -2787,9 +2896,14 @@ while :; do sleep 0.1; done'\n") ghostel-test-project-universal-arg ghostel-test-copy-all ghostel-test-copy-mode-buffer-navigation - ghostel-test-package-version ghostel-test-compile-module-invokes-zig-build ghostel-test-module-compile-command-uses-zig-build + ghostel-test-module-download-url-uses-requested-version + ghostel-test-module-download-url-uses-latest-release + ghostel-test-download-module-defaults-to-minimum-version + ghostel-test-download-module-prefix-uses-requested-version + ghostel-test-download-module-prefix-empty-uses-latest + ghostel-test-download-module-prefix-rejects-too-old-version ghostel-test-module-version-match ghostel-test-module-version-mismatch ghostel-test-module-version-newer-than-minimum