You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

2530 lines
75 KiB

--- @since 25.5.31
local M = {}
local SHELL = os.getenv("SHELL") or ""
local HOME = os.getenv("HOME") or ""
local PLUGIN_NAME = "gvfs"
local USER_ID = ya.uid()
local USER_NAME = tostring(ya.user_name(USER_ID))
local XDG_RUNTIME_DIR = os.getenv("XDG_RUNTIME_DIR") or ("/run/user/" .. USER_ID)
local GVFS_ROOT_MOUNTPOINT = XDG_RUNTIME_DIR and (XDG_RUNTIME_DIR .. "/gvfs") or (HOME .. "/.gvfs")
local GVFS_ROOT_MOUNTPOINT_FILE = "/run/media/" .. USER_NAME
local SECRET_TOOL = "secret-tool"
local GPG_TOOL = "gpg"
local PASS_TOOL = "pass"
local SECRET_VAULT_VERSION = "1"
local path_separator = package.config:sub(1, 1)
---@enum NOTIFY_MSG
local NOTIFY_MSG = {
CANT_CREATE_SAVE_FOLDER = "Can't create save folder: %s",
CANT_SAVE_DEVICES = "Can't write to save file: %s",
CMD_NOT_FOUND = 'Command "%s" not found. Make sure it is installed.',
MOUNT_SUCCESS = 'Mounted: "%s"',
MOUNT_ERROR = "Mount error: %s",
CANT_REMOUNT_DEVICE = "This device can't be remounted or already mounted: %s",
CANT_AUTOMOUNT = "This device can't be automounted: %s",
UNMOUNT_ERROR = "Unmount error: %s",
READING_GVFS_MOUNTED_FOLDER_ERROR = "Reading gvfs mounted folder error: %s",
UNMOUNT_SUCCESS = 'Unmounted: "%s"',
EJECT_SUCCESS = 'Ejected "%s", it can safely be removed',
LIST_DEVICES_EMPTY = "No device or URI found.",
REMOVED_MOUNT_URI = "Device or URI removed: %s",
ADDED_MOUNT_URI = "Device or URI added: %s",
UPDATED_MOUNT_URI = "Device or URI updated: %s",
DEVICE_IS_DISCONNECTED = "Device or URI is disconnected",
CANT_ACCESS_PREV_CWD = "Device or URI is disconnected or Previous directory is removed",
URI_CANT_BE_EMPTY = "URI can't be empty",
URI_IS_INVALID = "URI is invalid",
UNSUPPORTED_SCHEME = "Unsupported scheme %s",
UNSUPPORTED_MANUALLY_MOUNT_SCHEME = "%s scheme is mounted automatically via GNOME Online Accounts (GOA)",
DISPLAY_NAME_CANT_BE_EMPTY = "Display name can't be empty",
MOUNT_ERROR_PASSWORD = 'Failed to mount "%s", please check your password',
MOUNT_ERROR_USERNAME = 'Failed to mount "%s", please check your username',
HEADLESS_DETECTED = "GVFS.yazi plugin can only run on DBUS session. Check github HEADLESS_WORKAROUND.md to enable DBUS session",
LIST_MOUNTS_EMPTY = "List mounts URI is empty",
RETRIVE_PASSWORD_SUCCESS = "Retrieved password from secret vault",
SAVE_PASSWORD_SUCCESS = "Saved password to secret vault",
SAVE_PASSWORD_FAILED = "Save password failed: %s",
SECRET_VAULT_LOCKED = "Secret vault is locked%s",
PASS_INIT_GPG_ID = 'Please run "pass init <KEY_ID>" to initialize your GPG key first. \nCheck SECURE_SAVED_PASSWORD.md for the fix',
MISSING_PUBLIC_KEY_GPG_KEY = "GPG key is missing public key\nCheck SECURE_SAVED_PASSWORD.md for the fix",
AUTOMOUNT_WHEN_CD_STATE = "%s automount when cd for: %s",
}
---@enum PASSWORD_VAULT
local PASSWORD_VAULT = {
KEYRING = "keyring",
PASS = "pass",
}
---@enum DEVICE_CONNECT_STATUS
local DEVICE_CONNECT_STATUS = {
MOUNTED = 1,
NOT_MOUNTED = 2,
}
---@enum SCHEME
local SCHEME = {
MTP = "mtp",
SMB = "smb",
SFTP = "sftp",
SSH = "ssh", -- Alias for sftp
NFS = "nfs",
GPHOTO2 = "gphoto2",
FTP = "ftp",
FTPS = "ftps",
FTPIS = "ftpis",
GOOGLE_DRIVE = "google-drive",
ONE_DRIVE = "onedrive",
DNS_SD = "dns-sd",
DAV = "dav",
DAVS = "davs",
DAVSD = "dav+sd",
DAVSSD = "davs+sd",
AFP = "afp",
AFC = "afc",
FILE = "file",
}
---@enum STATE_KEY
local STATE_KEY = {
PREV_CWD = "PREV_CWD",
WHICH_KEYS = "WHICH_KEYS",
CMD_FOUND = "CMD_FOUND",
DBUS_SESSION = "DBUS_SESSION",
ROOT_MOUNTPOINT = "ROOT_MOUNTPOINT",
SAVE_PATH = "SAVE_PATH",
SAVE_PATH_AUTOMOUNTS = "SAVE_PATH_AUTOMOUNTS",
MOUNTS = "MOUNTS",
AUTOMOUNTS = "AUTOMOUNTS",
SAVE_PASSWORD_AUTOCONFIRM = "SAVE_PASSWORD_AUTOCONFIRM",
PASSWORD_VAULT = "PASSWORD_VAULT",
KEY_GRIP = "KEY_GRIP",
INPUT_POSITION = "INPUT_POSITION",
TASKS_LOAD_GDRIVE_FOLDER = "TASKS_LOAD_GDRIVE_FOLDER",
TASKS_LOAD_GDRIVE_FOLDER_RUNNING = "TASKS_LOAD_GDRIVE_FOLDER_RUNNING",
BLACKLIST_DEVICES = "BLACKLIST_DEVICES",
CACHED_LOCAL_PATH_DEVICE = "CACHED_LOCAL_PATH_DEVICE",
}
---@enum ACTION
local ACTION = {
SELECT_THEN_MOUNT = "select-then-mount",
JUMP_TO_DEVICE = "jump-to-device",
JUMP_BACK_PREV_CWD = "jump-back-prev-cwd",
SELECT_THEN_UNMOUNT = "select-then-unmount",
REMOUNT_KEEP_CWD_UNCHANGED = "remount-current-cwd-device",
ADD_MOUNT = "add-mount",
EDIT_MOUNT = "edit-mount",
REMOVE_MOUNT = "remove-mount",
LOAD_GDRIVE_FOLDER = "load-gdrive-folder",
CACHE_LOCAL_PATH_DEVICE = "cache-local-path-device",
AUTOMOUNT_WHEN_CD = "automount-when-cd",
MOUNT_THEN_JUMP_SUBFOLDER = "mount-then-jump-subfolder",
}
---@class (exact) GdriveMountedFolderAttribute
---@field display_name string
---@field is_symlink "FALSE" | "TRUE"
---@field name string
---@field type string
---@field size number
---@field modified number
---@field created number
---@field access number
---@field can_read boolean
---@field can_write boolean
---@field can_execute boolean
---@field can_delete boolean
---@field can_trash boolean
---@field can_rename boolean
---@class (exact) ChildrenFolderGioInfo
---@field display_name string
---@field uri string
---@field unix_mount string
---@field name string
---@field type string
---@field local_path string
---@field attributes GdriveMountedFolderAttribute
---@class (exact) Device
---@field name string
---@field class string?
---@field mounts Mount[]
---@field scheme SCHEME
---@field bus integer?
---@field device integer?
---@field uuid string?
---@field encrypted_uuid string?
---@field service_domain string?
---@field ["unix-device"] string?
---@field owner string?
---@field activation_root string?
---@field uri string
---@field is_manually_added boolean?
---@field can_mount "1"|"0"|nil
---@field can_unmount "1"|"0"
---@field can_eject "1"|"0"
---@field should_automount "1"|"0"
---@field remote_path string?
---@class (exact) Mount
---@field name string
---@field class string?
---@field uri string
---@field scheme SCHEME
---@field bus integer?
---@field device integer?
---@field uuid string?
---@field ["unix-device"] string?
---@field owner string?
---@field default_location string?
---@field can_unmount "1"|"0"|nil
---@field can_eject "1"|"0"|nil
---@field is_shadowed "1"|"0"|nil
-- Encode binary string to hex (e.g., "\xED" => "\\xED")
local function hex_encode(s)
return (s:gsub(".", function(c)
return string.format("\\x%02X", c:byte())
end))
end
-- Decode hex-encoded string (e.g., "\\xED" => "\xED")
local function hex_decode(s)
return (s:gsub("\\x(%x%x)", function(hex)
return string.char(tonumber(hex, 16))
end))
end
local function hex_encode_table(t)
local out = {}
for k, v in pairs(t) do
local new_k = type(k) == "string" and hex_encode(k) or k
local new_v
if type(v) == "table" then
new_v = hex_encode_table(v)
elseif type(v) == "string" then
new_v = hex_encode(v)
else
new_v = v
end
out[new_k] = new_v
end
return out
end
local function hex_decode_table(t)
local out = {}
for k, v in pairs(t) do
local new_k = type(k) == "string" and hex_decode(k) or k
local new_v
if type(v) == "table" then
new_v = hex_decode_table(v)
elseif type(v) == "string" then
new_v = hex_decode(v)
else
new_v = v
end
out[new_k] = new_v
end
return out
end
local set_state_table = ya.sync(function(state, table, key, value)
if type(table) == "string" and type(key) == "string" then
if not state[table] then
state[table] = {}
end
state[table][key] = value
end
end)
local set_state = ya.sync(function(state, key, value)
state[key] = value
end)
local get_state = ya.sync(function(state, key)
return state[key]
end)
local enqueue_task = ya.sync(function(state, task_name, task_data)
if not state[task_name] or type(state[task_name]) ~= "table" then
state[task_name] = {}
end
for _, _task_data in ipairs(state[task_name]) do
if _task_data.folder_to_load == task_data.folder_to_load then
return
end
end
table.insert(state[task_name], task_data)
end)
local dequeue_task = ya.sync(function(state, task_name)
if not state[task_name] or type(state[task_name]) ~= "table" then
return {}
end
return table.remove(state[task_name])
end)
---@param is_password boolean?
local function show_input(title, is_password, value)
local input_value, input_pw_event = ya.input({
title = title,
value = value or "",
obscure = is_password or false,
pos = get_state(STATE_KEY.INPUT_POSITION),
-- TODO: remove this after next yazi released
position = get_state(STATE_KEY.INPUT_POSITION),
})
if input_pw_event ~= 1 then
return nil, nil
end
return input_value, input_pw_event
end
local function error(s, ...)
ya.notify({ title = PLUGIN_NAME, content = string.format(s, ...), timeout = 3, level = "error" })
end
local function info(s, ...)
ya.notify({ title = PLUGIN_NAME, content = string.format(s, ...), timeout = 3, level = "info" })
end
---run any command
---@param cmd string
---@param args string[]
---@param _stdin? Stdio|nil
---@return Error|nil, Output|nil
local function run_command(cmd, args, _stdin)
local stdin = _stdin or Command.INHERIT
local child, cmd_err = Command(cmd)
:arg(args)
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:stdin(stdin)
:stdout(Command.PIPED)
:stderr(Command.PIPED)
:spawn()
if not child then
error("Failed to start `%s` with error: `%s`", cmd, cmd_err)
return cmd_err, nil
end
local output, out_err = child:wait_with_output()
if not output then
error("Cannot read `%s` output, error: `%s`", cmd, out_err)
return out_err, nil
else
return nil, output
end
end
local function is_in_dbus_session()
local dbus_session = get_state(STATE_KEY.DBUS_SESSION)
if dbus_session == nil then
local cha, _ = fs.cha(Url(XDG_RUNTIME_DIR))
dbus_session = cha and true or false
set_state(STATE_KEY.DBUS_SESSION, dbus_session)
end
return dbus_session
end
local function is_cmd_exist(cmd)
local cmd_found = get_state(STATE_KEY.CMD_FOUND .. cmd)
if cmd_found == nil then
local _, output = run_command("which", { cmd })
cmd_found = output and output.status and output.status.success
set_state(STATE_KEY.CMD_FOUND .. cmd, cmd_found)
end
return cmd_found
end
local function pathJoin(...)
-- Detect OS path separator ('\' for Windows, '/' for Unix)
local separator = package.config:sub(1, 1)
local parts = { ... }
local filteredParts = {}
-- Remove empty strings or nil values
for _, part in ipairs(parts) do
if part and part ~= "" then
table.insert(filteredParts, part)
end
end
-- Join the remaining parts with the separator
local path = table.concat(filteredParts, separator)
-- Normalize any double separators (e.g., "folder//file" → "folder/file")
path = path:gsub(separator .. "+", separator)
return path
end
local function is_folder_exist(path)
local err, output = run_command("[", { "-d", path, "]" })
return output and output.status and output.status.success
end
local function tbl_remove_empty(tbl)
local cleaned = {}
for _, v in pairs(tbl) do
if v ~= nil and v ~= "" then
table.insert(cleaned, v)
end
end
return cleaned
end
local current_dir = ya.sync(function()
return tostring(cx.active.current.cwd.path or cx.active.current.cwd)
end)
---@enum PUBSUB_KIND
local PUBSUB_KIND = {
cd = "cd",
hover = "hover",
mounts_changed = PLUGIN_NAME .. "-" .. "mounts-changed",
automounts_changed = PLUGIN_NAME .. "-" .. "automounts-changed",
unmounted = PLUGIN_NAME .. "-" .. "unmounted",
}
--- broadcast through pub sub to other instances
---@param _ table state
---@param pubsub_kind PUBSUB_KIND
---@param data any
---@param to number default = 0 to all instances
local broadcast = ya.sync(function(_, pubsub_kind, data, to)
ps.pub_to(to or 0, pubsub_kind, data)
end)
local is_dir = function(dir_path)
local cha, err = fs.cha(Url(dir_path))
return not err and cha and cha.is_dir
end
---split string by char
---@param s string
---@return string[]
local function string_to_array(s)
local array = {}
for i = 1, #s do
table.insert(array, s:sub(i, i))
end
return array
end
local function is_literal_string(str)
return str and str:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
end
local function tbl_deep_clone(original)
if type(original) ~= "table" then
return original
end
local copy = {}
for key, value in pairs(original) do
copy[tbl_deep_clone(key)] = tbl_deep_clone(value)
end
return copy
end
local function path_quote(path)
if not path or path == "" then
return path
end
local result = "'" .. string.gsub(tostring(path), "'", "'\\''") .. "'"
return result
end
local get_hovered_path = ya.sync(function()
local h = cx.active.current.hovered.path or cx.active.current.hovered
if h then
return tostring(h.url)
end
end)
local function is_secret_vault_available_keyring(unlock_vault_dialog)
local res, err = Command(SECRET_TOOL)
:arg({
"search",
PLUGIN_NAME,
SECRET_VAULT_VERSION,
unlock_vault_dialog and "--unlock" or nil,
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if err or (res and res.stderr and res.stderr:match("^secret%-tool")) then
return false
end
return true
end
local function build_secret_vault_entry_gpg(protocol, user, domain, prefix, port, service_domain)
protocol = protocol and ("/" .. protocol) or ""
user = user and ("/" .. user) or ""
domain = domain and ("/" .. domain) or ""
prefix = prefix and ("/" .. prefix) or ""
port = port and ("/" .. port) or ""
service_domain = service_domain and ("/" .. service_domain) or ""
return PLUGIN_NAME .. "/" .. SECRET_VAULT_VERSION .. protocol .. user .. domain .. port .. prefix .. service_domain
end
local function is_secret_vault_available_gpg(unlock_vault_dialog, is_second_run)
local test_vault_entry = build_secret_vault_entry_gpg("test")
local res, err = Command(SHELL)
:arg({
"-c",
"gpg-connect-agent 'keyinfo " .. get_state(STATE_KEY.KEY_GRIP) .. "' /bye",
})
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if res then
-- Case unlocked
if res.stdout:match(".* KEYINFO [^ ]+ .+ .+ .+ 1 ") or res.stdout:match(".* KEYINFO [^ ]+ .+ .+ .+ .+ C ") then
return true
elseif unlock_vault_dialog and res.stdout:match(".* KEYINFO [^ ]+ .+ .+ .+ - ") then
-- Display gpg unlock TUI window
-- TODO: remove this after next yazi released
local permit = (ui.hide or ya.hide)()
-- Wrap in shell to capture exit code
local full_cmd = string.format("bash -c '%s; echo __EXIT__$?__'", "pass " .. test_vault_entry .. " 2>&1")
local handle = io.popen(full_cmd)
local output = handle:read("*a")
handle:close()
-- Extract exit code
local exit_code = tonumber(output:match("__EXIT__(%d+)__"))
output = output:gsub("__EXIT__%d+__", ""):gsub("%s+$", "") -- clean output
permit:drop()
if output:match("Error: .* is not in the password store") then
res, err = Command(SHELL)
:arg({
"-c",
("printf '%s\n%s\n' " .. path_quote("test") .. " " .. path_quote("test") .. " | ")
.. PASS_TOOL
.. " insert "
.. " -f "
.. path_quote(test_vault_entry),
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if res and res.stderr:match("Error: You must run:") and res.stderr:match("pass init your%-gpg%-id") then
error(NOTIFY_MSG.PASS_INIT_GPG_ID)
return false
end
if
res
and res.stderr:match("encryption failed: No public key")
and res.stderr:match("Password encryption aborted")
then
error(NOTIFY_MSG.MISSING_PUBLIC_KEY_GPG_KEY)
return false
end
if is_second_run or err or (res and res.status and not res.status.success) then
return false
end
return is_secret_vault_available_gpg(unlock_vault_dialog, true)
end
return exit_code == 0
end
end
return false
end
local function is_secret_vault_available(unlock_vault_dialog)
if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.KEYRING then
return is_secret_vault_available_keyring(unlock_vault_dialog)
elseif get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.PASS then
return is_secret_vault_available_gpg(unlock_vault_dialog)
end
return nil
end
local function save_password_keyring(password, protocol, user, domain, prefix, port, service_domain)
if not user or not password or not protocol or not domain then
return false
end
local res, err = Command(SHELL)
:arg({
"-c",
("printf %s " .. path_quote(password) .. " | ")
.. SECRET_TOOL
.. " store "
.. " --label "
.. path_quote(
protocol
.. "://"
.. user
.. "@"
.. domain
.. (port and (":" .. port) or "")
.. (prefix and ("/" .. prefix) or "")
.. (service_domain and ("/" .. service_domain) or "")
)
.. " "
.. PLUGIN_NAME
.. " "
.. SECRET_VAULT_VERSION
.. " protocol "
.. protocol
.. " user "
.. path_quote(user)
.. " domain "
.. path_quote(domain)
.. (port and (" port " .. port) or "")
.. (prefix and (" prefix " .. path_quote(prefix)) or "")
.. (service_domain and (" service_domain " .. path_quote(service_domain)) or ""),
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if res and res.stderr then
if res.stderr:match("secret%-tool: Cannot get secret of a locked object") then
error(NOTIFY_MSG.SECRET_VAULT_LOCKED)
return false
elseif res.stderr:match("secret%-tool: The name is not activatable") then
error(NOTIFY_MSG.HEADLESS_DETECTED)
return false
elseif res.stderr:match("secret%-tool: Cannot autolaunch D%-Bus") then
error(NOTIFY_MSG.HEADLESS_DETECTED)
return false
end
end
if err or (res and not res.status.success and res.stderr) then
error(NOTIFY_MSG.SAVE_PASSWORD_FAILED, res and res.stderr or err)
return false
end
info(NOTIFY_MSG.SAVE_PASSWORD_SUCCESS)
return true
end
local function save_password_gpg(password, protocol, user, domain, prefix, port, service_domain)
if not user or not password or not protocol or not domain then
return false
end
local res, err = Command(SHELL)
:arg({
"-c",
("printf '%s\n%s\n' " .. path_quote(password) .. " " .. path_quote(password) .. " | ")
.. PASS_TOOL
.. " insert "
.. " -f "
.. path_quote(build_secret_vault_entry_gpg(protocol, user, domain, prefix, port, service_domain)),
})
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if err or (res and res.status and not res.status.success) then
error(NOTIFY_MSG.SAVE_PASSWORD_FAILED, res and res.stderr or err)
return false
end
info(NOTIFY_MSG.SAVE_PASSWORD_SUCCESS)
return true
end
local function save_password(password, protocol, user, domain, prefix, port, service_domain)
if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.KEYRING then
return save_password_keyring(password, protocol, user, domain, prefix, port, service_domain)
elseif get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.PASS then
return save_password_gpg(password, protocol, user, domain, prefix, port, service_domain)
end
return false
end
local function lookup_password_keyring(protocol, user, domain, prefix, port, service_domain)
if not user or not protocol or not domain then
return nil
end
local res, err = Command(SECRET_TOOL)
:arg(tbl_remove_empty({
"lookup",
PLUGIN_NAME,
SECRET_VAULT_VERSION,
"protocol",
protocol,
"user",
user,
"domain",
domain,
port and "port" or nil,
port and port or nil,
prefix and "prefix" or nil,
prefix and prefix or nil,
service_domain and "service_domain" or nil,
service_domain and service_domain or nil,
}))
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if not err and res and res.status and res.status.success then
return res.stdout
end
return nil
end
local function lookup_password_gpg(protocol, user, domain, prefix, port, service_domain)
if not user or not protocol or not domain then
return nil
end
local res, err = Command(PASS_TOOL)
:arg({
build_secret_vault_entry_gpg(protocol, user, domain, prefix, port, service_domain),
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if not err and res and res.status and res.status.success then
return res.stdout
end
return nil
end
local function lookup_password(protocol, user, domain, prefix, port, service_domain)
if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.KEYRING then
return lookup_password_keyring(protocol, user, domain, prefix, port, service_domain)
elseif get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.PASS then
return lookup_password_gpg(protocol, user, domain, prefix, port, service_domain)
end
return nil
end
local function clear_password_keyring(protocol, user, domain, prefix, port, service_domain)
local res, err = Command(SECRET_TOOL)
:arg(tbl_remove_empty({
"clear",
PLUGIN_NAME,
SECRET_VAULT_VERSION,
protocol and "protocol" or nil,
protocol and protocol or nil,
user and "user" or nil,
user and user or nil,
domain and "domain" or nil,
domain and domain or nil,
port and "port" or nil,
port and port or nil,
prefix and "prefix" or nil,
prefix and prefix or nil,
service_domain and "service_domain" or nil,
service_domain and service_domain or nil,
}))
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if res and res.stderr and not res.status.success then
if res.stderr:match("secret%-tool: Cannot get secret of a locked object") then
error(NOTIFY_MSG.SECRET_VAULT_LOCKED)
return false
elseif res.stderr:match("secret%-tool: The name is not activatable") then
error(NOTIFY_MSG.HEADLESS_DETECTED)
return false
elseif res.stderr:match("secret%-tool: Cannot autolaunch D%-Bus") then
error(NOTIFY_MSG.HEADLESS_DETECTED)
return false
end
end
if not err and res and res.status and res.status.success then
return true
end
return false
end
local function clear_password_gpg(protocol, user, domain, prefix, port, service_domain)
local res, err = Command(PASS_TOOL)
:arg({
"rm",
"-r",
"-f",
build_secret_vault_entry_gpg(protocol, user, domain, prefix, port, service_domain),
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if not err and res and res.status and res.status.success then
return true
end
return false
end
local function clear_password(protocol, user, domain, prefix, port, service_domain)
if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.KEYRING then
return clear_password_keyring(protocol, user, domain, prefix, port, service_domain)
elseif get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.PASS then
return clear_password_gpg(protocol, user, domain, prefix, port, service_domain)
end
return false
end
local function extract_info_from_uri(s)
local user
local domain
local port
local service_domain
-- Attempt 1: Look for user@domain:port first (if it exists)
local scheme, temp_user, temp_domain_part = s:match("^([^:]+)://([^@/]+)@([^/]+)")
if temp_user and temp_domain_part then
-- If user@domain found, the domain might be followed by a comma
-- We want the part before the first comma or slash in the domain part
user = temp_user
-- domain:port
domain, port = temp_domain_part:match("^([^:/]+):([^:/]+)")
if not port or port == "" then
port = nil
domain = temp_domain_part:match("^[^/]+") or temp_domain_part
end
else
-- Attempt 2: No user@domain, so try to get domain from the start (before first comma or slash)
scheme, temp_domain_part = s:match("^([^:]+)://([^/]+)")
if temp_domain_part then
domain, port = temp_domain_part:match("^([^:/]+):([^:/]+)")
if not port or port == "" then
port = nil
domain = temp_domain_part:match("^[^/]+") or temp_domain_part
end
end
end
local ssl = (s:match("^davs") or s:match("^ftps") or s:match("^ftpis") or s:match("^https")) and true or false
local prefix = s:match(".*" .. (is_literal_string(domain) or "") .. (port and ":" .. port or "") .. "/(.+)$") or nil
if user then
local _service_domain, _user = user:match("^([^;]+);(.+)")
user = _service_domain and _user or user
service_domain = _service_domain and _service_domain
end
return scheme, domain, user, ssl, prefix, port, service_domain
end
local function is_mountpoint_belong_to_volume(mount, volume)
return mount.is_shadowed ~= "1"
and mount.scheme
and mount.scheme == volume.scheme
and (
(mount.uri and mount.uri == volume.uri)
or (mount.uuid and mount.uuid == volume.uuid)
or (mount["unix-device"] and mount["unix-device"] == volume["unix-device"])
or (mount.bus and mount.device and mount.bus == volume.bus and mount.device == volume.device)
-- Case fstab with `x-gvfs-show`
or (mount.name and mount.name == volume.name and mount.scheme == SCHEME.FILE)
)
end
--- Parser for gvfs mounted gdrive folder info
---@param data string
---@return ChildrenFolderGioInfo[]
local function parse_gdrive_mountpoint_info(data)
---@type table[]
local result = {}
---@type table?
local current_item = nil
-- Iterate over each line of the input data.
for line in data:gmatch("([^\r\n]+)") do
-- First, try to match an indented attribute line, e.g., " standard::type: 2"
local indent, attr_key, attr_value = line:match("^(%s+)[^:]+::([^:]+):%s+(.+)$")
if indent and attr_key and attr_value then
-- This is an attribute line. Add it to the current item's attribute sub-table.
if current_item then
-- Ensure the 'attributes' sub-table exists.
current_item.attributes = current_item.attributes or {}
-- Clean the key (e.g., "standard::type" -> "standard_type") and assign the value.
local clean_attr_key = attr_key:match("^%s*(.-)%s*$"):gsub("[%s%-]", "_")
if clean_attr_key == "can_read" then
current_item.attributes[clean_attr_key] = attr_value == "TRUE"
elseif clean_attr_key == "can_write" then
current_item.attributes[clean_attr_key] = attr_value == "TRUE"
elseif clean_attr_key == "can_execute" then
current_item.attributes[clean_attr_key] = attr_value == "TRUE"
elseif clean_attr_key == "can_delete" then
current_item.attributes[clean_attr_key] = attr_value == "TRUE"
elseif clean_attr_key == "can_trash" then
current_item.attributes[clean_attr_key] = attr_value == "TRUE"
elseif clean_attr_key == "can_rename" then
current_item.attributes[clean_attr_key] = attr_value == "TRUE"
else
current_item.attributes[clean_attr_key] = attr_value
end
end
else
-- If it's not an attribute, try to match a top-level key-value pair.
-- The value part `(.*)` allows for keys with no value, like "attributes:".
local key, value = line:match("^%s*([^:]+):%s*(.*)$")
if key then
-- Clean the key by trimming whitespace and replacing spaces with underscores.
local clean_key = key:match("^%s*(.-)%s*$"):gsub("%s", "_")
-- The "display_name" key marks the beginning of a new item block.
if clean_key == "display_name" then
-- If we were processing a previous item, add it to the result list.
if current_item then
table.insert(result, current_item)
end
-- Start a new table for the new item.
current_item = {}
end
-- Add the property to the current item, provided we are inside an item block
-- and the key has a value.
if current_item and value and value ~= "" then
current_item[clean_key] = value
end
end
end
end
-- After the loop, if there's a pending item, add it to the results.
if current_item then
table.insert(result, current_item)
end
return result
end
local function get_gdrive_children_folder_info(parent_folder)
if not parent_folder then
return
end
parent_folder = tostring(parent_folder)
if
parent_folder:match(
"^" .. is_literal_string(get_state(STATE_KEY.ROOT_MOUNTPOINT) .. "/google-drive:host=gmail.com")
)
then
local output, err = Command(SHELL)
:arg({
"-c",
"gio info -a standard::display-name,standard::name,time::modified,time::created,time::access,standard::type,standard::is-symlink,standard::size,access::can-read,access::can-write,access::can-execute,access::can-delete,access::can-delete,access::can-rename "
.. path_quote(parent_folder)
.. "/*",
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:env("LC_ALL", "C")
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if err then
error(NOTIFY_MSG.READING_GVFS_MOUNTED_FOLDER_ERROR, tostring(err))
return nil
end
if output and output.status.code == 0 then
return parse_gdrive_mountpoint_info(output.stdout)
end
end
end
--- Display real folder name using virtual files/folders
---@param children_folder_info ChildrenFolderGioInfo[]
local function display_virtual_children(cwd, children_folder_info)
if children_folder_info == nil or #children_folder_info == 0 then
return
end
local id = ya.id("ft")
local files = {}
ya.emit("update_files", { op = fs.op("part", { id = id, url = Url(cwd), files = {} }) })
for _, gdrive_mountpoint_info in ipairs(children_folder_info) do
if id ~= nil and id ~= "" then
-- TODO: WORKAROUND: cwd prefix `search://` can't be joined
-- local url = Url(cwd):join(gdrive_mountpoint_info.display_name)
local url =
Url(tostring(cwd.path or cwd) .. path_separator .. tostring(gdrive_mountpoint_info.display_name))
local kind = gdrive_mountpoint_info.type == "directory" and 1
or (
gdrive_mountpoint_info.type == "regular" and 0
or (gdrive_mountpoint_info.type == "shortcut" and 4 or 16)
)
-- Fix for Google AI Studio using max unsigned 64-bit integer value
if gdrive_mountpoint_info.attributes.access == "18446744073709551615" then
gdrive_mountpoint_info.attributes.access = nil
end
if gdrive_mountpoint_info.attributes.modified == "18446744073709551615" then
gdrive_mountpoint_info.attributes.modified = nil
end
if gdrive_mountpoint_info.attributes.created == "18446744073709551615" then
gdrive_mountpoint_info.attributes.created = nil
end
table.insert(
files,
File({
url = url,
cha = Cha({
kind = kind,
mode = tonumber(kind == 1 and "40700" or "100644", 8),
len = tonumber(gdrive_mountpoint_info.attributes.size or "0"),
atime = gdrive_mountpoint_info.attributes.access,
mtime = gdrive_mountpoint_info.attributes.modified,
ctime = gdrive_mountpoint_info.attributes.created,
btime = gdrive_mountpoint_info.attributes.modified,
}),
})
)
end
end
ya.emit("update_files", { op = fs.op("part", { id = id, url = Url(cwd), files = files }) })
ya.emit("update_files", { op = fs.op("done", { id = id, url = Url(cwd), cha = fs.cha(Url(cwd), false) }) })
end
local function parse_devices(raw_input)
local volumes = {}
local mounts = {}
local predefined_mounts = tbl_deep_clone(get_state(STATE_KEY.MOUNTS)) or {}
local blacklist_devices = get_state(STATE_KEY.BLACKLIST_DEVICES) or {}
---@type Device?
local current_volume = nil
---@type Mount?
local current_mount = nil
for m = #predefined_mounts, 1, -1 do
local pm = predefined_mounts[m]
if pm.uri then
-- replace ssh:// with sftp://
if pm.scheme == SCHEME.SSH then
pm.scheme = SCHEME.SFTP
pm.uri = pm.uri:gsub("^ssh://", "sftp://")
end
-- keep remote path, for jumping. Only scheme that doesn't support remote path
if
pm.scheme == SCHEME.SFTP
or pm.scheme == SCHEME.FTP
or pm.scheme == SCHEME.FTPS
or pm.scheme == SCHEME.FTPIS
or pm.scheme == SCHEME.DNS_SD
or pm.scheme == SCHEME.AFC
then
pm.remote_path = pm.uri:match("^[%w+]+://[^/]+/(.+)$") or ""
-- Remove remote path
pm.uri = pm.uri:gsub("^(%a+://[^/]+).*", "%1")
end
end
end
for line in raw_input:gmatch("[^\r\n]+") do
local clean_line = line:match("^%s*(.-)%s*$")
-- Match volume(0)
local volume_name = clean_line:match("^Volume%(%d+%):%s*(.+)$")
if line:match("^Drive%(%d+%):") then
current_mount = nil
current_volume = nil
elseif volume_name then
current_mount = nil
current_volume = { name = volume_name, mounts = {} }
table.insert(volumes, current_volume)
-- Match mount(0)
elseif clean_line:match("^Mount%(%d+%):") then
current_mount = nil
local mount_indent, mount_name, mount_uri = line:match("^(%s*)Mount%(%d+%):%s*(.-)%s*->%s*(.+)$")
if not mount_name then
mount_name = clean_line:match("^Mount%(%d+%):%s*(.+)$")
end
if not mount_indent or #mount_indent == 0 then
current_volume = nil
end
current_mount = { name = mount_name or "", uri = mount_uri or "" }
local mount_base_uri = mount_uri:gsub("/+$", "")
local mount_uri_port = mount_base_uri:match(":%d+$")
for m = #predefined_mounts, 1, -1 do
local predefined_mount_base_uri = predefined_mounts[m].uri:gsub("/+$", "")
if
predefined_mount_base_uri == mount_base_uri
or (
not mount_uri_port
and predefined_mount_base_uri:gsub(":%d+$", "") == mount_base_uri:gsub(":%d+$", "")
)
then
current_mount = table.remove(predefined_mounts, m)
end
end
if not current_mount.scheme then
for _, value in pairs(SCHEME) do
if mount_uri:match("^" .. is_literal_string(value) .. ":") then
current_mount.scheme = value
end
end
end
-- Case mtp/gphoto2 usb bus dev
if mount_uri then
local protocol, bus, device = mount_uri:match("^(%w+)://%[usb:(%d+),(%d+)%]/")
-- Attach to mount or volume
if protocol and (protocol == SCHEME.MTP or protocol == SCHEME.GPHOTO2) and bus and device then
current_mount.bus = bus
current_mount.device = device
end
-- file:///run/media/huyhoang/6412-E4B2
local owner, label_or_uuid = mount_uri:match("^file:///run/media/(.+)/(.+)")
if owner and label_or_uuid then
current_mount.owner = owner
current_mount.uuid = current_volume and (current_volume.uuid or current_volume["unix-device"])
or label_or_uuid
current_mount["unix-device"] = current_volume and current_volume["unix-device"]
end
end
table.insert(mounts, current_mount)
-- Match key=value metadata
else
local key, value = clean_line:match("^(%S+)%s*=%s*(.+)$")
if not key or not value then
key, value = clean_line:match("^(%S+)%s*:%s*'(.-)'$")
if key == "uuid" and value then
current_volume.encrypted_uuid = value
end
end
if key and value then
-- Attach to mount or volume
local target = current_mount or current_volume
if target then
if key ~= "name" or not target[key] then
target[key] = value
end
end
else
local bus, device = line:match(".*:%s*'/dev/bus/usb/(%d+)/(%d+)'")
-- Attach to mount or volume
if bus and device then
local target = current_mount or current_volume
if target then
target.bus = bus
target.device = device
end
end
end
end
end
-- Remove shadowed mounts and attach mount points to volumes
for i = #volumes, 1, -1 do
local v = volumes[i]
if v.activation_root then
v.uri = v.activation_root
end
if not v.uuid and v.class == "device" and v["unix-device"] then
v.uuid = v["unix-device"]
v.scheme = SCHEME.FILE
-- Attach scheme to volume
elseif (v.can_mount == "0") or v.uuid and not v.uuid:match("([^:]+)://(.+)") then
-- NOTE: can_mount == "0" means that the volume is mounted fstab
v.scheme = SCHEME.FILE
else
for _, value in pairs(SCHEME) do
if
(v.uri and v.uri:match("^" .. is_literal_string(value) .. ":"))
or (v.uuid and v.uuid:match("^" .. is_literal_string(value) .. "://"))
then
v.scheme = value
end
end
end
-- NOTE: Remove volumes without scheme (fstab)
if volumes[i] and not v.scheme then
table.remove(volumes, i)
end
-- Attach mount points to volume, then remove it from mounts array
for j = #mounts, 1, -1 do
if is_mountpoint_belong_to_volume(mounts[j], v) then
table.insert(v.mounts, table.remove(mounts, j))
end
end
end
-- Remove shadowed mounts and attach unmapped mounts to itself
for _, m in ipairs(mounts) do
if m.is_shadowed ~= "1" and m.uri then
m.mounts = { tbl_deep_clone(m) }
table.insert(volumes, m)
end
end
for _, m in ipairs(predefined_mounts) do
m.mounts = { tbl_deep_clone(m) }
table.insert(volumes, m)
end
if #blacklist_devices > 0 then
for i = #volumes, 1, -1 do
local v = volumes[i]
for _, bl_device in pairs(blacklist_devices) do
if type(bl_device) == "string" and v.name == bl_device then
table.remove(volumes, i)
elseif type(bl_device) == "table" then
for bl_device_prop, bl_device_value in pairs(bl_device) do
if v[bl_device_prop] ~= bl_device_value then
goto skip_bl_device
end
end
table.remove(volumes, i)
end
::skip_bl_device::
end
end
end
return volumes
end
---@param device Device
---@return string|nil
local function get_mounted_path(device)
if not device then
return nil
end
if device.uri or (#device.mounts > 0 and device.mounts[1].uri) then
local res, err = Command(SHELL)
:arg({
"-c",
"gio info "
.. path_quote(device.uri or (#device.mounts > 0 and device.mounts[1].uri))
.. ' | grep -E "^local path: "',
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:env("LC_ALL", "C")
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if err or (res and res.status and not res.status.success) then
return nil
end
return res.stdout:match("^local path: (.+)$"):gsub("\n", "") or nil
end
return nil
end
local function can_device_umount(device)
if device and device.mounts and #device.mounts > 0 then
for _, mount in ipairs(device.mounts) do
if mount.can_unmount == "1" or mount.can_eject == "1" then
return true
end
end
end
end
---@param device Device
local function is_mounted(device)
if can_device_umount(device) then
return true
end
local mountpath = get_mounted_path(device)
return mountpath and is_folder_exist(mountpath)
end
---mount device
---@param opts {device: Device, username?:string, password?: string, service_domain?: string, is_pw_saved?: boolean, skipped_secret_vault?: boolean,max_retry?: integer, retries?: integer, anonymous?: boolean}
---@return boolean
local function mount_device(opts)
local device = opts.device
local max_retry = opts.max_retry or 3
local retries = opts.retries or 0
local password = opts.password
local is_pw_saved = opts.is_pw_saved
local skipped_secret_vault = opts.skipped_secret_vault
local username = opts.username
local anonymous = opts.anonymous
local service_domain = opts.service_domain
local error_msg = nil
local auths = ""
local auth_string_format = ""
if password or username then
if username then
auths = path_quote(username)
auth_string_format = auth_string_format .. "%s\n"
end
if service_domain then
auths = auths .. " " .. path_quote(service_domain)
auth_string_format = auth_string_format .. "%s\n"
end
if password then
auths = auths .. " " .. path_quote(password)
auth_string_format = auth_string_format .. "%s\n"
end
end
local res, err = Command(SHELL)
:arg({
"-c",
(auth_string_format ~= "" and "printf " .. path_quote(auth_string_format) .. " " .. auths .. " | " or "")
.. " gio mount "
.. (anonymous and "-a " or "")
.. (device.uuid and ("-d " .. device.uuid) or path_quote(device.uri)),
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:env("LC_ALL", "C")
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
local mount_success = res and res.status and res.status.success
if mount_success then
info(NOTIFY_MSG.MOUNT_SUCCESS, device.name)
if password and not is_pw_saved and not skipped_secret_vault and is_secret_vault_available() then
local confirmed_save_password = get_state(STATE_KEY.SAVE_PASSWORD_AUTOCONFIRM)
or ya.confirm({
title = ui.Line("Remember password?"):style(th.confirm.title),
body = ui.Text({
ui.Line(""),
ui.Line("Press Yes to save password to secret vault."):style(th.confirm.content),
ui.Line(""),
})
:align(ui.Align.CENTER)
:wrap(ui.Wrap.YES),
-- TODO: remove this after next yazi released
content = ui.Text({
ui.Line(""),
ui.Line("Press Yes to save password to secret vault."):style(th.confirm.content),
ui.Line(""),
})
:align(ui.Align.CENTER)
:wrap(ui.Wrap.YES),
pos = { "center", w = 70, h = 10 },
})
if confirmed_save_password then
if device.uuid then
-- case hard drive
save_password(password, device.scheme, device.uuid, device.uuid)
else
local scheme, domain, user, _, prefix, port, _service_domain = extract_info_from_uri(device.uri)
save_password(
password,
scheme,
username or user,
domain,
prefix,
port,
service_domain or (username or user or ""):match("^([^;]+);") or _service_domain
)
end
end
end
return true
elseif res and res.status.code == 2 then
if res.stderr:match(".*volume doesn’t implement mount.*") then
error_msg = string.format(NOTIFY_MSG.HEADLESS_DETECTED)
retries = max_retry
end
if res.stderr:match(".*is already mounted.*") then
return true
end
local stdout = res.stdout
if stdout:find("\nUser: \n") or stdout:find("\nUser %[.*%]: \n") then
if retries < max_retry then
username, _ = show_input(
"Enter username "
.. (device.name and ("(" .. device.name .. ")") or (device.uri and ("(" .. device.uri .. ")") or ""))
.. ":",
false,
username or stdout:match("User %[(.*)%]:") or ""
)
if username == nil then
return false
elseif username == "" then
-- Case using anonymous user
anonymous = true
end
else
error_msg = string.format(
NOTIFY_MSG.MOUNT_ERROR_USERNAME,
(device.name or "NO_NAME") .. " (" .. (device.scheme or "UNKNOWN_SCHEME") .. ")"
)
end
end
if
(device.scheme == SCHEME.SMB or device.scheme == SCHEME.DNS_SD or device.scheme == SCHEME.DAVSD)
and (
stdout:find("\nDomain: \n")
or stdout:find("\nDomain %[.*%]: \n")
or stdout:find("\nUser: \n")
or stdout:find("\nUser %[.*%]: \n")
)
then
if retries < max_retry then
service_domain, _ = show_input(
"Enter Domain "
.. (device.name and ("(" .. device.name .. ")") or (device.uri and ("(" .. device.uri .. ")") or ""))
.. ":",
false,
service_domain or stdout:match("Domain %[(.*)%]:") or "WORKGROUP"
)
if service_domain == nil then
return false
end
else
error_msg = string.format(
NOTIFY_MSG.MOUNT_ERROR_USERNAME,
(device.name or "NO_NAME") .. " (" .. device.scheme .. ")"
)
end
end
if
not anonymous
and (
stdout:find("Password: \n")
or stdout:find("\nUser: \n")
or stdout:find("\nUser %[.*%]: \n")
or stdout:find("\nDomain: \n")
or stdout:find("\nDomain %[.*%]: \n")
)
then
if username ~= opts.username or (username == nil and is_pw_saved == nil) then
-- Prevent showing gpg passphrase twice
if not skipped_secret_vault and not is_secret_vault_available(true) then
skipped_secret_vault = true
end
if not skipped_secret_vault then
if device.uuid then
-- case hard drive
password = lookup_password(device.scheme, device.uuid, device.uuid)
else
local scheme, domain, user, _, prefix, port, _service_domain = extract_info_from_uri(device.uri)
password = lookup_password(
scheme,
username or user,
domain,
prefix,
port,
service_domain or (username or user or ""):match("^([^;]+);") or _service_domain
)
end
is_pw_saved = password ~= nil
if is_pw_saved then
info(NOTIFY_MSG.RETRIVE_PASSWORD_SUCCESS)
end
end
end
if retries < max_retry then
if not is_pw_saved then
password, _ = show_input(
"Enter password "
.. (device.name and ("(" .. device.name .. ")") or (device.uri and ("(" .. device.uri .. ")") or ""))
.. ":",
true
)
if password == nil then
return false
end
end
else
error_msg = string.format(
NOTIFY_MSG.MOUNT_ERROR_PASSWORD,
(device.name or "NO_NAME") .. " (" .. device.scheme .. ")"
)
end
end
end
-- show notification after get max retry
if retries >= max_retry then
error(
tostring(error_msg or (res and not res.status.success and res.stderr) or err or "Error: Unknown"):gsub(
"%%",
"%%%%"
)
)
return false
end
-- Increase retries every run
retries = retries + 1
return mount_device({
device = device,
retries = retries,
max_retry = max_retry,
password = password,
is_pw_saved = is_pw_saved,
skipped_secret_vault = skipped_secret_vault,
username = username,
service_domain = service_domain,
anonymous = anonymous,
})
end
--- Return list of connected devices
---@return Device[]
local function list_gvfs_device()
---@type Device[]
local devices = {}
local _, res = run_command("gio", { "mount", "-li" })
if res and res.status then
if res.status.success then
devices = parse_devices(res.stdout)
end
end
return devices
end
---Return list of mounted devices
---@param status DEVICE_CONNECT_STATUS
---@param filter? function
---@return Device[]
local function list_gvfs_device_by_status(status, filter)
local devices = list_gvfs_device()
local devices_filtered = {}
for _, d in ipairs(devices) do
if filter and not filter(d) then
goto continue
end
local mounted = is_mounted(d)
if status == DEVICE_CONNECT_STATUS.MOUNTED and mounted then
table.insert(devices_filtered, d)
end
if status == DEVICE_CONNECT_STATUS.NOT_MOUNTED and not mounted then
table.insert(devices_filtered, d)
end
::continue::
end
return devices_filtered
end
--- Unmount a mounted device/uri
---@param device Device
---@param eject boolean? eject = true if user want to safty unplug the device
---@param force boolean? Ignore outstanding file operations when unmounting or ejecting
---@return boolean
local function unmount_gvfs(device, eject, force, max_retry, retries)
if not device then
return true
end
max_retry = max_retry or 3
retries = retries or 0
local unmount_method = "-u"
if eject then
unmount_method = "-e"
end
for _, mount in ipairs(device.mounts ~= nil and device.mounts or { device }) do
local cmd_err, res =
run_command("gio", tbl_remove_empty({ "mount", unmount_method, force and "-f" or nil, mount.uri }))
if cmd_err or (res and not res.status.success) then
if eject and res and res.stderr:find("mount doesn.*t implement .*eject.* or .*eject_with_operation.*") then
return unmount_gvfs(device, false, force)
end
if retries >= max_retry then
error(NOTIFY_MSG.UNMOUNT_ERROR, tostring(res and (res.stderr or res.stdout)))
return false
end
return unmount_gvfs(device, eject, force, max_retry, retries + 1)
end
if not cmd_err and res and res.status.success then
if eject then
info(NOTIFY_MSG.EJECT_SUCCESS, mount.name)
else
info(NOTIFY_MSG.UNMOUNT_SUCCESS, mount.name)
end
end
return true
end
end
---show which key to select device from list
---@param devices Device|Mount[]
---@return number|nil
local function select_device_which_key(devices)
local which_keys = get_state(STATE_KEY.WHICH_KEYS)
or "1234567890qwertyuiopasdfghjklzxcvbnm-=[]\\;',./!@#$%^&*()_+{}|:\"<>?"
local allow_key_array = string_to_array(which_keys)
local cands = {}
for idx, d in ipairs(devices) do
if idx > #allow_key_array then
break
end
table.insert(cands, {
on = tostring(allow_key_array[idx]),
desc = (d.name or "NO_NAME") .. " (" .. (d.scheme or "UNKNOWN_SCHEME") .. ")",
})
end
if #cands == 0 then
return
end
local selected_idx = ya.which({
cands = cands,
})
if selected_idx and selected_idx > 0 then
return selected_idx
end
end
---@param path string
---@return string?
local function get_gio_uri_from_local_path(path)
local root_mountpoint = get_state(STATE_KEY.ROOT_MOUNTPOINT)
if
not path:match("^" .. is_literal_string(root_mountpoint) .. "(.+)$")
and not path:match("^" .. is_literal_string(GVFS_ROOT_MOUNTPOINT_FILE) .. "(.+)$")
then
return nil
end
local path_info, err = Command(SHELL)
:arg({
"-c",
"gio info " .. path_quote(path) .. ' | grep "^uri:"',
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:env("LC_ALL", "C")
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:output()
if not path_info or err or (path_info and path_info.status and not path_info.status.success) then
return nil
end
local path_uri = path_info.stdout:match("^uri: (.+)$"):gsub("\n", "")
if not path_uri then
return nil
end
return path_uri
end
---@param path string
---@param state_key STATE_KEY.CACHED_LOCAL_PATH_DEVICE|STATE_KEY.AUTOMOUNTS|string
---@param devices Device[]?
---@return Device?
local function get_device_from_local_path(path, state_key, devices)
local local_path = path:match("^" .. is_literal_string(get_state(STATE_KEY.ROOT_MOUNTPOINT)) .. "/[^/]+")
or path:match("^" .. is_literal_string(GVFS_ROOT_MOUNTPOINT_FILE) .. "/[^/]+")
-- NOTE: Get cached gio uri or get it from command "gio info"
---@type Device?
local cached_device = get_state(state_key)[local_path]
local device_props_to_compare = {
"class",
"label",
"scheme",
"encrypted_uuid",
"service_domain",
"uri",
"remote_path",
}
local mount_props_to_compare = {
"class",
"uri",
"scheme",
"uuid",
"remote_path",
"name",
}
if cached_device then
if not devices then
devices = list_gvfs_device()
end
local matched_device = nil
for _, device in ipairs(devices) do
matched_device = device
for _, prop in ipairs(device_props_to_compare) do
if cached_device[prop] ~= device[prop] then
matched_device = nil
goto jump_to_compare_mounts
end
end
if matched_device then
return matched_device
end
::jump_to_compare_mounts::
for _, mount in ipairs(device.mounts) do
matched_device = device
for _, prop in ipairs(mount_props_to_compare) do
if cached_device[prop] ~= mount[prop] then
matched_device = nil
break
end
end
end
end
return matched_device
else
local gio_uri = get_gio_uri_from_local_path(path)
if not gio_uri then
return nil
end
if not devices then
devices = list_gvfs_device()
end
for _, device in ipairs(devices) do
if device.uri and gio_uri:match("^" .. is_literal_string(device.uri) .. ".*") then
return device
end
for _, mount in ipairs(device.mounts) do
if mount.uri and gio_uri:match("^" .. is_literal_string(mount.uri) .. ".*") then
return device
end
end
end
return nil
end
return nil
end
--- Jump to device mountpoint
---@param device Device?
local function jump_to_device_mountpoint_action(device, retry, automount)
if automount then
-- Trigger Automount
local automount_script = HOME .. "/.config/yazi/plugins/gvfs.yazi/assets/automount.sh"
run_command("chmod", { "+x", automount_script })
run_command(automount_script, {})
end
if not device then
local list_devices = list_gvfs_device_by_status(DEVICE_CONNECT_STATUS.MOUNTED)
device = #list_devices == 1 and list_devices[1] or nil
if not device then
local selected_device_idx = select_device_which_key(list_devices)
if not selected_device_idx then
return
end
device = list_devices[selected_device_idx]
end
end
if not device then
info(NOTIFY_MSG.LIST_DEVICES_EMPTY)
return
end
local mnt_path = get_mounted_path(device)
if not mnt_path and not retry then
-- case hard drive encrypted -> mount uuid changed
local matched_devices = list_gvfs_device_by_status(DEVICE_CONNECT_STATUS.MOUNTED, function(d)
return (device.uuid and (d.encrypted_uuid == device.uuid or d.uuid == device.uuid))
or (device.uri and d.uri == device.uri)
end)
if #matched_devices >= 1 then
device = matched_devices[1]
return jump_to_device_mountpoint_action(device, true, automount)
end
end
if mnt_path then
set_state(STATE_KEY.PREV_CWD, current_dir())
if device.remote_path then
mnt_path = pathJoin(mnt_path, device.remote_path)
end
ya.emit("cd", { mnt_path, raw = true })
else
error(NOTIFY_MSG.DEVICE_IS_DISCONNECTED)
end
end
--- Jump to previous directory
local function jump_to_prev_cwd_action()
local prev_cwd = get_state(STATE_KEY.PREV_CWD)
if not prev_cwd then
return
end
if is_dir(prev_cwd) then
set_state(STATE_KEY.PREV_CWD, current_dir())
ya.emit("cd", { prev_cwd, raw = true })
else
error(NOTIFY_MSG.CANT_ACCESS_PREV_CWD)
end
end
--- mount action
---@param opts { jump: boolean?, device: Device? }?
local function mount_action(opts)
local selected_device
-- Let user select a device if device is not specified
if not opts or not opts.device then
local list_devices = list_gvfs_device_by_status(DEVICE_CONNECT_STATUS.NOT_MOUNTED, function(d)
return true
end)
-- NOTE: Automatically select the first device if there is only one device
selected_device = #list_devices == 1 and list_devices[1] or nil
if not selected_device then
local selected_device_idx = select_device_which_key(list_devices)
if not selected_device_idx then
return
end
selected_device = list_devices[selected_device_idx]
end
if #list_devices == 0 then
-- If every devices are mounted, then jump to the first one
local root_mountpoint = get_state(STATE_KEY.ROOT_MOUNTPOINT)
local list_devices_mounted = list_gvfs_device_by_status(DEVICE_CONNECT_STATUS.MOUNTED, function(d)
if d.scheme == SCHEME.FILE then
return (
d.mounts
and #d.mounts >= 1
and d.mounts[1].uri
and (
d.mounts[1].uri:match("^" .. is_literal_string("file://" .. root_mountpoint) .. "(.+)$")
or d.mounts[1].uri:match(
"^" .. is_literal_string("file://" .. GVFS_ROOT_MOUNTPOINT_FILE) .. "(.+)$"
)
)
)
end
return true
end)
selected_device = #list_devices_mounted >= 1 and list_devices_mounted[1] or nil
if not selected_device then
info(NOTIFY_MSG.LIST_DEVICES_EMPTY)
return
end
--NOTE: Fall-safe x-gvfs-show
local status, err = Command(SHELL)
:arg({
"-c",
"gio info " .. path_quote(
selected_device.uri or (#selected_device.mounts > 0 and selected_device.mounts[1].uri)
) .. ' | grep -E "^unix mount:.*x-gvfs-show.*"',
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:env("LC_ALL", "C")
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:status()
if err then
error(NOTIFY_MSG.UNMOUNT_ERROR, tostring(err))
return
end
if status and status.code == 0 then
error(NOTIFY_MSG.UNMOUNT_ERROR, "Can't unmount device mounted through /etc/fstab")
return
end
jump_to_device_mountpoint_action(selected_device)
return true
end
else
selected_device = opts.device
end
if not selected_device then
return
end
local success = mount_device({
device = selected_device,
})
if success and opts and opts.jump then
jump_to_device_mountpoint_action(selected_device)
end
return success
end
local save_tab_hovered = ya.sync(function()
local hovered_item_per_tab = {}
for _, tab in ipairs(cx.tabs) do
local is_virtual = Url(tab.current.cwd).scheme and Url(tab.current.cwd).scheme.is_virtual
table.insert(hovered_item_per_tab, {
id = (type(tab.id) == "number" or type(tab.id) == "string") and tab.id or tab.id.value,
cwd = tostring((is_virtual and tab.current.cwd or tab.current.cwd.path) or tab.current.cwd),
})
end
return hovered_item_per_tab
end)
local redirect_unmounted_tab_to_home = ya.sync(function(_, unmounted_url, notify)
if not unmounted_url or unmounted_url == "" then
return
end
-- broadcast to other instances
if notify then
broadcast(PUBSUB_KIND.unmounted, hex_encode(unmounted_url))
end
for _, tab in ipairs(cx.tabs) do
local is_virtual = Url(tab.current.cwd).scheme and Url(tab.current.cwd).scheme.is_virtual
if ((is_virtual and tab.current.cwd or tab.current.cwd.path) or tab.current.cwd):starts_with(unmounted_url) then
ya.emit("cd", {
HOME,
tab = (type(tab.id) == "number" or type(tab.id) == "string") and tab.id or tab.id.value,
raw = true,
})
end
end
end)
--- unmount action
--- @param device Device?
--- @param eject boolean? eject = true if user want to safty unplug the device
--- @param force boolean? Ignore outstanding file operations when unmounting or ejecting
local function unmount_action(device, eject, force)
local selected_device
if not device then
local root_mountpoint = get_state(STATE_KEY.ROOT_MOUNTPOINT)
local list_devices = list_gvfs_device_by_status(DEVICE_CONNECT_STATUS.MOUNTED, function(d)
if d.scheme == SCHEME.FILE then
return (
d.mounts
and #d.mounts >= 1
and d.mounts[1].uri
and (
d.mounts[1].uri:match("^" .. is_literal_string("file://" .. root_mountpoint) .. "(.+)$")
or d.mounts[1].uri:match(
"^" .. is_literal_string("file://" .. GVFS_ROOT_MOUNTPOINT_FILE) .. "(.+)$"
)
)
)
end
return true
end)
-- NOTE: Automatically select the first device if there is only one device
selected_device = #list_devices == 1 and list_devices[1] or nil
if not selected_device then
local selected_device_idx = select_device_which_key(list_devices)
if not selected_device_idx then
return
end
selected_device = list_devices[selected_device_idx]
end
if not selected_device and #list_devices == 0 then
info(NOTIFY_MSG.LIST_DEVICES_EMPTY)
return
end
--NOTE: Fall-safe x-gvfs-show
if selected_device then
local status, err = Command(SHELL)
:arg({
"-c",
"gio info " .. path_quote(
selected_device.uri or (#selected_device.mounts > 0 and selected_device.mounts[1].uri)
) .. ' | grep -E "^unix mount:.*x-gvfs-show.*"',
})
:env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
:env("LC_ALL", "C")
:stderr(Command.PIPED)
:stdout(Command.PIPED)
:status()
if err then
error(NOTIFY_MSG.UNMOUNT_ERROR, tostring(err))
return
end
if status and status.code == 0 then
error(NOTIFY_MSG.UNMOUNT_ERROR, "Can't unmount device mounted through /etc/fstab")
return
end
end
end
if device then
selected_device = device
end
if not selected_device then
return
end
local mount_path = get_mounted_path(selected_device)
if selected_device.uuid and mount_path then
redirect_unmounted_tab_to_home(mount_path, true)
end
local success = unmount_gvfs(selected_device, eject, force)
if success and not selected_device.uuid and mount_path then
redirect_unmounted_tab_to_home(mount_path, true)
-- cd to home for all tabs within the device, and then restore the tabs location
end
end
---@param state_key STATE_KEY.CACHED_LOCAL_PATH_DEVICE|STATE_KEY.AUTOMOUNTS|string
---@param jump_location string?
---@param tab_id number?
local function remount_keep_cwd_unchanged_action(state_key, jump_location, tab_id, hide_cant_remount_message)
local cwd = jump_location or current_dir()
local root_mountpoint = get_state(STATE_KEY.ROOT_MOUNTPOINT)
if
not cwd:match("^" .. is_literal_string(root_mountpoint) .. "(.+)$")
and not cwd:match("^" .. is_literal_string(GVFS_ROOT_MOUNTPOINT_FILE) .. "(.+)$")
then
return nil
end
local devices = list_gvfs_device()
local current_tab_device = get_device_from_local_path(cwd, state_key, devices)
if not current_tab_device then
info(NOTIFY_MSG.DEVICE_IS_DISCONNECTED)
return
end
if is_mounted(current_tab_device) then
if not hide_cant_remount_message then
info(NOTIFY_MSG.CANT_REMOUNT_DEVICE, current_tab_device.name)
end
return current_tab_device
end
local tabs = save_tab_hovered()
local saved_matched_tabs = {}
-- cd to home for all tabs within the device, and then restore the tabs location
if state_key == STATE_KEY.CACHED_LOCAL_PATH_DEVICE then
for _, tab in ipairs(tabs) do
local tab_device = get_device_from_local_path(tostring(tab.cwd), state_key, devices)
if tab_device and tab_device.name == current_tab_device.name then
table.insert(saved_matched_tabs, tab)
ya.emit("cd", {
root_mountpoint,
tab = tab.id,
raw = true,
})
end
end
end
mount_action({ jump = false, device = current_tab_device })
if state_key == STATE_KEY.CACHED_LOCAL_PATH_DEVICE then
for _, tab in ipairs(saved_matched_tabs) do
ya.emit("cd", {
tostring(tab.cwd),
tab = tab.id,
raw = true,
})
end
else
if jump_location then
local jump_location_cha, _ = fs.cha(Url(jump_location))
ya.emit((jump_location_cha and jump_location_cha.is_dir) and "cd" or "reveal", {
tostring(jump_location),
no_dummy = true,
raw = true,
tab = tab_id,
})
end
end
return current_tab_device
end
local save_automount_devices = function()
local automounts = get_state(STATE_KEY.AUTOMOUNTS)
local save_path = Url(get_state(STATE_KEY.SAVE_PATH_AUTOMOUNTS))
-- create parent directories
local save_path_created, err_create = fs.create("dir_all", save_path.parent)
if err_create then
error(NOTIFY_MSG.CANT_CREATE_SAVE_FOLDER, tostring(save_path.parent))
end
-- save mounts to file
if save_path_created then
local _, err_write = fs.write(save_path, ya.json_encode(hex_encode_table(automounts)))
if err_write then
error(NOTIFY_MSG.CANT_SAVE_DEVICES, tostring(save_path))
end
end
-- trigger update to other instances
broadcast(PUBSUB_KIND.automounts_changed, hex_encode_table(automounts))
end
local save_mounts = function()
local mounts = get_state(STATE_KEY.MOUNTS)
local mounts_to_save = {}
for idx = #mounts, 1, -1 do
if mounts[idx].is_manually_added then
-- save name, uri, scheme, is_manually_added
table.insert(mounts_to_save, 1, {
name = mounts[idx].name,
uri = mounts[idx].uri,
scheme = mounts[idx].scheme,
is_manually_added = mounts[idx].is_manually_added,
})
end
end
local save_path = Url(get_state(STATE_KEY.SAVE_PATH))
-- create parent directories
local save_path_created, err_create = fs.create("dir_all", save_path.parent)
if err_create then
error(NOTIFY_MSG.CANT_CREATE_SAVE_FOLDER, tostring(save_path.parent))
end
-- save mounts to file
if save_path_created then
local _, err_write = fs.write(save_path, ya.json_encode(hex_encode_table(mounts)))
if err_write then
error(NOTIFY_MSG.CANT_SAVE_DEVICES, tostring(save_path))
end
end
-- trigger update to other instances
broadcast(PUBSUB_KIND.mounts_changed, hex_encode_table(mounts))
end
local read_hex_decoded_file_content = function(save_path)
local file = io.open(save_path, "r")
if file == nil then
return {}
end
local encoded_data = file:read("*all")
file:close()
return hex_decode_table(ya.json_decode(encoded_data))
end
---@param is_edit boolean?
local function add_or_edit_mount_action(is_edit)
---@type any
local mount = {
is_manually_added = true,
}
local selected_idx = nil
if is_edit then
local mounts = get_state(STATE_KEY.MOUNTS)
if #mounts == 0 then
info(NOTIFY_MSG.LIST_MOUNTS_EMPTY)
return
end
selected_idx = select_device_which_key(mounts)
if not selected_idx then
return
end
mount = tbl_deep_clone(mounts[selected_idx])
end
mount.uri, _ = show_input("Enter mount URI:", false, mount.uri)
if mount.uri == nil then
return
end
mount.uri = mount.uri:gsub("^%s*(.-)%s*$", "%1")
if mount.uri == nil then
return
elseif mount.uri == "" then
error(NOTIFY_MSG.URI_CANT_BE_EMPTY)
end
mount.uri = mount.uri:gsub("/$", "")
-- sftp://test@192.168.1.2
-- ftp://huyhoang@192.168.1.2:9999/
local _scheme, uri = string.match(mount.uri, "([^:]+)://(.+)")
local scheme
if not _scheme or not uri then
error(NOTIFY_MSG.URI_IS_INVALID)
return
end
for _, value in pairs(SCHEME) do
if _scheme == value and value ~= SCHEME.FILE then
scheme = value
end
end
mount.scheme = scheme
if not scheme then
error(NOTIFY_MSG.UNSUPPORTED_SCHEME, tostring(_scheme))
return
end
if scheme == SCHEME.GOOGLE_DRIVE or scheme == SCHEME.ONE_DRIVE then
error(NOTIFY_MSG.UNSUPPORTED_MANUALLY_MOUNT_SCHEME, tostring(_scheme))
return
end
if scheme == SCHEME.SMB then
mount.service_domain = mount.uri:match("^smb://([^;]+);")
if not mount.service_domain then
mount.service_domain, _ = show_input("Enter SMB domain:", false, "WORKGROUP")
end
if not mount.service_domain then
return
end
end
mount.name, _ = show_input("Enter display name:", false, mount.name or uri)
if mount.name == nil then
return
end
if mount.name == "" or not mount.name then
error(NOTIFY_MSG.DISPLAY_NAME_CANT_BE_EMPTY)
return
end
local mounts = get_state(STATE_KEY.MOUNTS)
if selected_idx then
if is_mounted(mounts[selected_idx]) then
unmount_action(mounts[selected_idx], false, true)
end
if mount.uri ~= mounts[selected_idx].uri then
local old_scheme, old_domain, old_user, _, old_prefix, old_port, old_service_domain =
extract_info_from_uri(mounts[selected_idx].uri)
if old_domain and old_scheme and is_secret_vault_available(true) then
clear_password(
old_scheme,
old_user,
old_domain,
old_prefix,
old_port,
old_service_domain or mounts[selected_idx].service_domain
)
end
end
mounts[selected_idx] = mount
info(NOTIFY_MSG.UPDATED_MOUNT_URI, mount.name)
else
table.insert(mounts, mount)
info(NOTIFY_MSG.ADDED_MOUNT_URI, mount.name)
end
set_state(STATE_KEY.MOUNTS, mounts)
save_mounts()
end
local function load_gdrive_folder_action()
if
get_state(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER_RUNNING)
or not get_state(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER)
or #get_state(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER) == 0
then
return
end
set_state(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER_RUNNING, true)
local data = dequeue_task(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER)
local folder_to_load_raw = data.folder_to_load
local self_load = data.self_load
local folder_to_load = Url(folder_to_load_raw)
local gdrive_mountpoint_children_info = get_gdrive_children_folder_info(folder_to_load_raw)
if gdrive_mountpoint_children_info then
display_virtual_children(folder_to_load, gdrive_mountpoint_children_info)
end
-- TODO: parent
if self_load then
-- ya.sleep(0.5)
-- if folder_to_load.parent and folder_to_load.parent.parent then
-- local gdrive_children_info_parents = get_gdrive_children_folder_info(folder_to_load.parent.parent)
-- if gdrive_children_info_parents then
-- display_virtual_children(folder_to_load.parent.parent, gdrive_children_info_parents)
-- end
-- end
-- TODO: Load preview
-- ya.sleep(0.5)
-- local hovered_folder_cwd = current_hovered_folder_cwd()
-- if hovered_folder_cwd then
-- local gdrive_children_info_parents = get_gdrive_children_folder_info(hovered_folder_cwd)
-- if gdrive_children_info_parents then
-- display_virtual_children(hovered_folder_cwd, gdrive_children_info_parents)
-- end
-- end
end
-- ya.sleep(0.5)
set_state(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER_RUNNING, false)
load_gdrive_folder_action()
end
local function cache_local_path_device_action(local_path)
local device_matched = get_device_from_local_path(local_path, STATE_KEY.CACHED_LOCAL_PATH_DEVICE)
if device_matched then
set_state_table(STATE_KEY.CACHED_LOCAL_PATH_DEVICE, local_path, device_matched)
end
end
---@param enabled boolean?
local function toggle_automount_when_cd_action(enabled)
local hovered_path = get_hovered_path()
local is_virtual = Url(hovered_path).scheme and Url(hovered_path).scheme.is_virtual
if is_virtual then
return
end
local local_path = hovered_path:match("^" .. is_literal_string(get_state(STATE_KEY.ROOT_MOUNTPOINT)) .. "/[^/]+")
or hovered_path:match("^" .. is_literal_string(GVFS_ROOT_MOUNTPOINT_FILE) .. "/[^/]+")
if local_path then
if not enabled then
set_state_table(STATE_KEY.AUTOMOUNTS, local_path, nil)
else
local device_matched = get_device_from_local_path(local_path, STATE_KEY.AUTOMOUNTS)
if device_matched then
if device_matched.mounts and #device_matched.mounts > 0 and not can_device_umount(device_matched) then
info(NOTIFY_MSG.CANT_AUTOMOUNT, device_matched.name)
return
end
--
set_state_table(STATE_KEY.AUTOMOUNTS, local_path, device_matched)
end
end
info(NOTIFY_MSG.AUTOMOUNT_WHEN_CD_STATE, enabled and "Enabled" or "Disabled", tostring(local_path))
save_automount_devices()
end
end
local function remove_mount_action()
local mounts = get_state(STATE_KEY.MOUNTS)
if #mounts == 0 then
info(NOTIFY_MSG.LIST_MOUNTS_EMPTY)
return
end
local selected_idx = select_device_which_key(mounts)
if not selected_idx then
return
end
local mount = mounts[selected_idx]
if not mount then
return
end
if is_mounted(mount) then
unmount_action(mount, false, true)
end
-- run_command("gio", { "mount", "-u", mount.uri })
local old_scheme, old_domain, old_user, _, old_prefix, old_port, old_service_domain =
extract_info_from_uri(mounts[selected_idx].uri)
if old_domain and old_scheme and is_secret_vault_available(true) then
clear_password(
old_scheme,
old_user,
old_domain,
old_prefix,
old_port,
old_service_domain or mount.service_domain
)
end
local removed_mount = table.remove(mounts, selected_idx)
set_state(STATE_KEY.MOUNTS, mounts)
info(NOTIFY_MSG.REMOVED_MOUNT_URI, removed_mount.name)
save_mounts()
end
---setup function in yazi/init.lua
---@param opts {}
function M:setup(opts)
local st = self
if opts and opts.key_grip then
st[STATE_KEY.KEY_GRIP] = opts.key_grip
end
st[STATE_KEY.INPUT_POSITION] = (opts and type(opts.input_position) == "table") and opts.input_position
or { "top-center", y = 3, w = 60 }
if opts and opts.save_password_autoconfirm == true then
st[STATE_KEY.SAVE_PASSWORD_AUTOCONFIRM] = true
end
if opts and opts.password_vault then
st[STATE_KEY.PASSWORD_VAULT] = (
opts and (opts.password_vault == PASSWORD_VAULT.KEYRING or opts.password_vault == PASSWORD_VAULT.PASS)
) and opts.password_vault
else
-- TODO: REMOVE: backwards compatibility
if opts and opts.enabled_keyring == true then
st[STATE_KEY.PASSWORD_VAULT] = PASSWORD_VAULT.KEYRING
end
end
if opts and opts.which_keys and type(opts.which_keys) == "string" then
st[STATE_KEY.WHICH_KEYS] = opts.which_keys
end
local save_path = os.getenv("HOME") .. "/.config/yazi/gvfs.private"
if type(opts) == "table" then
save_path = opts.save_path or save_path
end
local save_path_automounts = os.getenv("HOME") .. "/.config/yazi/gvfs_automounts.private"
if type(opts) == "table" then
save_path_automounts = opts.save_path_automounts or save_path_automounts
end
st[STATE_KEY.SAVE_PATH] = save_path
st[STATE_KEY.SAVE_PATH_AUTOMOUNTS] = save_path_automounts
-- NOTE: Use pathJoin to avoid double slashes and end with a slash
if opts and opts.root_mountpoint and type(opts.root_mountpoint) == "string" then
st[STATE_KEY.ROOT_MOUNTPOINT] = pathJoin(opts.root_mountpoint)
else
st[STATE_KEY.ROOT_MOUNTPOINT] = pathJoin(GVFS_ROOT_MOUNTPOINT)
end
st[STATE_KEY.BLACKLIST_DEVICES] = (opts and opts.blacklist_devices and type(opts.blacklist_devices) == "table")
and opts.blacklist_devices
or {}
st[STATE_KEY.MOUNTS] = read_hex_decoded_file_content(get_state(STATE_KEY.SAVE_PATH))
st[STATE_KEY.AUTOMOUNTS] = read_hex_decoded_file_content(get_state(STATE_KEY.SAVE_PATH_AUTOMOUNTS)) or {}
st[STATE_KEY.CACHED_LOCAL_PATH_DEVICE] = {}
ps.sub(PUBSUB_KIND.cd, function(payload)
local cwd = cx.active.current.cwd
if not cwd then
return
end
local cwd_raw = tostring(cwd)
if
cwd_raw:match(
"^" .. is_literal_string(get_state(STATE_KEY.ROOT_MOUNTPOINT) .. "/google-drive:host=gmail.com")
)
then
enqueue_task(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER, { folder_to_load = cwd_raw, self_load = true })
local args = ya.quote(ACTION.LOAD_GDRIVE_FOLDER)
ya.emit("plugin", {
self._id,
args,
})
end
local local_path = cwd_raw:match("^" .. is_literal_string(st[STATE_KEY.ROOT_MOUNTPOINT]) .. "/[^/]+")
or cwd_raw:match("^" .. is_literal_string(GVFS_ROOT_MOUNTPOINT_FILE) .. "/[^/]+")
if local_path then
if not st[STATE_KEY.CACHED_LOCAL_PATH_DEVICE][local_path] then
local args = ya.quote(ACTION.CACHE_LOCAL_PATH_DEVICE) .. " " .. ya.quote(local_path)
ya.emit("plugin", {
self._id,
args,
})
end
if st[STATE_KEY.AUTOMOUNTS][local_path] and not st[STATE_KEY.AUTOMOUNTS][local_path].locked_automount then
st[STATE_KEY.AUTOMOUNTS][local_path].locked_automount = true
local args = ya.quote(ACTION.MOUNT_THEN_JUMP_SUBFOLDER)
.. " "
.. ya.quote(cwd_raw)
.. " "
.. ya.quote(local_path)
.. " "
.. ya.quote(payload.tab)
ya.emit("plugin", {
self._id,
args,
})
end
end
end)
-- ps.sub(PUBSUB_KIND.hover, function()
-- local cwd = current_hovered_folder_cwd()
-- if not cwd then
-- return
-- end
-- local cwd_raw = tostring(cwd)
-- if
-- cwd_raw:match(
-- "^" .. is_literal_string(get_state(STATE_KEY.ROOT_MOUNTPOINT) .. "/google-drive:host=gmail.com")
-- )
-- then
-- enqueue_task(STATE_KEY.TASKS_LOAD_GDRIVE_FOLDER, { folder_to_load = cwd_raw, self_load = false })
-- local args = ya.quote(ACTION.LOAD_GDRIVE_FOLDER)
-- ya.emit("plugin", {
-- self._id,
-- args,
-- })
-- end
-- end)
ps.sub_remote(PUBSUB_KIND.mounts_changed, function(mounts)
set_state(STATE_KEY.MOUNTS, hex_decode_table(mounts))
end)
ps.sub_remote(PUBSUB_KIND.automounts_changed, function(automounts)
set_state(STATE_KEY.AUTOMOUNTS, hex_decode_table(automounts))
end)
ps.sub_remote(PUBSUB_KIND.unmounted, function(unmounted_url)
redirect_unmounted_tab_to_home(hex_decode(unmounted_url))
end)
end
---@param job {args: unknown[], args: {jump: boolean?, eject: boolean?, force: boolean?, automount: boolean?, disabled: boolean?}}
function M:entry(job)
if not is_cmd_exist("gio") then
error(NOTIFY_MSG.CMD_NOT_FOUND, "gio")
return
end
-- Fallback to pass if in headless session
if not is_in_dbus_session() then
error(NOTIFY_MSG.HEADLESS_DETECTED)
return
end
if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.KEYRING then
if not is_cmd_exist(SECRET_TOOL) then
set_state(STATE_KEY.PASSWORD_VAULT, nil)
end
end
if get_state(STATE_KEY.PASSWORD_VAULT) == PASSWORD_VAULT.PASS then
if not is_cmd_exist(GPG_TOOL) or not is_cmd_exist(PASS_TOOL) or get_state(STATE_KEY.KEY_GRIP) == nil then
set_state(STATE_KEY.PASSWORD_VAULT, nil)
end
end
local action = job.args[1]
-- Select a device then mount
if action == ACTION.SELECT_THEN_MOUNT then
local jump = job.args.jump or false
mount_action({ jump = jump })
-- select a device then unmount
elseif action == ACTION.SELECT_THEN_UNMOUNT then
local eject = job.args.eject or false
local force = job.args.force or false
unmount_action(nil, eject, force)
-- remount device within current cwd
elseif action == ACTION.REMOUNT_KEEP_CWD_UNCHANGED then
remount_keep_cwd_unchanged_action(STATE_KEY.CACHED_LOCAL_PATH_DEVICE)
-- select a device then go to its mounted point
elseif action == ACTION.JUMP_TO_DEVICE then
local automount = job.args.automount or false
jump_to_device_mountpoint_action(nil, nil, automount)
elseif action == ACTION.JUMP_BACK_PREV_CWD then
jump_to_prev_cwd_action()
elseif action == ACTION.ADD_MOUNT then
add_or_edit_mount_action()
elseif action == ACTION.EDIT_MOUNT then
add_or_edit_mount_action(true)
elseif action == ACTION.REMOVE_MOUNT then
remove_mount_action()
elseif action == ACTION.AUTOMOUNT_WHEN_CD then
local enabled = not job.args.disabled
toggle_automount_when_cd_action(enabled)
-- NOTE: Switch to new async when it's merged to yazi nightly
elseif action == ACTION.LOAD_GDRIVE_FOLDER then
load_gdrive_folder_action()
elseif action == ACTION.CACHE_LOCAL_PATH_DEVICE then
local local_path = job.args[2]
if fs.cha(Url(local_path)) then
cache_local_path_device_action(local_path)
end
elseif action == ACTION.MOUNT_THEN_JUMP_SUBFOLDER then
local subfolder_path = job.args[2]
local local_path = job.args[3]
local tab_id = job.args[4]
local local_path_cha, _ = fs.cha(Url(local_path))
local cached_device = get_state(STATE_KEY.AUTOMOUNTS)[local_path]
if local_path_cha and local_path_cha.is_dir then
-- NOTE: Skip automount
cached_device.locked_automount = false
set_state_table(STATE_KEY.AUTOMOUNTS, local_path, cached_device)
return
end
if cached_device then
-- Update cached device with new data
local new_cached_device =
remount_keep_cwd_unchanged_action(STATE_KEY.AUTOMOUNTS, subfolder_path, tab_id, true)
if new_cached_device then
cached_device = new_cached_device
end
end
cached_device.locked_automount = false
set_state_table(STATE_KEY.AUTOMOUNTS, local_path, cached_device)
end
-- TODO: remove this after next yazi released
(ui.render or ya.render)()
end
return M