mirror of
https://gitlab.com/thebiblelover7/dotfiles.git
synced 2025-09-14 07:33:49 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
'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 new Promise((resolve, reject) => {
|
||||
file.enumerate_children_async(
|
||||
Gio.FILE_ATTRIBUTE_STANDARD_NAME,
|
||||
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this.cancellable,
|
||||
(file, res) => {
|
||||
try {
|
||||
resolve(file.enumerate_children_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const infos = await new Promise((resolve, reject) => {
|
||||
iter.next_files_async(
|
||||
MAX_MOUNT_DIRS,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this.cancellable,
|
||||
(iter, res) => {
|
||||
try {
|
||||
resolve(iter.next_files_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
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 new Promise((resolve, reject) => {
|
||||
file.mount_enclosing_volume(0, op, null, (file, res) => {
|
||||
try {
|
||||
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.matches(Gio.IOErrorEnum, 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(host);
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
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
|
||||
*/
|
||||
_addPrivateKey() {
|
||||
const ssh_add = this._launcher.spawnv([
|
||||
Config.SSHADD_PATH,
|
||||
GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
|
||||
]);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssh_add.communicate_utf8_async(null, null, (proc, res) => {
|
||||
try {
|
||||
const 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 {
|
||||
const ssh_keygen = this._launcher.spawnv([
|
||||
Config.SSHKEYGEN_PATH,
|
||||
'-R',
|
||||
`[${host}]:${port}`,
|
||||
]);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
ssh_keygen.communicate_utf8_async(null, null, (proc, res) => {
|
||||
try {
|
||||
const stdout = proc.communicate_utf8_finish(res)[1];
|
||||
const status = proc.get_exit_status();
|
||||
|
||||
if (status !== 0) {
|
||||
throw new Gio.IOErrorEnum({
|
||||
code: Gio.io_error_from_errno(status),
|
||||
message: `${GLib.strerror(status)}\n${stdout}`.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
} 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 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() {
|
||||
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 new Promise((resolve, reject) => {
|
||||
this.gmount.unmount_with_operation(
|
||||
Gio.MountUnmountFlags.FORCE,
|
||||
new Gio.MountOperation(),
|
||||
null,
|
||||
(mount, res) => {
|
||||
try {
|
||||
resolve(mount.unmount_with_operation_finish(res));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} 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();
|
||||
}
|
||||
});
|
Reference in New Issue
Block a user