2024-07-08 22:46:35 +02:00

488 lines
15 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;