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();
|
|||
|
}
|
|||
|
});
|
|||
|
|