dot/.local/share/gnome-shell/extensions/gsconnect@andyholmes.github.io/service/plugins/sftp.js

511 lines
16 KiB
JavaScript
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.

'use strict';
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const GObject = imports.gi.GObject;
const Gtk = imports.gi.Gtk;
const PluginsBase = imports.service.plugins.base;
var Metadata = {
label: _('SFTP'),
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SFTP',
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']
}
}
};
/**
* 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
*/
var Plugin = GObject.registerClass({
Name: 'GSConnectSFTPPlugin'
}, class Plugin extends PluginsBase.Plugin {
_init(device) {
super._init(device, 'sftp');
// A reusable launcher for ssh processes
this._launcher = new Gio.SubprocessLauncher({
flags: Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_MERGE
});
this._mounting = false;
}
get info() {
if (this._info === undefined) {
this._info = {
directories: {},
mount: null,
regex: null,
uri: null
};
}
return this._info;
}
handlePacket(packet) {
if (packet.type === 'kdeconnect.sftp') {
// There was an error mounting the filesystem
if (packet.body.errorMessage) {
this.device.showNotification({
id: 'sftp-error',
title: `${this.device.name}: ${Metadata.label}`,
body: packet.body.errorMessage,
icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
priority: Gio.NotificationPriority.URGENT
});
// Ensure we don't mount on top of an existing mount
} else if (this.info.mount === null) {
this._mount(packet.body);
}
}
}
connected() {
super.connected();
// Disable for all bluetooth connections
if (this.device.connection_type !== 'lan') {
this.device.lookup_action('mount').enabled = false;
this.device.lookup_action('unmount').enabled = false;
// Request a mount
} else {
this.mount();
}
}
disconnected() {
super.disconnected();
this.unmount();
}
/**
* Parse the connection info
*
* @param {object} info - The body of a kdeconnect.sftp packet
*/
_parseInfo(info) {
if (!this.device.connected) {
throw new Gio.IOErrorEnum({
message: _('Device is disconnected'),
code: Gio.IOErrorEnum.CONNECTION_CLOSED
});
}
this._info = info;
this.info.ip = this.device.channel.host;
this.info.directories = {};
this.info.mount = null;
this.info.regex = new RegExp(
'sftp://(' + this.info.ip + '):(1739|17[4-5][0-9]|176[0-4])'
);
this.info.uri = 'sftp://' + this.info.ip + ':' + this.info.port + '/';
// If 'multiPaths' is present setup a local URI for each
if (info.hasOwnProperty('multiPaths')) {
for (let i = 0; i < info.multiPaths.length; i++) {
let name = info.pathNames[i];
let path = info.multiPaths[i];
this.info.directories[name] = this.info.uri + path;
}
// If 'multiPaths' is missing use 'path' and assume a Camera folder
} else {
let uri = this.info.uri + this.info.path;
this.info.directories[_('All files')] = uri;
this.info.directories[_('Camera pictures')] = uri + 'DCIM/Camera';
}
}
_onAskQuestion(op, message, choices) {
op.reply(Gio.MountOperationResult.HANDLED);
}
_onAskPassword(op, message, user, domain, flags) {
op.reply(Gio.MountOperationResult.HANDLED);
}
async _mount(info) {
try {
// If mounting is already in progress, let that fail before retrying
if (this._mounting) return;
this._mounting = true;
// Parse the connection info
this._parseInfo(info);
// Ensure the private key is in the keyring
await this._addPrivateKey();
// Create a new mount operation
let op = new Gio.MountOperation({
username: info.user,
password: info.password,
password_save: Gio.PasswordSave.NEVER
});
// Auto-accept new host keys and password requests
let questionId = op.connect('ask-question', this._onAskQuestion);
let passwordId = op.connect('ask-password', this._onAskPassword);
// This is the actual call to mount the device
await new Promise((resolve, reject) => {
let file = Gio.File.new_for_uri(this.info.uri);
file.mount_enclosing_volume(0, op, null, (file, res) => {
try {
op.disconnect(questionId);
op.disconnect(passwordId);
resolve(file.mount_enclosing_volume_finish(res));
} catch (e) {
// Special case when the GMount didn't unmount properly
// but is still on the same port and can be reused.
if (e.code && e.code === Gio.IOErrorEnum.ALREADY_MOUNTED) {
resolve(true);
// There's a good chance this is a host key verification
// error; regardless we'll remove the key for security.
} else {
this._removeHostKey(this.info.ip);
reject(e);
}
}
});
});
// Get the GMount from GVolumeMonitor
let monitor = Gio.VolumeMonitor.get();
for (let mount of monitor.get_mounts()) {
let uri = mount.get_root().get_uri();
// This is our GMount
if (this.info.uri === uri) {
this.info.mount = mount;
this.info.mount.connect(
'unmounted',
this.unmount.bind(this)
);
this._addSymlink(mount);
// This is one of our old mounts
} else if (this.info.regex.test(uri)) {
debug(`Remove stale mount at ${uri}`);
await this._unmount(mount);
}
}
// Populate the menu
this._addSubmenu();
} catch (e) {
logError(e, this.device.name);
this.unmount();
} finally {
this._mounting = false;
}
}
_unmount(mount = null) {
if (!mount) return Promise.resolve();
return new Promise((resolve, reject) => {
let op = new Gio.MountOperation();
mount.unmount_with_operation(1, op, null, (mount, res) => {
try {
mount.unmount_with_operation_finish(res);
resolve();
} catch (e) {
debug(e);
resolve();
}
});
});
}
/**
* Add GSConnect's private key identity to the authentication agent so our
* identity can be verified by Android during private key authentication.
*/
_addPrivateKey() {
let ssh_add = this._launcher.spawnv([
gsconnect.metadata.bin.ssh_add,
GLib.build_filenamev([gsconnect.configdir, 'private.pem'])
]);
return new Promise((resolve, reject) => {
ssh_add.communicate_utf8_async(null, null, (proc, res) => {
try {
let result = proc.communicate_utf8_finish(res)[1].trim();
if (proc.get_exit_status() !== 0) {
debug(result, this.device.name);
}
resolve();
} catch (e) {
reject(e);
}
});
});
}
/**
* 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 {
let ssh_keygen = this._launcher.spawnv([
gsconnect.metadata.bin.ssh_keygen,
'-R',
`[${host}]:${port}`
]);
await new Promise((resolve, reject) => {
ssh_keygen.wait_check_async(null, (proc, res) => {
try {
resolve(proc.wait_check_finish(res));
} catch (e) {
reject(e);
}
});
});
} catch (e) {
debug(e);
}
}
}
/**
* Mount menu helpers
*/
_getUnmountSection() {
if (this._unmountSection === undefined) {
this._unmountSection = new Gio.Menu();
let 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;
}
_getMountedIcon() {
if (this._mountedIcon === undefined) {
this._mountedIcon = new Gio.EmblemedIcon({
gicon: new Gio.ThemedIcon({name: 'folder-remote-symbolic'})
});
// TODO: this emblem often isn't very visible
let emblem = new Gio.Emblem({
icon: new Gio.ThemedIcon({name: 'emblem-default'})
});
this._mountedIcon.add_emblem(emblem);
}
return this._mountedIcon;
}
_addSubmenu() {
try {
// Directories Section
let dirSection = new Gio.Menu();
for (let [name, uri] of Object.entries(this.info.directories)) {
dirSection.append(name, `device.openPath::${uri}`);
}
// Unmount Section
let unmountSection = this._getUnmountSection();
// Files Submenu
let filesSubmenu = new Gio.Menu();
filesSubmenu.append_section(null, dirSection);
filesSubmenu.append_section(null, unmountSection);
// Files Item
let filesItem = new Gio.MenuItem();
filesItem.set_detailed_action('device.mount');
filesItem.set_icon(this._getMountedIcon());
filesItem.set_label(_('Files'));
filesItem.set_submenu(filesSubmenu);
this.device.replaceMenuAction('device.mount', filesItem);
} catch (e) {
logError(e);
}
}
_removeSubmenu() {
try {
let index = this.device.removeMenuAction('device.mount');
let 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);
}
}
/**
* Create a symbolic link referring to the device by name
*/
async _addSymlink(mount) {
try {
let by_name_dir = Gio.File.new_for_path(
gsconnect.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 = '··';
}
let link_target = mount.get_root().get_path();
let link = Gio.File.new_for_path(
by_name_dir.get_path() + '/' + safe_device_name
);
// Check for and remove any existing stale link
try {
let link_stat = await new Promise((resolve, reject) => {
link.query_info_async(
'standard::symlink-target',
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
GLib.PRIORITY_DEFAULT,
null,
(link, res) => {
try {
resolve(link.query_info_finish(res));
} catch (e) {
reject(e);
}
},
);
});
if (link_stat.get_symlink_target() === link_target) {
return;
}
await new Promise((resolve, reject) => {
link.delete_async(
GLib.PRIORITY_DEFAULT,
null,
(link, res) => {
try {
resolve(link.delete_finish(res));
} catch (e) {
reject(e);
}
},
);
});
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) {
throw e;
}
}
link.make_symbolic_link(link_target, null);
} catch (e) {
debug(e, this.device.name);
}
}
/**
* Send a request to mount the remote device
*/
mount() {
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.info.mount === null) {
return;
}
let mount = this.info.mount;
this._removeSubmenu();
this._info = undefined;
this._mounting = false;
await this._unmount(mount);
} catch (e) {
debug(e);
}
}
destroy() {
this.unmount();
super.destroy();
}
});