123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488 |
- // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
- //
- // SPDX-License-Identifier: GPL-2.0-or-later
- 'use strict';
- const Gio = imports.gi.Gio;
- const GLib = imports.gi.GLib;
- const GObject = imports.gi.GObject;
- const Config = imports.config;
- const Lan = imports.service.backends.lan;
- const PluginBase = imports.service.plugin;
- var 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
- */
- var Plugin = GObject.registerClass({
- GTypeName: 'GSConnectSFTPPlugin',
- }, class Plugin extends PluginBase.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 instanceof Lan.Channel) {
- 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();
- }
- });
|