511 lines
16 KiB
JavaScript
511 lines
16 KiB
JavaScript
'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();
|
||
}
|
||
});
|
||
|