feat: new JSON-RPC 2.0 RPC API (#7269)

* feat: add enum for JSON-RPC error codes

* feat: new `tr_rpc_request_exec()` overload that accepts string

* feat: add JSON-RPC parse error handling

* feat: add logic for branching to JSON-RPC or legacy API

* feat: error codes for existing errors strings

* refactor: async handlers now take the done cb as parameter

* feat: support non-batch JSON-RPC requests

* feat: support batch JSON-RPC requests

* refactor: move JSON-RPC error codes to header

* test: new tests for JSON-RPC

* refactor(webui): use jsonrpc api

* docs: update docs for jsonrpc

* fix: clang-tidy warning

* perf: avoid copying callback in batch mode

* code review: don't commit to dropping old RPC

* chore: fix shadowed variable warnings
This commit is contained in:
Yat Ho
2025-12-01 00:04:40 +08:00
committed by GitHub
parent d64a1a5699
commit 1cb24a701b
16 changed files with 1410 additions and 445 deletions

View File

@@ -5,6 +5,7 @@
import { AlertDialog } from './alert-dialog.js';
import { Formatter } from './formatter.js';
import { RPC } from './remote.js';
import { createDialogContainer, makeUUID } from './utils.js';
const is_ios =
@@ -76,20 +77,24 @@ export class OpenDialog extends EventTarget {
return;
}
const o = {
arguments: {
id: 'webui',
jsonrpc: RPC._JsonRpcVersion,
method: 'torrent-add',
params: {
'download-dir': destination,
metainfo: contents.slice(Math.max(0, index + key.length)),
paused,
},
method: 'torrent-add',
};
remote.sendRequest(o, (response) => {
if (response.result !== 'success') {
alert(`Error adding "${file.name}": ${response.result}`);
if ('error' in response) {
const message =
response.error.data?.errorString ?? response.error.message;
alert(`Error adding "${file.name}": ${message}`);
controller.setCurrentPopup(
new AlertDialog({
heading: `Error adding "${file.name}"`,
message: response.result,
message,
}),
);
}
@@ -104,19 +109,21 @@ export class OpenDialog extends EventTarget {
url = `magnet:?xt=urn:btih:${url}`;
}
const o = {
arguments: {
id: 'webui',
jsonrpc: RPC._JsonRpcVersion,
method: 'torrent-add',
params: {
'download-dir': destination,
filename: url,
paused,
},
method: 'torrent-add',
};
remote.sendRequest(o, (payload) => {
if (payload.result !== 'success') {
if ('error' in payload) {
controller.setCurrentPopup(
new AlertDialog({
heading: `Error adding "${url}"`,
message: payload.result,
message: payload.error.data?.errorString ?? payload.error.message,
}),
);
}

View File

@@ -61,10 +61,11 @@ export class PrefsDialog extends EventTarget {
return;
}
const args = response.result ?? response.error?.data ?? {};
const element = this.elements.network.port_status_label[ip_protocol];
const is_open = response.arguments['port-is-open'] || false;
const is_open = args['port-is-open'] ?? false;
element.dataset.open = is_open;
if ('port-is-open' in response.arguments) {
if ('port-is-open' in args) {
setTextContent(element, is_open ? 'Open' : 'Closed');
} else {
setTextContent(element, 'Error');

View File

@@ -8,6 +8,7 @@ export const RPC = {
_DaemonVersion: 'version',
_DownSpeedLimit: 'speed-limit-down',
_DownSpeedLimited: 'speed-limit-down-enabled',
_JsonRpcVersion: '2.0',
_QueueMoveBottom: 'queue-move-bottom',
_QueueMoveDown: 'queue-move-down',
_QueueMoveTop: 'queue-move-top',
@@ -46,12 +47,17 @@ export class Remote {
})
.then((response) => {
response_argument = response;
if (response.status === 409) {
const error = new Error(Remote._SessionHeader);
error.header = response.headers.get(Remote._SessionHeader);
throw error;
switch (response.status) {
case 409: {
const error = new Error(Remote._SessionHeader);
error.header = response.headers.get(Remote._SessionHeader);
throw error;
}
case 204:
return null;
default:
return response.json();
}
return response.json();
})
.then((payload) => {
if (callback) {
@@ -85,6 +91,8 @@ export class Remote {
// TODO: return a Promise
loadDaemonPrefs(callback, context) {
const o = {
id: 'webui',
jsonrpc: RPC._JsonRpcVersion,
method: 'session-get',
};
this.sendRequest(o, callback, context);
@@ -92,36 +100,50 @@ export class Remote {
checkPort(ip_protocol, callback, context) {
const o = {
arguments: {
id: 'webui',
jsonrpc: RPC._JsonRpcVersion,
method: 'port-test',
params: {
ip_protocol,
},
method: 'port-test',
};
this.sendRequest(o, callback, context);
}
renameTorrent(torrentIds, oldpath, newname, callback, context) {
const o = {
arguments: {
id: 'webui',
jsonrpc: RPC._JsonRpcVersion,
method: 'torrent-rename-path',
params: {
ids: torrentIds,
name: newname,
path: oldpath,
},
method: 'torrent-rename-path',
};
this.sendRequest(o, callback, context);
}
setLabels(torrentIds, labels, callback) {
const args = {
const params = {
ids: torrentIds,
labels,
};
this.sendRequest({ arguments: args, method: 'torrent-set' }, callback);
this.sendRequest(
{
id: 'webui',
jsonrpc: RPC._JsonRpcVersion,
method: 'torrent-set',
params,
},
callback,
);
}
loadDaemonStats(callback, context) {
const o = {
id: 'webui',
jsonrpc: RPC._JsonRpcVersion,
method: 'session-stats',
};
this.sendRequest(o, callback, context);
@@ -129,43 +151,46 @@ export class Remote {
updateTorrents(torrentIds, fields, callback, context) {
const o = {
arguments: {
id: 'webui',
jsonrpc: RPC._JsonRpcVersion,
method: 'torrent-get',
params: {
fields,
format: 'table',
},
method: 'torrent-get',
};
if (torrentIds) {
o.arguments.ids = torrentIds;
o.params.ids = torrentIds;
}
this.sendRequest(o, (response) => {
const arguments_ = response['arguments'];
callback.call(context, arguments_.torrents, arguments_.removed);
const { torrents, removed } = response.result;
callback.call(context, torrents, removed);
});
}
getFreeSpace(dir, callback, context) {
const o = {
arguments: {
path: dir,
},
id: 'webui',
jsonrpc: RPC._JsonRpcVersion,
method: 'free-space',
params: { path: dir },
};
this.sendRequest(o, (response) => {
const arguments_ = response['arguments'];
callback.call(context, arguments_.path, arguments_['size-bytes']);
const { path, 'size-bytes': size_bytes } = response.result;
callback.call(context, path, size_bytes);
});
}
changeFileCommand(torrentId, fileIndices, command) {
const arguments_ = {
const params = {
ids: [torrentId],
};
arguments_[command] = fileIndices;
params[command] = fileIndices;
this.sendRequest(
{
arguments: arguments_,
jsonrpc: RPC._JsonRpcVersion,
method: 'torrent-set',
params,
},
() => {
this._controller.refreshTorrents([torrentId]);
@@ -173,14 +198,14 @@ export class Remote {
);
}
sendTorrentSetRequests(method, torrent_ids, arguments_, callback, context) {
if (!arguments_) {
arguments_ = {};
}
arguments_['ids'] = torrent_ids;
sendTorrentSetRequests(method, torrent_ids, params, callback, context) {
params ||= {};
params.ids = torrent_ids;
const o = {
arguments: arguments_,
id: 'webui',
jsonrpc: RPC._JsonRpcVersion,
method,
params,
};
this.sendRequest(o, callback, context);
}
@@ -217,16 +242,17 @@ export class Remote {
removeTorrents(torrents, trash) {
const o = {
arguments: {
jsonrpc: RPC._JsonRpcVersion,
method: 'torrent-remove',
params: {
'delete-local-data': trash,
ids: [],
},
method: 'torrent-remove',
};
if (torrents) {
for (let index = 0, length_ = torrents.length; index < length_; ++index) {
o.arguments.ids.push(torrents[index].getId());
o.params.ids.push(torrents[index].getId());
}
}
this.sendRequest(o, () => {
@@ -254,20 +280,22 @@ export class Remote {
url = `magnet:?xt=urn:btih:${url}`;
}
const o = {
arguments: {
jsonrpc: RPC._JsonRpcVersion,
method: 'torrent-add',
params: {
filename: url,
paused: options.paused,
},
method: 'torrent-add',
};
this.sendRequest(o, () => {
this._controller.refreshTorrents();
});
}
savePrefs(arguments_) {
savePrefs(params) {
const o = {
arguments: arguments_,
jsonrpc: RPC._JsonRpcVersion,
method: 'session-set',
params,
};
this.sendRequest(o, () => {
this._controller.loadDaemonPrefs();
@@ -275,6 +303,7 @@ export class Remote {
}
updateBlocklist() {
const o = {
jsonrpc: RPC._JsonRpcVersion,
method: 'blocklist-update',
};
this.sendRequest(o, () => {

View File

@@ -65,8 +65,8 @@ export class RenameDialog extends EventTarget {
file_path,
new_name,
(response) => {
if (response.result === 'success') {
const args = response.arguments;
if ('result' in response) {
const args = response.result;
if (handler) {
handler.subtree.name = args.name;
setTextContent(handler.name_container, args.name);
@@ -85,11 +85,13 @@ export class RenameDialog extends EventTarget {
} else {
tor.refresh(args);
}
} else if (response.result === 'Invalid argument') {
} else {
const error_obj = response.error;
const err_msg =
error_obj.data?.errorString ?? error_obj.message ?? '';
const connection_alert = new AlertDialog({
heading: `Error renaming "${file_path}"`,
message:
'Could not rename a torrent or file name. The path to file may have changed/not reflected correctly or the argument is invalid.',
message: `${err_msg} (${error_obj.code}`,
});
this.controller.setCurrentPopup(connection_alert);
}

View File

@@ -18,7 +18,7 @@ export class StatisticsDialog extends EventTarget {
this.remote = remote;
const updateDaemon = () =>
this.remote.loadDaemonStats((data) => this._update(data.arguments));
this.remote.loadDaemonStats((data) => this._update(data.result));
const delay_msec = 5000;
this.interval = setInterval(updateDaemon, delay_msec);
updateDaemon();

View File

@@ -353,7 +353,7 @@ export class Transmission extends EventTarget {
loadDaemonPrefs() {
this.remote.loadDaemonPrefs((data) => {
this.session_properties = data.arguments;
this.session_properties = data.result;
this._openTorrentFromUrl();
});
}