diff --git a/docs/index.html b/docs/index.html
index cebdfa7..473e004 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1181,11 +1181,15 @@
Keyboard
A bare key is not accepted: it would be taken from every
application. Shift alone is accepted only
- with a key that types nothing, such as
- Shift+F5;
- Shift+A is how a
- capital A is typed. The shortcut does nothing on the lock screen
- or the login screen.
+ with a key that types nothing and that editing text does not
+ need, such as Shift+F5. Shift+A is how
+ a capital A is typed, and Shift with an
+ arrow key, Home, End, Page Up, Page Down, Tab, Enter or a dead
+ key selects text, moves focus, ends a line or types an accented
+ letter: the keys GNOME Settings refuses, plus dead keys. The
+ shortcut does nothing on the lock screen or the login screen.
It is stored under quickts-open-menu. The prefix
@@ -1789,7 +1793,12 @@
The LocalAPI
Receiving, streamed to disk. The file is written
- before the daemon is told to forget it.
+ before the daemon is told to forget it. If the
+ daemon will not, the file still counts as saved and
+ is not offered again while QuickTS runs. It stays in
+ Tailscale’s inbox, where
+ tailscale file get can clear it, and
+ after a reload it can be offered again.
|
diff --git a/modules/model.js b/modules/model.js
index b1473dc..6fe6787 100644
--- a/modules/model.js
+++ b/modules/model.js
@@ -95,6 +95,15 @@ export class TailscaleModel {
#peersReadAt = 0;
#menuOpen = false;
+ /**
+ * Files saved here that tailscaled then would not delete, by name, with
+ * the size they were listed at. waitingFiles hides them, so a saved file
+ * is not offered, and saved as a duplicate, a second time. Nothing here
+ * deletes them: a name and a size are not proof that the file listed
+ * later is the one that was saved.
+ */
+ #savedNotDeleted = new Map();
+
/**
* @param {object} options Options.
* @param {object} options.client Transport, from modules/io.js.
@@ -306,13 +315,30 @@ export class TailscaleModel {
async waitingFiles() {
if (this.#disposed || !isUp(this.#state)) return [];
+ let files;
try {
- return waitingFiles(await this.#request(waitingFilesRequest()));
+ files = waitingFiles(await this.#request(waitingFilesRequest()));
} catch (error) {
if (!isCanceled(error))
console.debug(`[quickts] could not list waiting files: ${error}`);
return [];
}
+
+ // Forget a saved file no longer listed at the size it was saved at: it
+ // has left the inbox, and a file listed under that name now is a
+ // different one, not yet saved.
+ const listed = new Map(files.map(({ name, size }) => [name, size]));
+ for (const [name, size] of this.#savedNotDeleted)
+ if (!listed.has(name) || listed.get(name) !== size)
+ this.#savedNotDeleted.delete(name);
+
+ return files.filter(
+ ({ name, size }) =>
+ !(
+ this.#savedNotDeleted.has(name) &&
+ this.#savedNotDeleted.get(name) === size
+ ),
+ );
}
/**
@@ -320,29 +346,47 @@ export class TailscaleModel {
*
* In that order. Deleting first loses the file if the write fails.
*
+ * Once the file is written, a delete that fails does not undo that: the
+ * path is still returned, with no error, because the file is saved. The
+ * file stays in tailscaled's inbox, where `tailscale file get` can clear
+ * it; while this model lives, waitingFiles hides it, so it is not offered,
+ * and saved as a duplicate, a second time. Only a numeric size is
+ * recorded: without one there is nothing but the name to match.
+ *
* @param {string} name The name as the daemon lists it.
+ * @param {number} size Its size as the daemon lists it.
* @returns {Promise<{path: string, error: string}>} Where it went, or a
* REASON from modules/errors.js if it did not — untranslated, so
* modules/taildrop-section.js can turn it into a literal `_()` call
* rather than being handed English composed at run time.
*/
- async saveFile(name) {
+ async saveFile(name, size) {
if (this.#disposed) return { path: '', error: '' };
// The daemon listed a name that cannot be a plain file here. It is
// left on the daemon, where `tailscale file get` can still reach it.
if (!isSafeFileName(name)) return { path: '', error: REASON.PROTOCOL };
+ let path;
try {
- const path = await this.#client.saveFile(getFileRequest(name), name);
- await this.#request(deleteFileRequest(name));
-
- return { path, error: '' };
+ path = await this.#client.saveFile(getFileRequest(name), name);
} catch (error) {
if (isCanceled(error)) return { path: '', error: '' };
return { path: '', error: reasonOf(error) };
}
+
+ try {
+ await this.#request(deleteFileRequest(name));
+ } catch (error) {
+ if (Number.isFinite(size)) this.#savedNotDeleted.set(name, size);
+ if (!isCanceled(error))
+ console.debug(
+ `[quickts] saved a file but could not remove it from Taildrop: ${reasonOf(error)}`,
+ );
+ }
+
+ return { path, error: '' };
}
/**
diff --git a/modules/shortcuts.js b/modules/shortcuts.js
index 63db194..52f6f6f 100644
--- a/modules/shortcuts.js
+++ b/modules/shortcuts.js
@@ -23,6 +23,59 @@ export const CAPTURE_IGNORE = 'ignore';
/** Bind the combination and close. */
export const CAPTURE_ASSIGN = 'assign';
+/**
+ * Keys that Shift alone may not be bound to, although none of them types a
+ * visible character: Shift with one of them selects text, moves focus or
+ * ends a line in every application.
+ *
+ * GNOME Settings' own list, forbidden_keyvals in is_valid_binding()
+ * (gnome-control-center, panels/keyboard/keyboard-shortcuts.c), plus
+ * ISO_Left_Tab, which is what GTK reports for Shift+Tab. Values are
+ * Gdk.KEY_* under gjs with Gdk 4.
+ */
+const SHIFT_FORBIDDEN_KEYVALS = new Set([
+ 0xff50, // Home
+ 0xff51, // Left
+ 0xff52, // Up
+ 0xff53, // Right
+ 0xff54, // Down
+ 0xff55, // Page_Up
+ 0xff56, // Page_Down
+ 0xff57, // End
+ 0xff09, // Tab
+ 0xfe20, // ISO_Left_Tab
+ 0xff8d, // KP_Enter
+ 0xff0d, // Return
+ 0xff7e, // Mode_switch
+]);
+
+/**
+ * The dead keys, as inclusive keyval ranges.
+ *
+ * Derived under gjs with Gdk 4.22: Gdk.keyval_name(k) for every k in
+ * 0xfe50..0xfeff names dead_grave..dead_currency at 0xfe50-0xfe6f and
+ * dead_a..dead_hamza at 0xfe80-0xfe8d, and nothing else starting with dead_.
+ * The Gdk.KEY_dead_* constants add dead_lowline..dead_longsolidusoverlay at
+ * 0xfe90-0xfe93, which keyval_name cannot name (it returns "0xfe90"), so they
+ * are listed too.
+ */
+const DEAD_KEY_RANGES = [
+ [0xfe50, 0xfe6f],
+ [0xfe80, 0xfe8d],
+ [0xfe90, 0xfe93],
+];
+
+/**
+ * Whether a key is a dead key, which types nothing itself but puts an accent
+ * on the next letter typed.
+ *
+ * @param {number} keyval Key value.
+ * @returns {boolean} True for a dead key.
+ */
+function isDeadKey(keyval) {
+ return DEAD_KEY_RANGES.some(([first, last]) => keyval >= first && keyval <= last);
+}
+
/**
* Whether a key's own code point types something visible.
*
@@ -39,10 +92,20 @@ function typesVisibly(codePoint) {
}
/**
- * Whether a captured combination may be bound as a global shortcut.
+ * Whether a captured key combination may be bound as a global shortcut.
+ *
+ * A bare key would steal it from every application, so it never may. Shift
+ * alone may only with a key that types no visible character and is not one
+ * that editing text needs (SHIFT_FORBIDDEN_KEYVALS) or a dead key: Shift+F5
+ * may, Shift+A, Shift+Left and Shift+dead_acute may not. Any other modifier
+ * makes a combination bindable, subject to Gtk's own accelerator check.
*
- * A bare key would steal it from every application. Shift alone is bindable
- * only when the key types nothing on its own, close to GNOME Settings' rule.
+ * Built on GNOME Settings' is_valid_binding() (gnome-control-center,
+ * panels/keyboard/keyboard-shortcuts.c), and differs in three ways: this
+ * refuses every bare key, where GNOME allows one such as F5; it refuses
+ * Shift with a dead key, which GNOME's list leaves out; and it judges what
+ * Shift alone types by whether the key's code point is a visible character,
+ * where GNOME checks per-script keyval ranges.
*
* @param {number} mask Modifier mask, already reduced to the default mod mask.
* @param {number} keyval Key value.
@@ -57,7 +120,13 @@ export function isValidBinding(
{ shiftMask, acceleratorValid, codePoint },
) {
if (mask === 0) return false;
- if (mask === shiftMask && typesVisibly(codePoint)) return false;
+ if (
+ mask === shiftMask &&
+ (typesVisibly(codePoint) ||
+ SHIFT_FORBIDDEN_KEYVALS.has(keyval) ||
+ isDeadKey(keyval))
+ )
+ return false;
return acceleratorValid(keyval, mask);
}
diff --git a/modules/taildrop-section.js b/modules/taildrop-section.js
index df4fc4f..c6ae06d 100644
--- a/modules/taildrop-section.js
+++ b/modules/taildrop-section.js
@@ -286,7 +286,7 @@ export class InboxSection {
row.label.text = fill(_('Saving %s…'), file.name);
row.setSensitive(false);
- const { path, error } = await this._model.saveFile(file.name);
+ const { path, error } = await this._model.saveFile(file.name, file.size);
if (generation !== this._generation) return;
if (error) {
diff --git a/template.sha256 b/template.sha256
index 536f7a0..8f92fd9 100644
--- a/template.sha256
+++ b/template.sha256
@@ -16,7 +16,7 @@ cbde6c3009a0b09910a20dc4bcd17bdf19c9174eee6d74c67c825efbfd9c17ca mise.toml
508b2aabe2c485cc9cdaf51ca7dd1a70e53c70c65c7dd0149dc1458a4f78a679 scripts/template-check.sh
f0a3265f966c1017a80bcba09bff0c465ccec94b7ad9dd6456742f94905b3ae2 template.list
f13c2fca51ef7a2db1755805d5f24a4a55c97605d40610168f1d39d12ad408af tests/docs.spec.js
-450e8d7c616dfaeb3ad697cd6d457444aa9cd300fb0261da763a7f5d9499bd0f tests/stubs/gi-glib.js
+8b3467177303c86b68bea7532c3bcec8585f5d66d0b7f1d604d22ef5790bea15 tests/stubs/gi-glib.js
3969a5d8b38c607f1f70be1e6e88ce3ff75749710e1591437b511a5fff300df1 tests/stubs/gi-gobject.js
dbebfe1620af3038a86d9454c57778ee4a5885f07748e6acb3f83aa11353dc86 tests/stubs/gi-meta.js
757435b7d2469254f94210744c243fd0dc49983800f2f227fd90c7eb413e385a tests/stubs/gi-pango.js
diff --git a/tests/model.test.js b/tests/model.test.js
index eaff084..08c9132 100644
--- a/tests/model.test.js
+++ b/tests/model.test.js
@@ -1082,6 +1082,103 @@ describe('waiting files', () => {
expect(daemon.deleted.at(-1)).toContain('a.txt');
});
+ // The file is already written by then. Reporting the failure as an error
+ // threw that path away, and saving again made a duplicate "a (1).txt".
+ it('keeps the saved path when the daemon will not forget the file', async () => {
+ const { model, daemon } = setup();
+ daemon.responses.files = [{ Name: 'a.txt', Size: 4 }];
+ await model.start();
+ daemon.failures.set(
+ 'DELETE /localapi/v0/files/a.txt',
+ new TransportError(REASON.HTTP, '500'),
+ );
+
+ const result = await model.saveFile('a.txt', 4);
+
+ expect(result).toEqual({ path: '/home/someone/Downloads/a.txt', error: '' });
+ expect(daemon.saved).toHaveLength(1);
+ });
+
+ // Nothing here ever deletes a file on its own: a name and a size are not
+ // proof that the file listed now is the one that was saved.
+ describe('a file saved that the daemon would not forget', () => {
+ const savedButKept = async size => {
+ const { model, daemon } = setup();
+ daemon.responses.files = [{ Name: 'a.txt', Size: 4 }];
+ await model.start();
+ await model.waitingFiles();
+ daemon.failures.set(
+ 'DELETE /localapi/v0/files/a.txt',
+ new TransportError(REASON.HTTP, '500'),
+ );
+ await model.saveFile('a.txt', size);
+ // Were a DELETE sent now, it would succeed.
+ daemon.failures.clear();
+ daemon.reset();
+
+ return { model, daemon };
+ };
+
+ // Only GET /files/ may be asked for: the file itself never again.
+ const deleteRequests = daemon => daemon.pathsMatching('/files/a.txt');
+
+ // Listed, it would be offered to save again, and a second save is a
+ // duplicate.
+ it('is not listed again, and no later listing deletes it', async () => {
+ const { model, daemon } = await savedButKept(4);
+
+ expect(await model.waitingFiles()).toEqual([]);
+ expect(await model.waitingFiles()).toEqual([]);
+ expect(deleteRequests(daemon)).toEqual([]);
+ expect(daemon.deleted).toEqual([]);
+ });
+
+ // The original left the inbox some other way (`tailscale file get`, or
+ // a reply lost in a restart), and a new file of the same name and size
+ // arrived before the next listing. It cannot be told apart, so it is
+ // hidden — but it stays in Tailscale's inbox, never deleted unsaved.
+ it('hides, but never deletes, a same-name same-size file that replaced it', async () => {
+ const { model, daemon } = await savedButKept(4);
+ daemon.responses.files = [{ Name: 'a.txt', Size: 4 }];
+
+ expect(await model.waitingFiles()).toEqual([]);
+ expect(deleteRequests(daemon)).toEqual([]);
+ expect(daemon.deleted).toEqual([]);
+ });
+
+ it('is forgotten once no longer listed, so a later file of that name is shown', async () => {
+ const { model, daemon } = await savedButKept(4);
+
+ daemon.responses.files = [];
+ expect(await model.waitingFiles()).toEqual([]);
+
+ daemon.responses.files = [{ Name: 'a.txt', Size: 4 }];
+ expect(await model.waitingFiles()).toEqual([{ name: 'a.txt', size: 4 }]);
+ expect(deleteRequests(daemon)).toEqual([]);
+ });
+
+ it('does not hide a file of that name listed at another size', async () => {
+ const { model, daemon } = await savedButKept(4);
+ daemon.responses.files = [{ Name: 'a.txt', Size: 99 }];
+
+ expect(await model.waitingFiles()).toEqual([{ name: 'a.txt', size: 99 }]);
+ expect(deleteRequests(daemon)).toEqual([]);
+ });
+
+ // With no size to match, a later listing could only be matched on the
+ // name, which is not enough to hide anything by.
+ it.each([
+ ['no size', undefined],
+ ['a string', '4'],
+ ['NaN', Number.NaN],
+ ])('records nothing when saved with %s', async (_reason, size) => {
+ const { model, daemon } = await savedButKept(size);
+
+ expect(await model.waitingFiles()).toEqual([{ name: 'a.txt', size: 4 }]);
+ expect(deleteRequests(daemon)).toEqual([]);
+ });
+ });
+
// tailscaled validates names on the way in; this is where one becomes a
// path here, so it is checked again rather than trusted.
it.each(['../escape.txt', '.bashrc', 'sub/dir.txt'])(
diff --git a/tests/shortcuts.test.js b/tests/shortcuts.test.js
index 50ae92c..b94b17d 100644
--- a/tests/shortcuts.test.js
+++ b/tests/shortcuts.test.js
@@ -12,10 +12,12 @@ import {
// Stand-ins for the Gdk and Gtk values prefs.js passes in.
const SHIFT = 1;
const CONTROL = 4;
+const SUPER = 0x4000000;
const ESCAPE = 0xff1b;
const BACKSPACE = 0xff08;
const F5 = 0xffc2;
const TAB = 0xff09;
+const LEFT = 0xff51;
const gtk = {
escapeKey: ESCAPE,
@@ -43,14 +45,66 @@ describe('isValidBinding', () => {
expect(isValidBinding(mask, keyval, { ...gtk, codePoint })).toBe(false);
});
- // Close to GNOME Settings' rule: Shift alone is enough for a key that types
- // nothing on its own — a function key has no code point, and Tab's is a
- // control character.
+ // Shift alone is enough for a key that types nothing on its own and that
+ // editing text does not need: a function key has no code point at all.
+ it('accepts Shift+F5', () => {
+ expect(isValidBinding(SHIFT, F5, { ...gtk, codePoint: 0 })).toBe(true);
+ });
+
+ // Delete has a code point (0x7f), unlike F5, but a control character, so
+ // this exercises the \p{Cc} half of the rule rather than codePoint <= 0.
+ it('accepts Shift+Delete, since Delete types a control character', () => {
+ expect(isValidBinding(SHIFT, 0xffff, { ...gtk, codePoint: 0x7f })).toBe(true);
+ });
+
+ // Shift with these selects text, moves focus or ends a line in every
+ // application, though none of them types a visible character. Keyvals and
+ // code points as Gdk 4 gives them (Gdk.KEY_*, Gdk.keyval_to_unicode) under
+ // gjs; ISO_Left_Tab is what GTK reports for Shift+Tab, and dead_acute is a
+ // dead key, which types the accent over the next letter.
+ it.each([
+ ['Left', LEFT, 0],
+ ['Up', 0xff52, 0],
+ ['Right', 0xff53, 0],
+ ['Down', 0xff54, 0],
+ ['Home', 0xff50, 0],
+ ['End', 0xff57, 0],
+ ['Page_Up', 0xff55, 0],
+ ['Page_Down', 0xff56, 0],
+ ['Tab', TAB, 0x09],
+ ['ISO_Left_Tab', 0xfe20, 0],
+ ['Return', 0xff0d, 0x0d],
+ ['KP_Enter', 0xff8d, 0],
+ ['Mode_switch', 0xff7e, 0],
+ ['dead_acute', 0xfe51, 0],
+ ])(
+ 'rejects Shift+%s, which applications need for editing text',
+ (_name, keyval, codePoint) => {
+ expect(isValidBinding(SHIFT, keyval, { ...gtk, codePoint })).toBe(false);
+ },
+ );
+
+ // The ends of each run of dead keys.
+ it.each([
+ ['dead_grave', 0xfe50],
+ ['dead_currency', 0xfe6f],
+ ['dead_a', 0xfe80],
+ ['dead_hamza', 0xfe8d],
+ ['dead_lowline', 0xfe90],
+ ['dead_longsolidusoverlay', 0xfe93],
+ ])('rejects Shift+%s, a dead key', (_name, keyval) => {
+ expect(isValidBinding(SHIFT, keyval, { ...gtk, codePoint: 0 })).toBe(false);
+ });
+
+ // The same keys stay bindable with a modifier other than Shift. Not Tab:
+ // Gtk.accelerator_valid refuses Tab with any modifier, so a Tab case here
+ // would pass only because acceleratorValid is stubbed.
it.each([
- ['Shift+F5', F5, 0],
- ['Shift+Tab', TAB, 0x09],
- ])('accepts %s', (_reason, keyval, codePoint) => {
- expect(isValidBinding(SHIFT, keyval, { ...gtk, codePoint })).toBe(true);
+ ['Ctrl+Left', CONTROL, LEFT, 0],
+ ['Super+Left', SUPER, LEFT, 0],
+ ['Ctrl+Return', CONTROL, 0xff0d, 0x0d],
+ ])('accepts %s', (_reason, mask, keyval, codePoint) => {
+ expect(isValidBinding(mask, keyval, { ...gtk, codePoint })).toBe(true);
});
it('defers to Gtk on what is a valid accelerator', () => {
diff --git a/tests/stubs/gi-glib.js b/tests/stubs/gi-glib.js
index 9f01469..8ce0971 100644
--- a/tests/stubs/gi-glib.js
+++ b/tests/stubs/gi-glib.js
@@ -68,4 +68,12 @@ export default {
// pass unless the test injects its own clock.
get_monotonic_time: () => 42_000_000,
uuid_string_random: () => '00000000-0000-4000-8000-000000000000',
+
+ // Only the signature is kept: a caller builds one to pass to a D-Bus call,
+ // and no fake reads it back.
+ VariantType: class VariantType {
+ constructor(signature) {
+ this.signature = signature;
+ }
+ },
};
diff --git a/tests/support/daemon.js b/tests/support/daemon.js
index 7f82db1..d051dc8 100644
--- a/tests/support/daemon.js
+++ b/tests/support/daemon.js
@@ -87,7 +87,11 @@ export function createDaemon(seed = {}) {
const paths = [];
/** Bodies of every PATCH, in order. */
const patches = [];
- /** Paths the daemon should reject, mapped to the error to throw. */
+ /**
+ * Paths the daemon should reject, mapped to the error to throw. A key may
+ * also start with a method, as in 'DELETE /localapi/v0/files/a.txt', to
+ * reject only that method's requests.
+ */
const failures = new Map();
/** Files the daemon was told to forget, in order. */
const deleted = [];
@@ -101,8 +105,9 @@ export function createDaemon(seed = {}) {
paths.push(path);
if (method === 'PATCH') patches.push(body);
- const failure = [...failures.entries()].find(([prefix]) =>
- path.startsWith(prefix),
+ const failure = [...failures.entries()].find(
+ ([prefix]) =>
+ path.startsWith(prefix) || `${method} ${path}`.startsWith(prefix),
);
if (failure) throw failure[1];
diff --git a/tests/taildrop-section.test.js b/tests/taildrop-section.test.js
index 95f898d..607808a 100644
--- a/tests/taildrop-section.test.js
+++ b/tests/taildrop-section.test.js
@@ -119,6 +119,47 @@ describe('received files', () => {
expect(daemon.saved).toHaveLength(1);
});
+ // Saved, but tailscaled would not remove it from its inbox. The file is on
+ // disk, so the row says so; offering it again on the next open would save
+ // a duplicate "report (1).pdf".
+ it('shows a file saved when the daemon will not forget it, and does not offer it again', async () => {
+ const { panel, model, daemon } = setup();
+ withFiles(daemon);
+ panel.enable();
+ await model.start();
+ await settle();
+ toggleOf().menu.open();
+ await settle();
+
+ daemon.failures.set('DELETE /localapi/v0/files/report.pdf', {
+ name: 'TransportError',
+ reason: REASON.HTTP,
+ });
+
+ const row = toggleOf()._inbox.menu.items.at(0);
+ row.activate();
+ await settle();
+
+ expect(row.text).toBe('Saved to /home/someone/Downloads/report.pdf');
+ expect(row.sensitive).toBe(false);
+ expect(Main.osdMessages.at(-1).label).toBe('Saved report.pdf');
+
+ daemon.failures.clear();
+ daemon.reset();
+ toggleOf().menu.close();
+ toggleOf().menu.open();
+ await settle();
+
+ const rows = toggleOf()._inbox.menu.items.map(item => item.text);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toContain('notes.txt');
+ expect(toggleOf()._inbox.label.text).toBe('1 received file');
+ expect(daemon.saved).toHaveLength(1);
+ // Hidden, not deleted: it stays in Tailscale's inbox.
+ expect(daemon.pathsMatching('report.pdf')).toEqual([]);
+ expect(daemon.deleted).toEqual([]);
+ });
+
it('does not forget a file it could not save', async () => {
const { panel, model, daemon } = setup();
withFiles(daemon);