Repository navigation
feat: discover Talkies hosts from Android companion #231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,84 @@ | ||||||||||||
| //! Optional DNS-SD advertisement for the Android Talkies companion. | ||||||||||||
| //! | ||||||||||||
| //! Android scans `_ssh._tcp` to find machines it can securely connect to. | ||||||||||||
| //! Publish only when this machine has a non-loopback SSH listener, and let | ||||||||||||
| //! Avahi's child lifetime match the Talkies daemon lifetime. Discovery is an | ||||||||||||
| //! enhancement; missing Avahi/ss must never prevent dictation from starting. | ||||||||||||
|
|
||||||||||||
| const std = @import("std"); | ||||||||||||
| const utils = @import("core").utils; | ||||||||||||
|
|
||||||||||||
| const default_ssh_port: u16 = 22; | ||||||||||||
|
|
||||||||||||
| pub const Publisher = struct { | ||||||||||||
| child: ?std.process.Child = null, | ||||||||||||
|
|
||||||||||||
| pub fn start(allocator: std.mem.Allocator) Publisher { | ||||||||||||
| const port = sshPort() orelse return .{}; | ||||||||||||
| const ss_output = utils.runCapture(allocator, &.{ "ss", "-H", "-ltn" }) catch return .{}; | ||||||||||||
| defer allocator.free(ss_output); | ||||||||||||
| if (!hasReachableListener(ss_output, port)) return .{}; | ||||||||||||
|
|
||||||||||||
| const port_text = std.fmt.allocPrintSentinel(allocator, "{d}", .{port}, 0) catch return .{}; | ||||||||||||
| defer allocator.free(port_text); | ||||||||||||
| const child = std.process.spawn(utils.io(), .{ | ||||||||||||
| .argv = &.{ "avahi-publish-service", "-s", "Talkies", "_ssh._tcp", port_text, "talkies=1" }, | ||||||||||||
| .stdin = .ignore, | ||||||||||||
| .stdout = .ignore, | ||||||||||||
| .stderr = .ignore, | ||||||||||||
| }) catch |err| { | ||||||||||||
| // Avahi is optional on many Linux desktops. | ||||||||||||
| if (err != error.FileNotFound) utils.logError("Companion discovery unavailable: {}", .{err}); | ||||||||||||
| return .{}; | ||||||||||||
| }; | ||||||||||||
| utils.log("Advertising the SSH host for the Talkies companion on port {d}", .{port}); | ||||||||||||
| return .{ .child = child }; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| pub fn stop(self: *Publisher) void { | ||||||||||||
| if (self.child) |*child| { | ||||||||||||
| child.kill(utils.io()); | ||||||||||||
| self.child = null; | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| fn sshPort() ?u16 { | ||||||||||||
| const value = utils.getEnv("TALKIES_SSH_PORT") orelse return default_ssh_port; | ||||||||||||
| const port = std.fmt.parseInt(u16, value, 10) catch return null; | ||||||||||||
| return if (port == 0) null else port; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| fn hasReachableListener(ss_output: []const u8, port: u16) bool { | ||||||||||||
| var lines = std.mem.tokenizeScalar(u8, ss_output, '\n'); | ||||||||||||
| while (lines.next()) |line| { | ||||||||||||
| var fields = std.mem.tokenizeAny(u8, line, " \t"); | ||||||||||||
| _ = fields.next() orelse continue; // state | ||||||||||||
| _ = fields.next() orelse continue; // recv-q | ||||||||||||
| _ = fields.next() orelse continue; // send-q | ||||||||||||
| const local = fields.next() orelse continue; | ||||||||||||
| const remote = fields.next() orelse continue; | ||||||||||||
| _ = remote; | ||||||||||||
|
|
||||||||||||
| var suffix_buf: [6]u8 = undefined; | ||||||||||||
| const suffix = std.fmt.bufPrint(&suffix_buf, ":{d}", .{port}) catch continue; | ||||||||||||
| if (!std.mem.endsWith(u8, local, suffix)) continue; | ||||||||||||
| const address_with_brackets = local[0 .. local.len - suffix.len]; | ||||||||||||
| const address = if (std.mem.startsWith(u8, address_with_brackets, "[") and | ||||||||||||
| std.mem.endsWith(u8, address_with_brackets, "]")) | ||||||||||||
| address_with_brackets[1 .. address_with_brackets.len - 1] | ||||||||||||
| else | ||||||||||||
| address_with_brackets; | ||||||||||||
| if (std.mem.eql(u8, address, "127.0.0.1") or std.mem.eql(u8, address, "::1")) continue; | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Treat the whole This check excludes only 🐛 Proposed fix--- "a/linux/src/app/companion_discovery.zig"
+++ "b/linux/src/app/companion_discovery.zig"
@@ -69,7 +69,10 @@
address_with_brackets[1 .. address_with_brackets.len - 1]
else
address_with_brackets;
- if (std.mem.eql(u8, address, "127.0.0.1") or std.mem.eql(u8, address, "::1")) continue;
+ const host_part = if (std.mem.indexOfScalar(u8, address, '%')) |i| address[0..i] else address;
+ if (std.mem.startsWith(u8, host_part, "127.") or
+ std.mem.startsWith(u8, host_part, "::ffff:127.") or
+ std.mem.eql(u8, host_part, "::1")) continue;
return true;
}
return false;Add a test case for 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
| return true; | ||||||||||||
| } | ||||||||||||
| return false; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| test "detects only externally reachable SSH listeners" { | ||||||||||||
| try std.testing.expect(hasReachableListener("LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\n", 22)); | ||||||||||||
| try std.testing.expect(hasReachableListener("LISTEN 0 128 [::]:2222 [::]:*\n", 2222)); | ||||||||||||
| try std.testing.expect(!hasReachableListener("LISTEN 0 128 127.0.0.1:22 0.0.0.0:*\n", 22)); | ||||||||||||
| try std.testing.expect(!hasReachableListener("LISTEN 0 128 [::1]:22 [::]:*\n", 22)); | ||||||||||||
| try std.testing.expect(!hasReachableListener("LISTEN 0 128 0.0.0.0:2200 0.0.0.0:*\n", 22)); | ||||||||||||
| } | ||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| package com.talkies.android | ||
|
|
||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||
| import androidx.test.platform.app.InstrumentationRegistry | ||
| import org.junit.Assert.assertArrayEquals | ||
| import org.junit.Assert.assertFalse | ||
| import org.junit.Assert.assertNotNull | ||
| import org.junit.Assert.assertNull | ||
| import org.junit.Test | ||
| import org.junit.runner.RunWith | ||
| import java.io.File | ||
| import java.nio.charset.StandardCharsets | ||
| import java.util.UUID | ||
|
|
||
| @RunWith(AndroidJUnit4::class) | ||
| class AndroidSshCredentialStoreTest { | ||
| private val context = InstrumentationRegistry.getInstrumentation().targetContext | ||
|
|
||
| @Test | ||
| fun remembersPasswordEncryptedAndBindsItToItsHostProfile() { | ||
| val store = AndroidSshCredentialStore(context) | ||
| val profile = profile() | ||
| val password = "Correct horse 🔐 battery".toCharArray() | ||
| try { | ||
| val directory = File(context.noBackupFilesDir, "ssh-credentials") | ||
| val existing = directory.listFiles().orEmpty().map { it.absolutePath }.toSet() | ||
| store.save(profile, password) | ||
| val files = directory.listFiles() | ||
| assertNotNull(files) | ||
| val encryptedFile = requireNotNull(files).single { | ||
| it.extension == "credential" && it.absolutePath !in existing | ||
| } | ||
| assertFalse(String(encryptedFile.readBytes(), StandardCharsets.UTF_8).contains(String(password))) | ||
| assertArrayEquals(password, requireNotNull(store.load(profile))) | ||
|
|
||
| val changedHost = profile.copy(host = "other.example") | ||
| assertNull(store.load(changedHost)) | ||
| assertNull(store.load(profile)) | ||
| } finally { | ||
| password.fill('\u0000') | ||
| store.delete(profile.id) | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun deletingAHostCredentialMakesItUnavailable() { | ||
| val store = AndroidSshCredentialStore(context) | ||
| val profile = profile() | ||
| val password = "secret".toCharArray() | ||
| try { | ||
| store.save(profile, password) | ||
| store.delete(profile.id) | ||
| assertNull(store.load(profile)) | ||
| } finally { | ||
| password.fill('\u0000') | ||
| store.delete(profile.id) | ||
| } | ||
| } | ||
|
|
||
| private fun profile() = SshHostProfile( | ||
| id = UUID.randomUUID().toString(), | ||
| name = "Test host", | ||
| host = "192.0.2.4", | ||
| port = 2222, | ||
| username = "tester", | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Publish a per-machine service name instead of the fixed name
Talkies.Every Linux machine advertises the instance name
Talkies. If two machines are on the same LAN, Avahi resolves the name conflict by renaming one of them, for example toTalkies #2. On Android,SshHostDiscoveryusesinfo.serviceNameasDiscoveredSshHost.name.NearbyHostCardshows that name, andtoProfilestores it as the saved profile name. As a result, users see identical or numbered entries and cannot tell which machine is which.onServiceLostalso matches on this name.Put the machine hostname in the instance name, for example
Talkies on <hostname>. Fall back toTalkiesif the hostname cannot be read.♻️ Proposed change
Build
service_namebefore the spawn call, using allocator ownership anddeferas withport_text:Check the
gethostnameAPI against the Zig master version pinned by CI.🤖 Prompt for AI Agents
Source: Coding guidelines