488 lines
15 KiB
JavaScript
488 lines
15 KiB
JavaScript
|
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
|
|||
|
//
|
|||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|||
|
|
|||
|
import Gio from 'gi://Gio';
|
|||
|
import GLib from 'gi://GLib';
|
|||
|
import GObject from 'gi://GObject';
|
|||
|
|
|||
|
import Config from '../../config.js';
|
|||
|
import Plugin from '../plugin.js';
|
|||
|
|
|||
|
|
|||
|
export const Metadata = {
|
|||
|
label: _('SFTP'),
|
|||
|
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SFTP',
|
|||
|
description: _('Browse the paired device filesystem'),
|
|||
|
incomingCapabilities: ['kdeconnect.sftp'],
|
|||
|
outgoingCapabilities: ['kdeconnect.sftp.request'],
|
|||
|
actions: {
|
|||
|
mount: {
|
|||
|
label: _('Mount'),
|
|||
|
icon_name: 'folder-remote-symbolic',
|
|||
|
|
|||
|
parameter_type: null,
|
|||
|
incoming: ['kdeconnect.sftp'],
|
|||
|
outgoing: ['kdeconnect.sftp.request'],
|
|||
|
},
|
|||
|
unmount: {
|
|||
|
label: _('Unmount'),
|
|||
|
icon_name: 'media-eject-symbolic',
|
|||
|
|
|||
|
parameter_type: null,
|
|||
|
incoming: ['kdeconnect.sftp'],
|
|||
|
outgoing: ['kdeconnect.sftp.request'],
|
|||
|
},
|
|||
|
},
|
|||
|
};
|
|||
|
|
|||
|
|
|||
|
const MAX_MOUNT_DIRS = 12;
|
|||
|
|
|||
|
|
|||
|
/**
|
|||
|
* SFTP Plugin
|
|||
|
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sftp
|
|||
|
* https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SftpPlugin
|
|||
|
*/
|
|||
|
const SFTPPlugin = GObject.registerClass({
|
|||
|
GTypeName: 'GSConnectSFTPPlugin',
|
|||
|
}, class SFTPPlugin extends Plugin {
|
|||
|
|
|||
|
_init(device) {
|
|||
|
super._init(device, 'sftp');
|
|||
|
|
|||
|
this._gmount = null;
|
|||
|
this._mounting = false;
|
|||
|
|
|||
|
// A reusable launcher for ssh processes
|
|||
|
this._launcher = new Gio.SubprocessLauncher({
|
|||
|
flags: (Gio.SubprocessFlags.STDOUT_PIPE |
|
|||
|
Gio.SubprocessFlags.STDERR_MERGE),
|
|||
|
});
|
|||
|
|
|||
|
// Watch the volume monitor
|
|||
|
this._volumeMonitor = Gio.VolumeMonitor.get();
|
|||
|
|
|||
|
this._mountAddedId = this._volumeMonitor.connect(
|
|||
|
'mount-added',
|
|||
|
this._onMountAdded.bind(this)
|
|||
|
);
|
|||
|
|
|||
|
this._mountRemovedId = this._volumeMonitor.connect(
|
|||
|
'mount-removed',
|
|||
|
this._onMountRemoved.bind(this)
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
get gmount() {
|
|||
|
if (this._gmount === null && this.device.connected) {
|
|||
|
const host = this.device.channel.host;
|
|||
|
|
|||
|
const regex = new RegExp(
|
|||
|
`sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`
|
|||
|
);
|
|||
|
|
|||
|
for (const mount of this._volumeMonitor.get_mounts()) {
|
|||
|
const uri = mount.get_root().get_uri();
|
|||
|
|
|||
|
if (regex.test(uri)) {
|
|||
|
this._gmount = mount;
|
|||
|
this._addSubmenu(mount);
|
|||
|
this._addSymlink(mount);
|
|||
|
|
|||
|
break;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
return this._gmount;
|
|||
|
}
|
|||
|
|
|||
|
connected() {
|
|||
|
super.connected();
|
|||
|
|
|||
|
// Only enable for Lan connections
|
|||
|
if (this.device.channel.constructor.name === 'LanChannel') { // FIXME: Circular import workaround
|
|||
|
if (this.settings.get_boolean('automount'))
|
|||
|
this.mount();
|
|||
|
} else {
|
|||
|
this.device.lookup_action('mount').enabled = false;
|
|||
|
this.device.lookup_action('unmount').enabled = false;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
handlePacket(packet) {
|
|||
|
switch (packet.type) {
|
|||
|
case 'kdeconnect.sftp':
|
|||
|
if (packet.body.hasOwnProperty('errorMessage'))
|
|||
|
this._handleError(packet);
|
|||
|
else
|
|||
|
this._handleMount(packet);
|
|||
|
|
|||
|
break;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
_onMountAdded(monitor, mount) {
|
|||
|
if (this._gmount !== null || !this.device.connected)
|
|||
|
return;
|
|||
|
|
|||
|
const host = this.device.channel.host;
|
|||
|
const regex = new RegExp(`sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`);
|
|||
|
const uri = mount.get_root().get_uri();
|
|||
|
|
|||
|
if (!regex.test(uri))
|
|||
|
return;
|
|||
|
|
|||
|
this._gmount = mount;
|
|||
|
this._addSubmenu(mount);
|
|||
|
this._addSymlink(mount);
|
|||
|
}
|
|||
|
|
|||
|
_onMountRemoved(monitor, mount) {
|
|||
|
if (this.gmount !== mount)
|
|||
|
return;
|
|||
|
|
|||
|
this._gmount = null;
|
|||
|
this._removeSubmenu();
|
|||
|
}
|
|||
|
|
|||
|
async _listDirectories(mount) {
|
|||
|
const file = mount.get_root();
|
|||
|
|
|||
|
const iter = await file.enumerate_children_async(
|
|||
|
Gio.FILE_ATTRIBUTE_STANDARD_NAME,
|
|||
|
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
|
|||
|
GLib.PRIORITY_DEFAULT,
|
|||
|
this.cancellable);
|
|||
|
|
|||
|
const infos = await iter.next_files_async(MAX_MOUNT_DIRS,
|
|||
|
GLib.PRIORITY_DEFAULT, this.cancellable);
|
|||
|
iter.close_async(GLib.PRIORITY_DEFAULT, null, null);
|
|||
|
|
|||
|
const directories = {};
|
|||
|
|
|||
|
for (const info of infos) {
|
|||
|
const name = info.get_name();
|
|||
|
directories[name] = `${file.get_uri()}${name}/`;
|
|||
|
}
|
|||
|
|
|||
|
return directories;
|
|||
|
}
|
|||
|
|
|||
|
_onAskQuestion(op, message, choices) {
|
|||
|
op.reply(Gio.MountOperationResult.HANDLED);
|
|||
|
}
|
|||
|
|
|||
|
_onAskPassword(op, message, user, domain, flags) {
|
|||
|
op.reply(Gio.MountOperationResult.HANDLED);
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Handle an error reported by the remote device.
|
|||
|
*
|
|||
|
* @param {Core.Packet} packet - a `kdeconnect.sftp`
|
|||
|
*/
|
|||
|
_handleError(packet) {
|
|||
|
this.device.showNotification({
|
|||
|
id: 'sftp-error',
|
|||
|
title: _('%s reported an error').format(this.device.name),
|
|||
|
body: packet.body.errorMessage,
|
|||
|
icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
|
|||
|
priority: Gio.NotificationPriority.HIGH,
|
|||
|
});
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Mount the remote device using the provided information.
|
|||
|
*
|
|||
|
* @param {Core.Packet} packet - a `kdeconnect.sftp`
|
|||
|
*/
|
|||
|
async _handleMount(packet) {
|
|||
|
try {
|
|||
|
// Already mounted or mounting
|
|||
|
if (this.gmount !== null || this._mounting)
|
|||
|
return;
|
|||
|
|
|||
|
this._mounting = true;
|
|||
|
|
|||
|
// Ensure the private key is in the keyring
|
|||
|
await this._addPrivateKey();
|
|||
|
|
|||
|
// Create a new mount operation
|
|||
|
const op = new Gio.MountOperation({
|
|||
|
username: packet.body.user || null,
|
|||
|
password: packet.body.password || null,
|
|||
|
password_save: Gio.PasswordSave.NEVER,
|
|||
|
});
|
|||
|
|
|||
|
op.connect('ask-question', this._onAskQuestion);
|
|||
|
op.connect('ask-password', this._onAskPassword);
|
|||
|
|
|||
|
// This is the actual call to mount the device
|
|||
|
const host = this.device.channel.host;
|
|||
|
const uri = `sftp://${host}:${packet.body.port}/`;
|
|||
|
const file = Gio.File.new_for_uri(uri);
|
|||
|
|
|||
|
await file.mount_enclosing_volume(GLib.PRIORITY_DEFAULT, op,
|
|||
|
this.cancellable);
|
|||
|
} catch (e) {
|
|||
|
// Special case when the GMount didn't unmount properly but is still
|
|||
|
// on the same port and can be reused.
|
|||
|
if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.ALREADY_MOUNTED))
|
|||
|
return;
|
|||
|
|
|||
|
// There's a good chance this is a host key verification error;
|
|||
|
// regardless we'll remove the key for security.
|
|||
|
this._removeHostKey(this.device.channel.host);
|
|||
|
logError(e, this.device.name);
|
|||
|
} finally {
|
|||
|
this._mounting = false;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Add GSConnect's private key identity to the authentication agent so our
|
|||
|
* identity can be verified by Android during private key authentication.
|
|||
|
*
|
|||
|
* @return {Promise} A promise for the operation
|
|||
|
*/
|
|||
|
async _addPrivateKey() {
|
|||
|
const ssh_add = this._launcher.spawnv([
|
|||
|
Config.SSHADD_PATH,
|
|||
|
GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
|
|||
|
]);
|
|||
|
|
|||
|
const [stdout] = await ssh_add.communicate_utf8_async(null,
|
|||
|
this.cancellable);
|
|||
|
|
|||
|
if (ssh_add.get_exit_status() !== 0)
|
|||
|
debug(stdout.trim(), this.device.name);
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Remove all host keys from ~/.ssh/known_hosts for @host in the port range
|
|||
|
* used by KDE Connect (1739-1764).
|
|||
|
*
|
|||
|
* @param {string} host - A hostname or IP address
|
|||
|
*/
|
|||
|
async _removeHostKey(host) {
|
|||
|
for (let port = 1739; port <= 1764; port++) {
|
|||
|
try {
|
|||
|
const ssh_keygen = this._launcher.spawnv([
|
|||
|
Config.SSHKEYGEN_PATH,
|
|||
|
'-R',
|
|||
|
`[${host}]:${port}`,
|
|||
|
]);
|
|||
|
|
|||
|
const [stdout] = await ssh_keygen.communicate_utf8_async(null,
|
|||
|
this.cancellable);
|
|||
|
|
|||
|
const status = ssh_keygen.get_exit_status();
|
|||
|
|
|||
|
if (status !== 0) {
|
|||
|
throw new Gio.IOErrorEnum({
|
|||
|
code: Gio.io_error_from_errno(status),
|
|||
|
message: `${GLib.strerror(status)}\n${stdout}`.trim(),
|
|||
|
});
|
|||
|
}
|
|||
|
} catch (e) {
|
|||
|
logError(e, this.device.name);
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/*
|
|||
|
* Mount menu helpers
|
|||
|
*/
|
|||
|
_getUnmountSection() {
|
|||
|
if (this._unmountSection === undefined) {
|
|||
|
this._unmountSection = new Gio.Menu();
|
|||
|
|
|||
|
const unmountItem = new Gio.MenuItem();
|
|||
|
unmountItem.set_label(Metadata.actions.unmount.label);
|
|||
|
unmountItem.set_icon(new Gio.ThemedIcon({
|
|||
|
name: Metadata.actions.unmount.icon_name,
|
|||
|
}));
|
|||
|
unmountItem.set_detailed_action('device.unmount');
|
|||
|
this._unmountSection.append_item(unmountItem);
|
|||
|
}
|
|||
|
|
|||
|
return this._unmountSection;
|
|||
|
}
|
|||
|
|
|||
|
_getFilesMenuItem() {
|
|||
|
if (this._filesMenuItem === undefined) {
|
|||
|
// Files menu icon
|
|||
|
const emblem = new Gio.Emblem({
|
|||
|
icon: new Gio.ThemedIcon({name: 'emblem-default'}),
|
|||
|
});
|
|||
|
|
|||
|
const mountedIcon = new Gio.EmblemedIcon({
|
|||
|
gicon: new Gio.ThemedIcon({name: 'folder-remote-symbolic'}),
|
|||
|
});
|
|||
|
mountedIcon.add_emblem(emblem);
|
|||
|
|
|||
|
// Files menu item
|
|||
|
this._filesMenuItem = new Gio.MenuItem();
|
|||
|
this._filesMenuItem.set_detailed_action('device.mount');
|
|||
|
this._filesMenuItem.set_icon(mountedIcon);
|
|||
|
this._filesMenuItem.set_label(_('Files'));
|
|||
|
}
|
|||
|
|
|||
|
return this._filesMenuItem;
|
|||
|
}
|
|||
|
|
|||
|
async _addSubmenu(mount) {
|
|||
|
try {
|
|||
|
const directories = await this._listDirectories(mount);
|
|||
|
|
|||
|
// Submenu sections
|
|||
|
const dirSection = new Gio.Menu();
|
|||
|
const unmountSection = this._getUnmountSection();
|
|||
|
|
|||
|
for (const [name, uri] of Object.entries(directories))
|
|||
|
dirSection.append(name, `device.openPath::${uri}`);
|
|||
|
|
|||
|
// Files submenu
|
|||
|
const filesSubmenu = new Gio.Menu();
|
|||
|
filesSubmenu.append_section(null, dirSection);
|
|||
|
filesSubmenu.append_section(null, unmountSection);
|
|||
|
|
|||
|
// Files menu item
|
|||
|
const filesMenuItem = this._getFilesMenuItem();
|
|||
|
filesMenuItem.set_submenu(filesSubmenu);
|
|||
|
|
|||
|
// Replace the existing menu item
|
|||
|
const index = this.device.removeMenuAction('device.mount');
|
|||
|
this.device.addMenuItem(filesMenuItem, index);
|
|||
|
} catch (e) {
|
|||
|
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
|
|||
|
debug(e, this.device.name);
|
|||
|
|
|||
|
// Reset to allow retrying
|
|||
|
this._gmount = null;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
_removeSubmenu() {
|
|||
|
try {
|
|||
|
const index = this.device.removeMenuAction('device.mount');
|
|||
|
const action = this.device.lookup_action('mount');
|
|||
|
|
|||
|
if (action !== null) {
|
|||
|
this.device.addMenuAction(
|
|||
|
action,
|
|||
|
index,
|
|||
|
Metadata.actions.mount.label,
|
|||
|
Metadata.actions.mount.icon_name
|
|||
|
);
|
|||
|
}
|
|||
|
} catch (e) {
|
|||
|
logError(e, this.device.name);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Create a symbolic link referring to the device by name
|
|||
|
*
|
|||
|
* @param {Gio.Mount} mount - A GMount to link to
|
|||
|
*/
|
|||
|
async _addSymlink(mount) {
|
|||
|
try {
|
|||
|
const by_name_dir = Gio.File.new_for_path(
|
|||
|
`${Config.RUNTIMEDIR}/by-name/`
|
|||
|
);
|
|||
|
|
|||
|
try {
|
|||
|
by_name_dir.make_directory_with_parents(null);
|
|||
|
} catch (e) {
|
|||
|
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
|
|||
|
throw e;
|
|||
|
}
|
|||
|
|
|||
|
// Replace path separator with a Unicode lookalike:
|
|||
|
let safe_device_name = this.device.name.replace('/', '∕');
|
|||
|
|
|||
|
if (safe_device_name === '.')
|
|||
|
safe_device_name = '·';
|
|||
|
else if (safe_device_name === '..')
|
|||
|
safe_device_name = '··';
|
|||
|
|
|||
|
const link_target = mount.get_root().get_path();
|
|||
|
const link = Gio.File.new_for_path(
|
|||
|
`${by_name_dir.get_path()}/${safe_device_name}`);
|
|||
|
|
|||
|
// Check for and remove any existing stale link
|
|||
|
try {
|
|||
|
const link_stat = await link.query_info_async(
|
|||
|
'standard::symlink-target',
|
|||
|
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
|
|||
|
GLib.PRIORITY_DEFAULT,
|
|||
|
this.cancellable);
|
|||
|
|
|||
|
if (link_stat.get_symlink_target() === link_target)
|
|||
|
return;
|
|||
|
|
|||
|
await link.delete_async(GLib.PRIORITY_DEFAULT,
|
|||
|
this.cancellable);
|
|||
|
} catch (e) {
|
|||
|
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
|
|||
|
throw e;
|
|||
|
}
|
|||
|
|
|||
|
link.make_symbolic_link(link_target, this.cancellable);
|
|||
|
} catch (e) {
|
|||
|
debug(e, this.device.name);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Send a request to mount the remote device
|
|||
|
*/
|
|||
|
mount() {
|
|||
|
if (this.gmount !== null)
|
|||
|
return;
|
|||
|
|
|||
|
this.device.sendPacket({
|
|||
|
type: 'kdeconnect.sftp.request',
|
|||
|
body: {
|
|||
|
startBrowsing: true,
|
|||
|
},
|
|||
|
});
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Remove the menu items, unmount the filesystem, replace the mount item
|
|||
|
*/
|
|||
|
async unmount() {
|
|||
|
try {
|
|||
|
if (this.gmount === null)
|
|||
|
return;
|
|||
|
|
|||
|
this._removeSubmenu();
|
|||
|
this._mounting = false;
|
|||
|
|
|||
|
await this.gmount.unmount_with_operation(
|
|||
|
Gio.MountUnmountFlags.FORCE,
|
|||
|
new Gio.MountOperation(),
|
|||
|
this.cancellable);
|
|||
|
} catch (e) {
|
|||
|
debug(e, this.device.name);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
destroy() {
|
|||
|
if (this._volumeMonitor) {
|
|||
|
this._volumeMonitor.disconnect(this._mountAddedId);
|
|||
|
this._volumeMonitor.disconnect(this._mountRemovedId);
|
|||
|
this._volumeMonitor = null;
|
|||
|
}
|
|||
|
|
|||
|
super.destroy();
|
|||
|
}
|
|||
|
});
|
|||
|
|
|||
|
export default SFTPPlugin;
|