Skip to content

Commit 6251a6b

Browse files
committed
SFTP plugin: Re-work mount handling
Remove `_listDirectories()` from the SFTP plugin code, and never try to probe the root of the remote device mount (since it's always a permission-denied path on Android). Instead, use the `multiPaths` / `pathNames` keys in the SFTP packet body to determine the director(y/ies) on the remote device. Instead of creating a single symlink to the device's root path in the `by-name` directory, turn the `by-name/${device_name}` path into a directory, populated on mount with a symlink to each of the device paths supplied in the packet. Build the `Files` menu for the device with a list of these shared path names, each of which (although KDE Connect seems to be back to just a single shared directory, again) will open the remote path associated with that share-name. Unmounts (either explicit via the Files submenu, or observed) will remove these symlinks from the device directory (which is kept around for future mounts).
1 parent 1c0b4e7 commit 6251a6b

File tree

1 file changed

+103
-64
lines changed

1 file changed

+103
-64
lines changed

src/service/plugins/sftp.js

Lines changed: 103 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import GObject from 'gi://GObject';
88

99
import Config from '../../config.js';
1010
import Plugin from '../plugin.js';
11+
import {safe_dirname} from '../utils/file.js';
1112

1213

1314
export const Metadata = {
@@ -37,9 +38,6 @@ export const Metadata = {
3738
};
3839

3940

40-
const MAX_MOUNT_DIRS = 12;
41-
42-
4341
/**
4442
* SFTP Plugin
4543
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sftp
@@ -53,6 +51,8 @@ const SFTPPlugin = GObject.registerClass({
5351
super._init(device, 'sftp');
5452

5553
this._gmount = null;
54+
this._directories = {};
55+
this._device_dir = null;
5656
this._mounting = false;
5757

5858
// A reusable launcher for ssh processes
@@ -89,7 +89,7 @@ const SFTPPlugin = GObject.registerClass({
8989
if (regex.test(uri)) {
9090
this._gmount = mount;
9191
this._addSubmenu(mount);
92-
this._addSymlink(mount);
92+
this._addSymlinks(mount, this._directories);
9393

9494
break;
9595
}
@@ -104,8 +104,11 @@ const SFTPPlugin = GObject.registerClass({
104104

105105
// Only enable for Lan connections
106106
if (this.device.channel.constructor.name === 'LanChannel') { // FIXME: Circular import workaround
107-
if (this.settings.get_boolean('automount'))
107+
if (this.settings.get_boolean('automount')) {
108+
debug(
109+
`Initial SFTP automount for ${this.device.name}`);
108110
this.mount();
111+
}
109112
} else {
110113
this.device.lookup_action('mount').enabled = false;
111114
this.device.lookup_action('unmount').enabled = false;
@@ -135,40 +138,20 @@ const SFTPPlugin = GObject.registerClass({
135138
if (!regex.test(uri))
136139
return;
137140

141+
debug(`Found new SFTP mount for ${this.device.name}`);
138142
this._gmount = mount;
139143
this._addSubmenu(mount);
140-
this._addSymlink(mount);
144+
this._addSymlinks(mount, this._directories);
141145
}
142146

143147
_onMountRemoved(monitor, mount) {
144148
if (this.gmount !== mount)
145149
return;
146150

151+
debug(`Mount for ${this.device.name} removed, cleaning up`);
147152
this._gmount = null;
148153
this._removeSubmenu();
149-
}
150-
151-
async _listDirectories(mount) {
152-
const file = mount.get_root();
153-
154-
const iter = await file.enumerate_children_async(
155-
Gio.FILE_ATTRIBUTE_STANDARD_NAME,
156-
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
157-
GLib.PRIORITY_DEFAULT,
158-
this.cancellable);
159-
160-
const infos = await iter.next_files_async(MAX_MOUNT_DIRS,
161-
GLib.PRIORITY_DEFAULT, this.cancellable);
162-
iter.close_async(GLib.PRIORITY_DEFAULT, null, null);
163-
164-
const directories = {};
165-
166-
for (const info of infos) {
167-
const name = info.get_name();
168-
directories[name] = `${file.get_uri()}${name}/`;
169-
}
170-
171-
return directories;
154+
this._cleanupDirectories();
172155
}
173156

174157
_onAskQuestion(op, message, choices) {
@@ -220,11 +203,23 @@ const SFTPPlugin = GObject.registerClass({
220203
op.connect('ask-question', this._onAskQuestion);
221204
op.connect('ask-password', this._onAskPassword);
222205

223-
// This is the actual call to mount the device
224206
const host = this.device.channel.host;
225207
const uri = `sftp://${host}:${packet.body.port}/`;
226208
const file = Gio.File.new_for_uri(uri);
227209

210+
const _directories = {};
211+
for (let i = 0; i < packet.body.multiPaths.length; ++i) {
212+
try {
213+
const _name = packet.body.pathNames[i];
214+
const _dir = packet.body.multiPaths[i];
215+
_directories[_name] = _dir;
216+
} catch {}
217+
}
218+
this._directories = _directories;
219+
debug(`Directories: ${Object.entries(this._directories)}`);
220+
221+
debug(`Mounting ${this.device.name} SFTP server as ${uri}`);
222+
// This is the actual call to mount the device
228223
await file.mount_enclosing_volume(GLib.PRIORITY_DEFAULT, op,
229224
this.cancellable);
230225
} catch (e) {
@@ -258,7 +253,7 @@ const SFTPPlugin = GObject.registerClass({
258253
this.cancellable);
259254

260255
if (ssh_add.get_exit_status() !== 0)
261-
debug(stdout.trim(), this.device.name);
256+
logError(stdout.trim(), this.device.name);
262257
}
263258

264259
/**
@@ -334,16 +329,17 @@ const SFTPPlugin = GObject.registerClass({
334329
return this._filesMenuItem;
335330
}
336331

337-
async _addSubmenu(mount) {
332+
_addSubmenu(mount) {
338333
try {
339-
const directories = await this._listDirectories(mount);
340334

341335
// Submenu sections
342336
const dirSection = new Gio.Menu();
343337
const unmountSection = this._getUnmountSection();
344338

345-
for (const [name, uri] of Object.entries(directories))
339+
for (const [name, path] of Object.entries(this._directories)) {
340+
const uri = `${mount.get_root().get_uri()}${path}`;
346341
dirSection.append(name, `device.openPath::${uri}`);
342+
}
347343

348344
// Files submenu
349345
const filesSubmenu = new Gio.Menu();
@@ -367,6 +363,7 @@ const SFTPPlugin = GObject.registerClass({
367363
}
368364

369365
_removeSubmenu() {
366+
debug('Removing device.mount submenu and restoring mount action');
370367
try {
371368
const index = this.device.removeMenuAction('device.mount');
372369
const action = this.device.lookup_action('mount');
@@ -388,54 +385,95 @@ const SFTPPlugin = GObject.registerClass({
388385
* Create a symbolic link referring to the device by name
389386
*
390387
* @param {Gio.Mount} mount - A GMount to link to
388+
* @param {object} directories - The name:path mappings for
389+
* the directory symlinks.
391390
*/
392-
async _addSymlink(mount) {
391+
async _addSymlinks(mount, directories) {
392+
if (!directories)
393+
return;
394+
debug(`Building symbolic links for ${this.device.name}`);
393395
try {
394-
const by_name_dir = Gio.File.new_for_path(
395-
`${Config.RUNTIMEDIR}/by-name/`
396+
// Replace path separator with a Unicode lookalike:
397+
const safe_device_name = safe_dirname(this.device.name);
398+
399+
const device_dir = Gio.File.new_for_path(
400+
`${Config.RUNTIMEDIR}/by-name/${safe_device_name}`
396401
);
402+
// Check for and remove any existing links or other cruft
403+
if (device_dir.query_exists(null) &&
404+
device_dir.query_file_type(
405+
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) !==
406+
Gio.FileType.DIRECTORY) {
407+
await device_dir.delete_async(
408+
GLib.PRIORITY_DEFAULT, this.cancellable);
409+
}
397410

398411
try {
399-
by_name_dir.make_directory_with_parents(null);
412+
device_dir.make_directory_with_parents(null);
400413
} catch (e) {
401414
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
402415
throw e;
403416
}
417+
this._device_dir = device_dir;
418+
419+
const base_path = mount.get_root().get_path();
420+
for (const [_name, _path] of Object.entries(directories)) {
421+
const safe_name = safe_dirname(_name);
422+
const link_target = `${base_path}${_path}`;
423+
const link = Gio.File.new_for_path(
424+
`${device_dir.get_path()}/${safe_name}`);
425+
426+
// Check for and remove any existing stale link
427+
try {
428+
const link_stat = await link.query_info_async(
429+
'standard::symlink-target',
430+
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
431+
GLib.PRIORITY_DEFAULT,
432+
this.cancellable);
433+
434+
if (link_stat.get_symlink_target() === link_target)
435+
continue;
436+
437+
await link.delete_async(GLib.PRIORITY_DEFAULT,
438+
this.cancellable);
439+
} catch (e) {
440+
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
441+
throw e;
442+
}
404443

405-
// Replace path separator with a Unicode lookalike:
406-
let safe_device_name = this.device.name.replace('/', '∕');
407-
408-
if (safe_device_name === '.')
409-
safe_device_name = '·';
410-
else if (safe_device_name === '..')
411-
safe_device_name = '··';
444+
debug(`Linking '${_name}' to device path ${_path}`);
445+
link.make_symbolic_link(link_target, this.cancellable);
446+
}
447+
} catch (e) {
448+
debug(e, this.device.name);
449+
}
450+
}
412451

413-
const link_target = mount.get_root().get_path();
414-
const link = Gio.File.new_for_path(
415-
`${by_name_dir.get_path()}/${safe_device_name}`);
452+
/**
453+
* Remove the directory symlinks placed in the by-name path for the
454+
* device.
455+
*/
456+
async _cleanupDirectories() {
457+
if (this._device_dir === null || !this._directories)
458+
return;
416459

417-
// Check for and remove any existing stale link
460+
for (const _name of Object.keys(this._directories)) {
418461
try {
419-
const link_stat = await link.query_info_async(
420-
'standard::symlink-target',
421-
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
422-
GLib.PRIORITY_DEFAULT,
423-
this.cancellable);
462+
const safe_name = safe_dirname(_name);
424463

425-
if (link_stat.get_symlink_target() === link_target)
426-
return;
427-
428-
await link.delete_async(GLib.PRIORITY_DEFAULT,
429-
this.cancellable);
464+
debug(`Destroying symlink '${safe_name}'`);
465+
const link = Gio.File.new_for_path(
466+
`${this._device_dir.get_path()}/${safe_name}`);
467+
await link.delete_async(GLib.PRIORITY_DEFAULT, null);
430468
} catch (e) {
431469
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
432-
throw e;
470+
debug(e, this.device.name);
433471
}
434-
435-
link.make_symbolic_link(link_target, this.cancellable);
436-
} catch (e) {
437-
debug(e, this.device.name);
438472
}
473+
this._device_dir = null;
474+
// We don't clean up this._directories here, because a new mount may
475+
// be created in the future without another packet being received,
476+
// and we'll need to know the pathnames to re-create.
439477
}
440478

441479
/**
@@ -462,6 +500,7 @@ const SFTPPlugin = GObject.registerClass({
462500
return;
463501

464502
this._removeSubmenu();
503+
this._cleanupDirectories();
465504
this._mounting = false;
466505

467506
await this.gmount.unmount_with_operation(

0 commit comments

Comments
 (0)