diff options
author | Christian Segundo | 2023-06-11 22:09:15 +0200 |
---|---|---|
committer | Christian Segundo | 2023-06-11 22:09:15 +0200 |
commit | 3bd3f432a95da405634cdbd2a662d79a3a5ba7af (patch) | |
tree | ed7ac24e3309d8c6b4f43d1051d95b18a7165a2c | |
download | zmission-3bd3f432a95da405634cdbd2a662d79a3a5ba7af.tar.gz |
wip
-rw-r--r-- | build.zig | 47 | ||||
-rw-r--r-- | src/main.zig | 73 | ||||
-rw-r--r-- | src/request.zig | 363 | ||||
-rw-r--r-- | src/transmission.zig | 106 | ||||
-rw-r--r-- | src/util.zig | 14 | ||||
-rw-r--r-- | test.c | 13 | ||||
-rw-r--r-- | xmission.h | 20 |
7 files changed, 636 insertions, 0 deletions
diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..d13c67b --- /dev/null +++ b/build.zig @@ -0,0 +1,47 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const lib = b.addStaticLibrary(.{ + .name = "zmission", + // In this case the main source file is merely a path, however, in more + // complicated build scripts, this could be a generated file. + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + // This declares intent for the library to be installed into the standard + // location when the user invokes the "install" step (the default step when + // running `zig build`). + b.installArtifact(lib); + + // Creates a step for unit testing. This only builds the test executable + // but does not run it. + const main_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const run_main_tests = b.addRunArtifact(main_tests); + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build test` + // This will evaluate the `test` step rather than the default, which is "install". + const test_step = b.step("test", "Run library tests"); + test_step.dependOn(&run_main_tests.step); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..1b796a3 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,73 @@ +const std = @import("std"); +const testing = std.testing; +const transmission = @import("transmission.zig"); + +var gpa = std.heap.GeneralPurposeAllocator(.{}){}; +const allocator = gpa.allocator(); + +export fn add(a: i32, b: i32) i32 { + const clientOptions = transmission.ClientOptions{ + .host = "192.168.0.2", + .port = 9091, + .https = false, + }; + var client = transmission.Client.init(allocator, clientOptions); + defer client.deinit(); + + const body = transmission.sessionGet(&client) catch |err| { + std.debug.print("error: {any}\n", .{err}); + unreachable; + }; + defer allocator.free(body); + + std.debug.print("body: {s}\n", .{body}); + + return a + b; +} + +export fn c_client_init(clientOptions: transmission.ClientOptions) ?*anyopaque { + var client = allocator.create(transmission.Client) catch unreachable; + client.* = transmission.Client.init(allocator, clientOptions); + return @ptrCast(*anyopaque, client); +} + +export fn c_client_deinit(client: ?*anyopaque) void { + var real_client: *transmission.Client = @ptrCast(*transmission.Client, @alignCast( + @alignOf(transmission.Client), + client.?, + )); + real_client.*.deinit(); + allocator.destroy(real_client); +} + +export fn c_session_get(client: ?*anyopaque, buf: [*]u8) c_int { + var real_client: *transmission.Client = @ptrCast(*transmission.Client, @alignCast( + @alignOf(transmission.Client), + client.?, + )); + + const body = transmission.sessionGet(real_client) catch |err| { + std.debug.print("error: {any}\n", .{err}); + unreachable; + }; + defer allocator.free(body); + + std.debug.print("body: {s}\n", .{body}); + _ = buf; + return 0; +} + +test "basic add functionality" { + try testing.expect(add(3, 7) == 10); +} + +test "c api" { + const clientOptions = transmission.ClientOptions{ + .host = "192.168.0.2", + .port = 9091, + .https = false, + }; + var foo = c_client_init(clientOptions); + _ = c_session_get(foo, undefined); + c_client_deinit(foo); +} diff --git a/src/request.zig b/src/request.zig new file mode 100644 index 0000000..950a1fc --- /dev/null +++ b/src/request.zig @@ -0,0 +1,363 @@ +const std = @import("std"); +const util = @import("util.zig"); + +const Method = enum { + // Torrent requests: Torrent action + // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#31-torrent-action-requests + torrent_start, + torrent_start_now, + torrent_stop, + torrent_verify, + torrent_reannounce, + + // Torrent requests: Mutator + // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#32-torrent-mutator-torrent-set + torrent_set, + + // Torrent requests: Torrent accessor + // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#33-torrent-accessor-torrent-get + torrent_get, + + // Torrent requests: Torrent adding + // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#34-adding-a-torrent + torrent_add, + + // Torrent requests: Torrent removing + // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#35-removing-a-torrent + torrent_remove, + + // Torrent requests: Torrent moving + // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#36-moving-a-torrent + torrent_set_location, + + // Torrent requests: Torrent moving + // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#37-renaming-a-torrents-path + torrent_rename_path, + + // Session requests: Get + // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#4--session-requests + session_get, + + // Session requests: Mutator + // https://github.com/transmission/transmission/blob/main/docs/rpc-spec.md#411-mutators + session_set, + //session_stats, + + const Self = @This(); + + // Transmission RPC uses hyphens instead of underscores, this creates a + // table of method names using the same enum field names but with + // underscores. + pub const Fields = util.RPCFields(Self); + + pub fn str(self: Self) []const u8 { + return Fields[@enumToInt(self)]; + } + + pub fn jsonStringify(self: Self, options: std.json.StringifyOptions, out_stream: anytype) !void { + try std.json.stringify(self.str(), options, out_stream); + } +}; + +pub const SessionSetFields = struct { + alt_speed_down: ?usize = null, + alt_speed_enabled: ?bool = null, + alt_speed_time_begin: ?usize = null, + alt_speed_time_day: ?usize = null, + alt_speed_time_enabled: ?bool = null, + alt_speed_time_end: ?usize = null, + alt_speed_up: ?usize = null, + blocklist_enabled: ?bool = null, + blocklist_url: ?[]u8 = null, + cache_size_mb: ?usize = null, + default_trackers: ?[]u8 = null, + dht_enabled: ?bool = null, + download_dir: ?[]u8 = null, + download_dir_free_space: ?usize = null, + download_queue_enabled: ?bool = null, + download_queue_size: ?usize = null, + encryption: ?enum { + required, + preferred, + tolerated, + + const Self = @This(); + + pub const Fields = util.RPCFields(Self); + + pub fn str(self: Self) []const u8 { + return Fields[@enumToInt(self)]; + } + + pub fn jsonStringify(self: Self, options: std.json.StringifyOptions, out_stream: anytype) !void { + try std.json.stringify(self.str(), options, out_stream); + } + } = null, + idle_seeding_limit_enabled: ?bool = null, + idle_seeding_limit: ?usize = null, + incomplete_dir_enabled: ?bool = null, + incomplete_dir: ?[]u8 = null, + lpd_enabled: ?bool = null, + peer_limit_global: ?usize = null, + peer_limit_per_torrent: ?usize = null, + peer_port_random_on_start: ?bool = null, + peer_port: ?usize = null, + pex_enabled: ?bool = null, + port_forwarding_enabled: ?bool = null, + queue_stalled_enabled: ?bool = null, + queue_stalled_minutes: ?usize = null, + rename_partial_files: ?bool = null, + script_torrent_added_enabled: ?bool = null, + script_torrent_added_filename: ?[]u8 = null, + script_torrent_done_enabled: ?bool = null, + script_torrent_done_filename: ?[]u8 = null, + script_torrent_done_seeding_enabled: ?bool = null, + script_torrent_done_seeding_filename: ?[]u8 = null, + seed_queue_enabled: ?bool = null, + seed_queue_size: ?usize = null, + seedRatioLimit: ?f64 = null, + seedRatioLimited: ?bool = null, + speed_limit_down_enabled: ?bool = null, + speed_limit_down: ?usize = null, + speed_limit_up_enabled: ?bool = null, + speed_limit_up: ?usize = null, + start_added_torrents: ?bool = null, + trash_original_torrent_files: ?bool = null, + utp_enabled: ?bool = null, +}; + +pub const SessionGetFields = enum { + alt_speed_down, + alt_speed_enabled, + alt_speed_time_begin, + alt_speed_time_day, + alt_speed_time_enabled, + alt_speed_time_end, + alt_speed_up, + blocklist_enabled, + blocklist_size, + blocklist_url, + cache_size_mb, + config_dir, + default_trackers, + dht_enabled, + download_dir, + download_dir_free_space, + download_queue_enabled, + download_queue_size, + encryption, + idle_seeding_limit_enabled, + idle_seeding_limit, + incomplete_dir_enabled, + incomplete_dir, + lpd_enabled, + peer_limit_global, + peer_limit_per_torrent, + peer_port_random_on_start, + peer_port, + pex_enabled, + port_forwarding_enabled, + queue_stalled_enabled, + queue_stalled_minutes, + rename_partial_files, + rpc_version_minimum, + rpc_version_semver, + rpc_version, + script_torrent_added_enabled, + script_torrent_added_filename, + script_torrent_done_enabled, + script_torrent_done_filename, + script_torrent_done_seeding_enabled, + script_torrent_done_seeding_filename, + seed_queue_enabled, + seed_queue_size, + seedRatioLimit, + seedRatioLimited, + speed_limit_down_enabled, + speed_limit_down, + speed_limit_up_enabled, + speed_limit_up, + start_added_torrents, + trash_original_torrent_files, + units, + utp_enabled, + version, + + const Self = @This(); + + pub const Fields = util.RPCFields(Self); + + pub fn str(self: Self) []const u8 { + return Fields[@enumToInt(self)]; + } + + // We have to add our own custom stringification because the default + // one will nest the enum inside the method name. + pub fn jsonStringify(self: Self, options: std.json.StringifyOptions, out_stream: anytype) !void { + try std.json.stringify(self.str(), options, out_stream); + } +}; + +pub const TorrentIDs = union(enum) { + single: usize, + many: []const usize, + recently_active, + + const Self = @This(); + + pub fn jsonStringify(self: Self, options: std.json.StringifyOptions, out_stream: anytype) !void { + switch (self) { + .single => |v| try std.json.stringify(v, options, out_stream), + .many => |v| try std.json.stringify(v, options, out_stream), + .recently_active => try std.json.stringify("recently-active", options, out_stream), + } + } +}; + +pub const TorrentActionFields = struct { + ids: TorrentIDs, +}; + +pub const Request = struct { + method: Method, + arguments: union(Method) { + torrent_start: TorrentActionFields, + torrent_start_now: TorrentActionFields, + torrent_stop: TorrentActionFields, + torrent_verify: TorrentActionFields, + torrent_reannounce: TorrentActionFields, + + torrent_set: u8, + torrent_get: u8, + torrent_add: u8, + torrent_remove: u8, + torrent_set_location: u8, + torrent_rename_path: u8, + + session_get: struct { + fields: []const SessionGetFields, + }, + + session_set: SessionSetFields, + + const Self = @This(); + + pub fn jsonStringify(self: Self, _: std.json.StringifyOptions, out_stream: anytype) !void { + const options = std.json.StringifyOptions{ + .emit_null_optional_fields = false, + }; + + switch (self) { + .torrent_start, + .torrent_start_now, + .torrent_stop, + .torrent_verify, + .torrent_reannounce, + => |v| try std.json.stringify(v, options, out_stream), + + .session_set => |v| try std.json.stringify(v, options, out_stream), + + .session_get => |v| try std.json.stringify(v, options, out_stream), + else => unreachable, + } + } + }, +}; + +test "json request encoding" { + const test_case = struct { + name: []const u8, + expected: []const u8, + request: Request, + + fn run(self: @This()) !void { + var req = std.ArrayList(u8).init(std.testing.allocator); + defer req.deinit(); + try std.json.stringify(self.request, .{}, req.writer()); + try std.testing.expect(std.mem.eql(u8, req.items, self.expected)); + } + }; + + const test_cases = [_]test_case{ + .{ + .name = "session-get", + .request = .{ + .method = .session_get, + .arguments = .{ + .session_get = .{ + .fields = &[_]SessionGetFields{ + .version, + .utp_enabled, + }, + }, + }, + }, + .expected = + \\{"method":"session-get","arguments":{"fields":["version","utp-enabled"]}} + , + }, + + .{ + .name = "session-set", + .request = .{ + .method = .session_set, + .arguments = .{ + .session_set = .{ + .lpd_enabled = true, + .encryption = .required, + }, + }, + }, + .expected = + \\{"method":"session-set","arguments":{"encryption":"required","lpd_enabled":true}} + , + }, + + .{ + .name = "torrent-reannounce single id", + .request = .{ + .method = .torrent_reannounce, + .arguments = .{ + .torrent_reannounce = .{ + .ids = .{ .single = 1 }, + }, + }, + }, + .expected = + \\{"method":"torrent-reannounce","arguments":{"ids":1}} + , + }, + + .{ + .name = "torrent-reannounce multiple id", + .request = .{ + .method = .torrent_reannounce, + .arguments = .{ + .torrent_reannounce = .{ + .ids = .{ .many = &[_]usize{ 1, 2 } }, + }, + }, + }, + .expected = + \\{"method":"torrent-reannounce","arguments":{"ids":[1,2]}} + , + }, + + .{ + .name = "torrent-reannounce recently-active", + .request = .{ + .method = .torrent_reannounce, + .arguments = .{ + .torrent_reannounce = .{ + .ids = .recently_active, + }, + }, + }, + .expected = + \\{"method":"torrent-reannounce","arguments":{"ids":"recently-active"}} + , + }, + }; + + for (test_cases) |tc| try tc.run(); +} diff --git a/src/transmission.zig b/src/transmission.zig new file mode 100644 index 0000000..fec1b12 --- /dev/null +++ b/src/transmission.zig @@ -0,0 +1,106 @@ +const std = @import("std"); + +const Request = @import("request.zig").Request; +const SessionGetFields = @import("request.zig").SessionGetFields; + +pub const ClientOptions = extern struct { + host: [*:0]const u8, + port: u16, + https: bool, + user: ?[*:0]const u8 = null, + password: ?[*:0]const u8 = null, +}; + +pub const Client = struct { + uri: std.Uri, + allocator: std.mem.Allocator, + http_client: std.http.Client, + http_headers: std.http.Headers, + current_session_id: []u8 = "", + + pub fn init(allocator: std.mem.Allocator, opts: ClientOptions) Client { + const base_url = std.Uri{ + .path = "/transmission/rpc", + .scheme = blk: { + if (opts.https) { + break :blk "https"; + } else { + break :blk "http"; + } + }, + .host = std.mem.span(opts.host), + .port = opts.port, + .user = null, + .password = null, + .query = null, + .fragment = null, + }; + + return Client{ + .uri = base_url, + .allocator = allocator, + .http_client = std.http.Client{ .allocator = allocator }, + .http_headers = std.http.Headers{ .allocator = allocator }, + }; + } + + pub fn deinit(self: *Client) void { + self.http_headers.deinit(); + self.http_client.deinit(); + self.allocator.free(self.current_session_id); + } + + fn setSessionId(self: *Client, r: std.http.Client.Response) !void { + self.allocator.free(self.current_session_id); + const session_id = r.headers.getFirstValue("X-Transmission-Session-Id") orelse return error.NoSessionId; + self.current_session_id = try std.fmt.allocPrint(self.allocator, "{s}", .{session_id}); + } + + fn newRequest(self: *Client) !std.http.Client.Request { + try self.http_headers.append("X-Transmission-Session-Id", self.current_session_id); + return try self.http_client.request(.POST, self.uri, self.http_headers, .{}); + } + + pub fn do(self: *Client, req: Request) ![]u8 { + var real_req = try self.newRequest(); + defer real_req.deinit(); + + var payload = std.ArrayList(u8).init(self.allocator); + defer payload.deinit(); + + try std.json.stringify(req, .{}, payload.writer()); + + real_req.transfer_encoding = std.http.Client.RequestTransfer{ + .content_length = payload.items.len, + }; + + try real_req.start(); + if (try real_req.write(payload.items) != payload.items.len) { + return error.InvalidSize; + } + + try real_req.finish(); + try real_req.wait(); + + if (real_req.response.status == std.http.Status.conflict) { + try self.setSessionId(real_req.response); + return self.do(req); + } + + const body = try real_req.reader().readAllAlloc(self.allocator, 9000); + return body; + } +}; + +pub fn sessionGet(client: *Client) ![]u8 { + const r = Request{ + .method = .session_get, + .arguments = .{ + .session_get = .{ + .fields = &[_]SessionGetFields{ .version, .utp_enabled }, + }, + }, + }; + const body = try client.do(r); + return body; +} diff --git a/src/util.zig b/src/util.zig new file mode 100644 index 0000000..b701ff6 --- /dev/null +++ b/src/util.zig @@ -0,0 +1,14 @@ +const std = @import("std"); + +// Returns an array of all the fields in the given +// type replacing all '_' with '-'. +pub fn RPCFields(comptime E: type) []const []const u8 { + @setEvalBranchQuota(10000); + var names: []const []const u8 = &[_][]const u8{}; + for (std.meta.fields(E)) |field| { + var name: [field.name.len]u8 = undefined; + _ = std.mem.replace(u8, field.name, &[_]u8{'_'}, &[_]u8{'-'}, &name); + names = names ++ &[_][]const u8{&name}; + } + return names; +} @@ -0,0 +1,13 @@ +// This header is generated by zig from mathtest.zig +#include "xmission.h" +#include <stdio.h> + +int main(int argc, char **argv) { + struct ClientOptions opts; + opts.host = "192.168.0.2"; + opts.port = 9091; + + c_client client = c_client_init(opts); + c_client_deinit(client); + return 0; +} diff --git a/xmission.h b/xmission.h new file mode 100644 index 0000000..be1d8d7 --- /dev/null +++ b/xmission.h @@ -0,0 +1,20 @@ +#ifndef _XMISSION_H + +#define _XMISSION_H +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +typedef void *c_client; + +typedef struct ClientOptions { + char *host; + uint16_t port; + bool https; + char *user; + char *passowrd; +} ClientOptions_t; + +c_client c_client_init(ClientOptions_t opts); +void c_client_deinit(c_client); +#endif |