Skip to content

Commit 16b80c4

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 1e3ebed commit 16b80c4

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
@@ -9,6 +9,7 @@ import GObject from 'gi://GObject';
99
import Config from '../../config.js';
1010
import * as Core from '../core.js';
1111
import Plugin from '../plugin.js';
12+
import {safe_dirname} from '../utils/file.js';
1213

1314

1415
export const Metadata = {
@@ -38,9 +39,6 @@ export const Metadata = {
3839
};
3940

4041

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

5654
this._gmount = null;
55+
this._directories = {};
56+
this._device_dir = null;
5757
this._mounting = false;
5858

5959
// A reusable launcher for ssh processes
@@ -90,7 +90,7 @@ const SFTPPlugin = GObject.registerClass({
9090
if (regex.test(uri)) {
9191
this._gmount = mount;
9292
this._addSubmenu(mount);
93-
this._addSymlink(mount);
93+
this._addSymlinks(mount, this._directories);
9494

9595
break;
9696
}
@@ -105,8 +105,11 @@ const SFTPPlugin = GObject.registerClass({
105105

106106
// Only enable for Lan connections
107107
if (this.device.channel.constructor.name === 'LanChannel') { // FIXME: Circular import workaround
108-
if (this.settings.get_boolean('automount'))
108+
if (this.settings.get_boolean('automount')) {
109+
debug(
110+
`Initial SFTP automount for ${this.device.name}`);
109111
this.mount();
112+
}
110113
} else {
111114
this.device.lookup_action('mount').enabled = false;
112115
this.device.lookup_action('unmount').enabled = false;
@@ -136,40 +139,20 @@ const SFTPPlugin = GObject.registerClass({
136139
if (!regex.test(uri))
137140
return;
138141

142+
debug(`Found new SFTP mount for ${this.device.name}`);
139143
this._gmount = mount;
140144
this._addSubmenu(mount);
141-
this._addSymlink(mount);
145+
this._addSymlinks(mount, this._directories);
142146
}
143147

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

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

175158
_onAskQuestion(op, message, choices) {
@@ -221,11 +204,23 @@ const SFTPPlugin = GObject.registerClass({
221204
op.connect('ask-question', this._onAskQuestion);
222205
op.connect('ask-password', this._onAskPassword);
223206

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

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

261256
if (ssh_add.get_exit_status() !== 0)
262-
debug(stdout.trim(), this.device.name);
257+
logError(stdout.trim(), this.device.name);
263258
}
264259

265260
/**
@@ -335,16 +330,17 @@ const SFTPPlugin = GObject.registerClass({
335330
return this._filesMenuItem;
336331
}
337332

338-
async _addSubmenu(mount) {
333+
_addSubmenu(mount) {
339334
try {
340-
const directories = await this._listDirectories(mount);
341335

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

346-
for (const [name, uri] of Object.entries(directories))
340+
for (const [name, path] of Object.entries(this._directories)) {
341+
const uri = `${mount.get_root().get_uri()}${path}`;
347342
dirSection.append(name, `device.openPath::${uri}`);
343+
}
348344

349345
// Files submenu
350346
const filesSubmenu = new Gio.Menu();
@@ -368,6 +364,7 @@ const SFTPPlugin = GObject.registerClass({
368364
}
369365

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

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

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

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

418-
// Check for and remove any existing stale link
461+
for (const _name of Object.keys(this._directories)) {
419462
try {
420-
const link_stat = await link.query_info_async(
421-
'standard::symlink-target',
422-
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
423-
GLib.PRIORITY_DEFAULT,
424-
this.cancellable);
463+
const safe_name = safe_dirname(_name);
425464

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

442480
/**
@@ -463,6 +501,7 @@ const SFTPPlugin = GObject.registerClass({
463501
return;
464502

465503
this._removeSubmenu();
504+
this._cleanupDirectories();
466505
this._mounting = false;
467506

468507
await this.gmount.unmount_with_operation(

0 commit comments

Comments
 (0)