mirror of
https://github.com/neovide/neovide.git
synced 2026-09-10 07:16:25 -04:00
use local open for remote neovim
This commit is contained in:
parent
090851dc76
commit
c9b26a24d5
37
Cargo.lock
generated
37
Cargo.lock
generated
|
|
@ -1313,6 +1313,25 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
|
|
@ -1629,6 +1648,7 @@ dependencies = [
|
|||
"objc2-foundation 0.3.2",
|
||||
"objc2-metal 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"open",
|
||||
"parking_lot",
|
||||
"rand 0.9.2",
|
||||
"raw-window-handle",
|
||||
|
|
@ -2230,6 +2250,17 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2299,6 +2330,12 @@ version = "1.0.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ neovide-derive = { path = "neovide-derive", version = "0.1.5" }
|
|||
notify-debouncer-full = "0.6.0"
|
||||
num = "0.4.3"
|
||||
nvim-rs = { version = "0.9.2", features = ["use_tokio"] }
|
||||
open = "5"
|
||||
parking_lot = "0.12.3"
|
||||
rand = "0.9.0"
|
||||
raw-window-handle = "0.6.2"
|
||||
|
|
|
|||
30
lua/init.lua
30
lua/init.lua
|
|
@ -3,6 +3,7 @@
|
|||
---@field neovide_version string
|
||||
---@field config_path string
|
||||
---@field register_clipboard boolean
|
||||
---@field register_open boolean
|
||||
---@field register_right_click boolean
|
||||
---@field remote boolean
|
||||
---@field enable_focus_command boolean
|
||||
|
|
@ -80,6 +81,35 @@ if args.register_clipboard and not vim.g.neovide_no_custom_clipboard then
|
|||
vim.cmd.runtime("autoload/provider/clipboard.vim")
|
||||
end
|
||||
|
||||
if args.register_open and not vim.g.neovide_no_remote_open then
|
||||
local original_open = vim.ui.open
|
||||
|
||||
vim.ui.open = function(path, opt)
|
||||
opt = opt or {}
|
||||
-- If user specified a custom command or opted out of remote open,
|
||||
-- delegate to the original implementation (runs on the remote machine)
|
||||
if opt.cmd or opt.neovide_no_remote_open then
|
||||
return original_open(path, opt)
|
||||
end
|
||||
|
||||
-- Send to Neovide to open on the host system
|
||||
local ok, err = pcall(rpcrequest, "neovide.open", path)
|
||||
if not ok then
|
||||
return nil, "neovide.open failed: " .. tostring(err)
|
||||
end
|
||||
|
||||
-- Return a mock SystemObj for API compatibility
|
||||
-- (callers may chain :wait() on the result)
|
||||
local obj = {
|
||||
pid = 0,
|
||||
wait = function(_, _timeout)
|
||||
return { code = 0, signal = 0, stdout = "", stderr = "" }
|
||||
end,
|
||||
}
|
||||
return obj, nil
|
||||
end
|
||||
end
|
||||
|
||||
if args.register_right_click then
|
||||
vim.api.nvim_create_user_command("NeovideRegisterRightClick", function()
|
||||
rpcnotify("neovide.register_right_click")
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ use crate::{
|
|||
};
|
||||
|
||||
use super::ui_commands::UiCommand;
|
||||
use super::url_allowlist::is_url_allowed;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ClipboardRequestError {
|
||||
|
|
@ -90,6 +91,7 @@ pub struct NeovimHandler {
|
|||
#[allow(dead_code)]
|
||||
settings: Arc<Settings>,
|
||||
clipboard: ClipboardHandle,
|
||||
allowed_url_patterns: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NeovimHandler {
|
||||
|
|
@ -109,6 +111,7 @@ impl NeovimHandler {
|
|||
route_id: RouteId,
|
||||
settings: Arc<Settings>,
|
||||
clipboard: ClipboardHandle,
|
||||
allowed_url_patterns: Option<Vec<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
proxy: Arc::new(Mutex::new(proxy)),
|
||||
|
|
@ -121,6 +124,7 @@ impl NeovimHandler {
|
|||
route_id,
|
||||
settings,
|
||||
clipboard,
|
||||
allowed_url_patterns,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +196,20 @@ impl Handler for NeovimHandler {
|
|||
.map_err(|_| ClipboardRequestError::CannotSetContents)
|
||||
})
|
||||
.map_err(Value::from),
|
||||
"neovide.open" => {
|
||||
let path = arguments
|
||||
.first()
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Value::from("neovide.open: missing path argument"))?;
|
||||
|
||||
if !is_url_allowed(path, &self.allowed_url_patterns) {
|
||||
return Err(Value::from(format!("URL rejected by allowlist: {path}")));
|
||||
}
|
||||
|
||||
open::that(path)
|
||||
.map(|_| Value::Nil)
|
||||
.map_err(|e| Value::from(format!("neovide.open: {e}")))
|
||||
}
|
||||
"neovide.quit" => {
|
||||
let error_code =
|
||||
arguments[0].as_i64().expect("Could not parse error code from neovim");
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ mod restart;
|
|||
pub mod session;
|
||||
mod setup;
|
||||
mod ui_commands;
|
||||
mod url_allowlist;
|
||||
|
||||
use std::{
|
||||
io::Error,
|
||||
|
|
@ -399,12 +400,15 @@ impl NeovimRuntime {
|
|||
mode: OpenMode,
|
||||
) -> Result<NeovimHandler> {
|
||||
let mut colorscheme_stream = self.colorscheme_stream();
|
||||
let allowed_url_patterns =
|
||||
config.remote.as_ref().and_then(|r| r.allowed_url_patterns.clone());
|
||||
let editor_handler = start_editor_handler(
|
||||
route_id,
|
||||
event_loop_proxy.clone(),
|
||||
running_tracker,
|
||||
settings.clone(),
|
||||
self.clipboard.clone(),
|
||||
allowed_url_patterns,
|
||||
);
|
||||
let initial_background =
|
||||
self.runtime().block_on(initial_background_from_stream(&mut colorscheme_stream));
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ pub async fn setup_neovide_specific_state(
|
|||
.context("Error setting client info")?;
|
||||
|
||||
let register_clipboard = remote;
|
||||
let register_open = remote;
|
||||
let register_right_click = cfg!(target_os = "windows");
|
||||
|
||||
let setting_locations = settings.setting_locations();
|
||||
|
|
@ -80,6 +81,7 @@ pub async fn setup_neovide_specific_state(
|
|||
"neovide_version" => BUILD_VERSION,
|
||||
"config_path" => config_path().to_string_lossy().into_owned(),
|
||||
"register_clipboard" => register_clipboard,
|
||||
"register_open" => register_open,
|
||||
"register_right_click" => register_right_click,
|
||||
"remote" => remote,
|
||||
"global_variable_settings" => global_variable_settings,
|
||||
|
|
|
|||
167
src/bridge/url_allowlist.rs
Normal file
167
src/bridge/url_allowlist.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/// Check if a URL/path matches a single wildcard pattern.
|
||||
///
|
||||
/// `*` matches any sequence of characters (including none).
|
||||
/// All other characters are matched literally.
|
||||
fn wildcard_match(pattern: &str, text: &str) -> bool {
|
||||
// Split pattern on `*`, then verify each literal segment appears in order.
|
||||
let segments: Vec<&str> = pattern.split('*').collect();
|
||||
|
||||
// If there are no wildcards, require exact match
|
||||
if segments.len() == 1 {
|
||||
return pattern == text;
|
||||
}
|
||||
|
||||
let mut pos = 0;
|
||||
|
||||
for (i, segment) in segments.iter().enumerate() {
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match text[pos..].find(segment) {
|
||||
Some(offset) => {
|
||||
// First segment must anchor to the start
|
||||
if i == 0 && offset != 0 {
|
||||
return false;
|
||||
}
|
||||
pos += offset + segment.len();
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
|
||||
// The last segment is what comes after the final `*`.
|
||||
// If it's non-empty, the text must end with it (end anchor).
|
||||
// If it's empty (pattern ends with `*`), no end anchoring needed.
|
||||
let last = segments.last().expect("split always produces at least one segment");
|
||||
if !last.is_empty() {
|
||||
return text.ends_with(last);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if a URL is allowed by any of the patterns.
|
||||
///
|
||||
/// Returns `false` if patterns is `None` or empty (deny by default).
|
||||
pub fn is_url_allowed(url: &str, patterns: &Option<Vec<String>>) -> bool {
|
||||
match patterns {
|
||||
None => false,
|
||||
Some(p) if p.is_empty() => false,
|
||||
Some(patterns) => patterns.iter().any(|p| wildcard_match(p, url)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exact_match() {
|
||||
assert!(wildcard_match("hello", "hello"));
|
||||
assert!(!wildcard_match("hello", "world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_matches_all() {
|
||||
assert!(wildcard_match("*", "anything"));
|
||||
assert!(wildcard_match("*", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_prefix() {
|
||||
assert!(wildcard_match("http://*", "http://example.com"));
|
||||
assert!(wildcard_match("http://*", "http://example.com/path/to/page"));
|
||||
assert!(!wildcard_match("http://*", "https://example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_suffix() {
|
||||
assert!(wildcard_match("*.txt", "file.txt"));
|
||||
assert!(wildcard_match("*.txt", "path/to/file.txt"));
|
||||
assert!(!wildcard_match("*.txt", "file.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wildcard_both_ends() {
|
||||
assert!(wildcard_match("http://*.com", "http://example.com"));
|
||||
assert!(wildcard_match("http://*.com", "http://www.example.com"));
|
||||
assert!(!wildcard_match("http://*.com", "http://example.org"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_wildcards() {
|
||||
assert!(wildcard_match("http://*/path/*", "http://example.com/path/to/page"));
|
||||
assert!(!wildcard_match("http://*/path/*", "http://example.com/other/page"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anchoring() {
|
||||
// Pattern must match from start
|
||||
assert!(!wildcard_match("://*", "http://example.com"));
|
||||
// Pattern must match to end
|
||||
assert!(!wildcard_match("*.com/path", "http://example.com/path/extra"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_url_allowed() {
|
||||
let patterns = Some(vec!["https://*".to_string(), "http://*".to_string()]);
|
||||
assert!(is_url_allowed("https://example.com", &patterns));
|
||||
assert!(is_url_allowed("http://example.com/path", &patterns));
|
||||
assert!(!is_url_allowed("ftp://example.com", &patterns));
|
||||
|
||||
// Deny by default
|
||||
assert!(!is_url_allowed("https://example.com", &None));
|
||||
assert!(!is_url_allowed("https://example.com", &Some(vec![])));
|
||||
}
|
||||
|
||||
// --- Adversarial pattern examples ---
|
||||
//
|
||||
// These tests document some common but insecure patterns.
|
||||
|
||||
#[test]
|
||||
fn adversarial_userinfo_spoof() {
|
||||
// Pattern intends: any github.com URL
|
||||
// Problem: `*` matches the `@`, so `github.com` appears as the userinfo
|
||||
// (username:password) prefix of a completely different host.
|
||||
assert!(wildcard_match(
|
||||
"https://github.com*",
|
||||
"https://github.com:foo@phishing-domain.com/",
|
||||
));
|
||||
// Solution: always include the `/` after the domain name.
|
||||
assert!(!wildcard_match(
|
||||
"https://github.com/*",
|
||||
"https://github.com:foo@phishing-domain.com/",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_scheme() {
|
||||
// Pattern intends: any path on mydomain.com
|
||||
// Problem: the pattern matches any URL scheme
|
||||
assert!(wildcard_match(
|
||||
"*://mydomain.com/*",
|
||||
"mailto:hacker@phishing-domain.com?subject=://mydomain.com/&body=Get%20phished",
|
||||
));
|
||||
// Solution: always start the pattern with the intended scheme
|
||||
assert!(!wildcard_match(
|
||||
"https://mydomain.com/*",
|
||||
"mailto:hacker@phishing-domain.com?subject=://mydomain.com/&body=Get%20phished",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_subdomains() {
|
||||
// Pattern intends: any path on *.mydomain.com
|
||||
// Problem: `*` matches across the `?` query boundary, so a phishing
|
||||
// domain can embed the literal substring in its query string.
|
||||
assert!(wildcard_match(
|
||||
"https://*.mydomain.com/*",
|
||||
"https://phishing-domain.com/?.mydomain.com/",
|
||||
));
|
||||
// Solution: do not use wildcard subdomains.
|
||||
assert!(!wildcard_match(
|
||||
"https://allowed.mydomain.com/*",
|
||||
"https://phishing-domain.com/?.mydomain.com/",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1335,6 +1335,7 @@ pub fn start_editor_handler(
|
|||
running_tracker: RunningTracker,
|
||||
settings: Arc<Settings>,
|
||||
clipboard: ClipboardHandle,
|
||||
allowed_url_patterns: Option<Vec<String>>,
|
||||
) -> NeovimHandler {
|
||||
let (redraw_event_sender, mut redraw_event_receiver) = unbounded_channel();
|
||||
let (ui_command_sender, ui_command_receiver) = unbounded_channel();
|
||||
|
|
@ -1347,6 +1348,7 @@ pub fn start_editor_handler(
|
|||
route_id,
|
||||
settings.clone(),
|
||||
clipboard,
|
||||
allowed_url_patterns,
|
||||
);
|
||||
thread::spawn(move || {
|
||||
let mut editor = Editor::new(route_id, event_loop_proxy.clone(), settings.clone());
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use super::font::FontSettings;
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct RemoteConfig {
|
||||
pub allowed_url_patterns: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
const CONFIG_FILE: &str = "config.toml";
|
||||
|
||||
#[cfg(unix)]
|
||||
|
|
@ -119,6 +125,7 @@ pub struct Config {
|
|||
pub wayland_app_id: Option<String>,
|
||||
pub x11_wm_class: Option<String>,
|
||||
pub x11_wm_class_instance: Option<String>,
|
||||
pub remote: Option<RemoteConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ mode = "font-glyph"
|
|||
|
||||
[box-drawing.sizes]
|
||||
default = [2, 4] # Thin and thick values respectively, for all sizes
|
||||
|
||||
[remote]
|
||||
allowed-url-patterns = ["https://*", "http://*"] # deny all if unset
|
||||
```
|
||||
|
||||
Refer to [Command Line Reference](command-line-reference.md) for details about the config settings
|
||||
|
|
@ -258,3 +261,39 @@ The default location is the following:
|
|||
| Linux | `$XDG_DATA_HOME or $HOME/.local/share/neovide` | `/home/alice/.local/share/neovide` |
|
||||
| macOS | `$HOME/Library/Application Support/neovide` | `/Users/Alice/Library/Application Support/neovide` |
|
||||
| Windows | `{FOLDERID_LocalAppData}\neovide` | `C:\Users\Alice\AppData\Local\neovide` |
|
||||
|
||||
#### Remote
|
||||
|
||||
Controls behavior when Neovide connects to a remote Neovim instance (`--server` or `--wsl`).
|
||||
|
||||
##### `allowed-url-patterns`
|
||||
|
||||
```toml
|
||||
[remote]
|
||||
allowed-url-patterns = [
|
||||
"https://*",
|
||||
"http://*",
|
||||
]
|
||||
```
|
||||
|
||||
When running in remote mode, Neovide overrides `vim.ui.open` to open URLs and files on the host
|
||||
system. Patterns use simple wildcard matching where `*` matches any sequence of characters (including
|
||||
`/`). Everything else is matched literally. For example:
|
||||
|
||||
| Pattern | Matches |
|
||||
|---------|--------|
|
||||
| `https://*` | Any HTTPS URL |
|
||||
| `http://example.com/*` | Any HTTP URL on the domain `example.com` |
|
||||
| `*` | Dangerous! All URLs, including app deep links (e.g. `zoommtg://zoom.us/join?confno=`) |
|
||||
|
||||
**Note:** Because `*` matches all characters, overly broad patterns can match unintended URLs. For
|
||||
example, `https://*.mydomain.com/*` also matches `https://phishing-domain.com/?.mydomain.com/`. It
|
||||
is recommended to always start your URL patterns with `https://` at a minimum.
|
||||
|
||||
If `allowed-url-patterns` is not set or is empty, remote open will reject all URLs. When a URL is
|
||||
rejected, you will see this error:
|
||||
|
||||
> `neovide.open failed: URL rejected by allowlist: URL`
|
||||
|
||||
You can disable the remote open functionality entirely by using `vim.g.neovide_no_remote_open` in
|
||||
your [Vim Configuration](configuration.md).
|
||||
|
|
|
|||
|
|
@ -851,6 +851,61 @@ indicator.
|
|||
|
||||
Note: recommended setup is `--frame full` with titles enabled for a cleaner look.
|
||||
|
||||
### Remote Settings
|
||||
|
||||
These settings only take effect when Neovide is connecting to a remote Neovim instance, i.e. when
|
||||
`--server` or `--wsl` is passed (even on non-Windows platforms).
|
||||
|
||||
#### No Custom Clipboard
|
||||
|
||||
VimScript:
|
||||
|
||||
```vim
|
||||
let g:neovide_no_custom_clipboard = v:true
|
||||
```
|
||||
|
||||
Lua:
|
||||
|
||||
```lua
|
||||
vim.g.neovide_no_custom_clipboard = true
|
||||
```
|
||||
|
||||
When running in remote mode, Neovide normally overrides the clipboard provider so that yanking and
|
||||
pasting use the host system's clipboard rather than the remote machine's. Setting
|
||||
`g:neovide_no_custom_clipboard` to a boolean value of `true` disables this override, letting the
|
||||
remote Neovim's own clipboard provider handle things instead. The default is `false`.
|
||||
|
||||
#### No Remote Open
|
||||
|
||||
VimScript:
|
||||
|
||||
```vim
|
||||
let g:neovide_no_remote_open = v:true
|
||||
```
|
||||
|
||||
Lua:
|
||||
|
||||
```lua
|
||||
vim.g.neovide_no_remote_open = true
|
||||
```
|
||||
|
||||
When running in remote mode, Neovide normally overrides `vim.ui.open` so that opening URLs and files
|
||||
(for example via `gx`) uses the host system's default handler rather than trying to open them on the
|
||||
remote machine. Setting `g:neovide_no_remote_open` to a boolean value of `true` disables this
|
||||
override entirely at startup, so the remote Neovim's native `vim.ui.open` is used instead. The
|
||||
default is `false`.
|
||||
|
||||
You can also bypass the override on a per-call basis by passing `neovide_no_remote_open = true` in
|
||||
the options table:
|
||||
|
||||
```lua
|
||||
vim.ui.open("https://example.com", { neovide_no_remote_open = true })
|
||||
```
|
||||
|
||||
This forces the call to use the remote machine's native handler without disabling the override
|
||||
globally. Additionally, if the call specifies a `cmd` to use, Neovide does not intercept the call
|
||||
(so `cmd` is always run remotely).
|
||||
|
||||
### Input Settings
|
||||
|
||||
#### macOS Option Key is Meta
|
||||
|
|
|
|||
Loading…
Reference in a new issue