
Send Wake-on-LAN/Wake-on-Wireless magic packets via UDP broadcast by MAC address; optional ARP-based IP→MAC lookup, test-friendly WakeSender/FakeWake seam, and a small CLI for batch wakes.
Kotlin Multiplatform Wake-on-LAN / Wake-on-Wireless: send a magic packet over UDP broadcast to wake a device on the network by its MAC address. One small headless library, shared across iOS, macOS, Android, and the JVM.
Status: initial scaffold. Library core (
:wake+:wake-testing), the build toolchain, CI, and the Maven Central + SPM release workflow are in place — see.github/PUBLISHING.mdto cut a release. API reference HTML is generated on demand by Dokka (mise run docs).
A Wake-on-LAN magic packet is 102 bytes: six 0xFF bytes followed by the
target's 6-byte MAC address repeated sixteen times, sent as a UDP broadcast
datagram (port 9 by convention). A network card listening in Wake-on-LAN mode
powers its host on when it sees that pattern addressed to its own MAC.
Wake-on-Wireless is the same mechanism over Wi-Fi — no different code path. Be aware, though, that WoL is far more reliable over Ethernet than Wi-Fi. A sleeping wired NIC keeps its PHY powered to watch for the magic packet; many Wi-Fi chipsets instead power the radio down in sleep, so nothing is listening and no magic packet — from this library or any other — can reach the device. If a device won't wake on Wi-Fi, that's usually the cause, not the packet. Wiring it to Ethernet (and using that interface's MAC) is the dependable path; WoWLAN over Wi-Fi works only on the subset of devices and access points that keep the radio in a low-power listen mode. (Example: a Roku wakes reliably on Ethernet but typically not over Wi-Fi, since its radio sleeps.)
// gradle/libs.versions.toml
[libraries]
wake = { module = "com.happycodelucky.wake:wake", version = "1.2.0" }
wake-testing = { module = "com.happycodelucky.wake:wake-testing", version = "1.2.0" }
// build.gradle.kts
commonMain.dependencies { implementation(libs.wake) }
commonTest.dependencies { implementation(libs.wake.testing) } // FakeWakewake brings KotlinResult
(com.happycodelucky.kotlinresult:kotlinresult, its Result type) with it
transitively. If you build your own Apple framework that links wake, also
export KotlinResult into it — see
KotlinResult's setup notes.
Add this repository as a package dependency, pinned to a release tag. The
WakeKit XCFramework ships as a GitHub Release asset (see
.github/PUBLISHING.md).
.package(url: "https://github.com/happycodelucky/wake-kmp.git", from: "1.2.0")Wake.up returns Result<Unit> from
KotlinResult — a
Swift-friendly mirror of the standard library's Result, with the same API as
members (isSuccess, getOrThrow, fold, onFailure, map…, and
toStdlibResult() for the stdlib type). A failure always holds a sealed
WakeException, so a when over it is exhaustive:
Wake.up("AA:BB:CC:DD:EE:FF")
.onSuccess { println("magic packet sent") }
.onFailure { e ->
when (e as WakeException) {
is WakeException.InvalidMacAddress -> println("bad MAC: ${e.mac}")
is WakeException.NetworkError -> println("send failed: ${e.message}")
}
}
Wake.up(mac).getOrThrow() // or throw instead
// Cross a router that forwards directed broadcasts, or change the port:
Wake.up("aa-bb-cc-dd-ee-ff", broadcastAddress = "192.168.1.255", port = 7)Wake is a stateless object — there's nothing to construct or close; just call
Wake.up(...). MAC strings may use colons (AA:BB:CC:DD:EE:FF), hyphens
(aa-bb-cc-dd-ee-ff), or no separators (aabbccddeeff), case-insensitive.
import WakeKit
do {
try await Wake.up(mac: "AA:BB:CC:DD:EE:FF").get()
print("magic packet sent")
} catch let error as WakeException {
switch onEnum(of: error) {
case let .invalidMacAddress(e): print("bad MAC: \(e.mac)")
case let .networkError(e): print("send failed: \(e.message)")
}
}The Swift module / SPM product is WakeKit (the framework is named with a
"Kit" suffix so the module name doesn't collide with the Wake type).
Wake.up(mac:) returns the same result, as KotlinResult<KotlinUnit>; unwrap it
with get(), which returns
on success and throws the Kotlin WakeException itself as a Swift Error —
catch it by class and switch exhaustively with onEnum(of:), or assert
#expect(throws: WakeException.InvalidMacAddress.self) in Swift Testing. For a
value, name the type: let mac: String = try result.get() (or
get(as: String.self)); result.result(as:) gives a Swift.Result, and
KotlinResult<NSString>(value:) / KotlinResult<KotlinUnit>(failure:) build one
(handy in Swift test fakes). Task cancellation arrives as CancellationError.
You often know a device's IP but Wake-on-LAN needs its MAC. lookupMac(ip)
reads the host's ARP cache to resolve one — capture the MAC while the device is
awake, then wake it by MAC later (ARP entries age out once a host goes idle).
lookupMac("192.168.1.42") // Result<String>
.onSuccess { mac -> Wake.up(mac) } // feeds straight into up()
.onFailure { e ->
if (e is MacLookupException.NotInCache) println("no ARP entry — ping it first")
}// A global async function (not a Wake member):
do {
let mac: String = try await lookupMac(ip: "192.168.1.42").get()
try await Wake.up(mac: mac).get()
} catch is MacLookupException.NotInCache {
print("no ARP entry — ping it first")
}This is intentionally not a cross-platform API. Reading the ARP cache is
impossible on iOS (the kernel returns a spoofed address to sandboxed apps since
iOS 10.2) and on modern Android (/proc/net/arp is SELinux-blocked from API 29,
with no replacement). So lookupMac is declared only on the macOS and JVM
desktop targets — calling it from iOS or Android is a compile error, never a
fake result. The companion sample CLI (mise run cli -- --ip 192.168.1.42) uses
it for its "wake by IP" path.
| Target | Implementation |
|---|---|
iOS / macOS (iosArm64, iosSimulatorArm64, macosArm64) |
POSIX UDP sockets (socket / setsockopt(SO_BROADCAST) / sendto) via Kotlin/Native cinterop; iOS apps must declare NSLocalNetworkUsageDescription
|
Android (arm64-v8a, minSdk 30) |
java.net.DatagramSocket with broadcast enabled; declares android.permission.INTERNET
|
| JVM (desktop, JVM 21) |
java.net.DatagramSocket — the same java.net broadcaster as Android, shared via a jvmShared source set. No permission or manifest needed |
The same Wake.up(...) call shown above works on every target. Android and the
JVM desktop target share their entire UDP send path (one java.net broadcaster
in a jvmShared intermediate source set); the only difference is that Android
contributes an INTERNET-permission manifest entry, while a plain JVM process
needs no permission to open a broadcast socket. Native targets are ARM-only, no
x86 (CLAUDE.md §1). The library is headless — any UI lives in the consuming app.
On iOS the first broadcast send triggers the Local Network privacy prompt, so
the embedding app must supply NSLocalNetworkUsageDescription in its Info.plist
— without it iOS silently drops the packet and Wake.up still reports
success (the datagram was handed to the OS, not delivered). This is
the app's own Info.plist, not something the library can vendor — unlike Android's
INTERNET permission, which merges in from the library manifest. macOS does not
prompt. If broadcasts still don't leave the device, the app may additionally need
the com.apple.developer.networking.multicast entitlement.
Because Wake.up(...) is a static call, a feature you want to unit-test depends
on the small WakeSender interface instead — production wires Wake.asSender(),
tests wire FakeWake from :wake-testing. FakeWake records every up call
and returns a programmable Result without opening a socket:
import com.happycodelucky.kotlinresult.Result // not kotlin.Result
// Production: WakeMyDesktop(wake = Wake.asSender())
class WakeMyDesktop(private val wake: WakeSender = Wake.asSender()) {
suspend fun run() = wake.up("AA:BB:CC:DD:EE:FF")
}
// Test:
val fake = FakeWake(result = Result.failure(WakeException.NetworkError("no route to host")))
val result = WakeMyDesktop(wake = fake).run()
assertIs<WakeException.NetworkError>(result.exceptionOrNull())
assertEquals("AA:BB:CC:DD:EE:FF", fake.lastCall?.mac)Code that doesn't need a test seam can ignore WakeSender and call Wake.up(...)
directly.
In Swift, the same seam is the WakeSender protocol: production passes
Wake.asSender(), and tests pass the FakeWake that ships in WakeKit. It records
calls and returns a programmable KotlinResult:
import WakeKit
struct WakeMyDesktop {
let wake: WakeSender // Wake.asSender() in production
func run() async throws { try await wake.up(mac: "AA:BB:CC:DD:EE:FF").get() }
}
// Test:
let fake = FakeWake(result: KotlinResult(failure: WakeException.NetworkError(message: "no route", cause: nil)))
await #expect(throws: WakeException.NetworkError.self) { try await WakeMyDesktop(wake: fake).run() }
#expect(fake.lastCall?.mac == "AA:BB:CC:DD:EE:FF")Subclass FakeWake and override respond(to:) to script per-call results. Use
it rather than conforming to WakeSender by hand: SKIE renames the protocol's
requirement to __up(mac:broadcastAddress:port:), and FakeWake implements it
for you.
:apps:cli is a small JVM command-line tool that wakes a device by MAC or by IP
(IP → ARP-resolve → wake). It's a sample, not a published artifact — a plain
kotlin("jvm") app depending on :wake.
mise run cli -- AA:BB:CC:DD:EE:FF # wake by MAC
mise run cli -- <wifi-mac> <eth-mac> # wake several MACs (one packet each)
mise run cli -- --ip 192.168.1.42 # resolve the IP's MAC, then wake it
mise run cli -- --ip 192.168.1.42 --port 7 --broadcast 192.168.1.255
./gradlew :apps:cli:run --args="--help" # full usage (--help via the raw form)"Wake by IP" relies on lookupMac, so it works on a Linux or macOS desktop but
not, say, inside a container with no arp//proc/net/arp.
Passing several MACs sends a packet to each. Some devices listen on more than one interface — a Roku in deep sleep, for instance, has separate Wi-Fi and Ethernet MACs (visible under Settings → Network → About on the device), and you have to wake the interface it's actually on. Listing both is the reliable move.
mise install # provision JDK, Gradle, gh
mise run check # ktlint + detekt + all unit tests (all targets, both modules)
mise run build # assemble the release WakeKit.xcframework
mise run cli -- … # run the sample CLI (see above)See mise.toml for the full task surface and CLAUDE.md
for the project rules.
IP_BOUND_IF egress-interface binding for multi-interface hosts.WifiManager multicast lock + a Context-taking factory overload,
for chipsets that gate broadcast egress behind a held lock.MIT License. See LICENSE.
Kotlin Multiplatform Wake-on-LAN / Wake-on-Wireless: send a magic packet over UDP broadcast to wake a device on the network by its MAC address. One small headless library, shared across iOS, macOS, Android, and the JVM.
Status: initial scaffold. Library core (
:wake+:wake-testing), the build toolchain, CI, and the Maven Central + SPM release workflow are in place — see.github/PUBLISHING.mdto cut a release. API reference HTML is generated on demand by Dokka (mise run docs).
A Wake-on-LAN magic packet is 102 bytes: six 0xFF bytes followed by the
target's 6-byte MAC address repeated sixteen times, sent as a UDP broadcast
datagram (port 9 by convention). A network card listening in Wake-on-LAN mode
powers its host on when it sees that pattern addressed to its own MAC.
Wake-on-Wireless is the same mechanism over Wi-Fi — no different code path. Be aware, though, that WoL is far more reliable over Ethernet than Wi-Fi. A sleeping wired NIC keeps its PHY powered to watch for the magic packet; many Wi-Fi chipsets instead power the radio down in sleep, so nothing is listening and no magic packet — from this library or any other — can reach the device. If a device won't wake on Wi-Fi, that's usually the cause, not the packet. Wiring it to Ethernet (and using that interface's MAC) is the dependable path; WoWLAN over Wi-Fi works only on the subset of devices and access points that keep the radio in a low-power listen mode. (Example: a Roku wakes reliably on Ethernet but typically not over Wi-Fi, since its radio sleeps.)
// gradle/libs.versions.toml
[libraries]
wake = { module = "com.happycodelucky.wake:wake", version = "1.2.0" }
wake-testing = { module = "com.happycodelucky.wake:wake-testing", version = "1.2.0" }
// build.gradle.kts
commonMain.dependencies { implementation(libs.wake) }
commonTest.dependencies { implementation(libs.wake.testing) } // FakeWakewake brings KotlinResult
(com.happycodelucky.kotlinresult:kotlinresult, its Result type) with it
transitively. If you build your own Apple framework that links wake, also
export KotlinResult into it — see
KotlinResult's setup notes.
Add this repository as a package dependency, pinned to a release tag. The
WakeKit XCFramework ships as a GitHub Release asset (see
.github/PUBLISHING.md).
.package(url: "https://github.com/happycodelucky/wake-kmp.git", from: "1.2.0")Wake.up returns Result<Unit> from
KotlinResult — a
Swift-friendly mirror of the standard library's Result, with the same API as
members (isSuccess, getOrThrow, fold, onFailure, map…, and
toStdlibResult() for the stdlib type). A failure always holds a sealed
WakeException, so a when over it is exhaustive:
Wake.up("AA:BB:CC:DD:EE:FF")
.onSuccess { println("magic packet sent") }
.onFailure { e ->
when (e as WakeException) {
is WakeException.InvalidMacAddress -> println("bad MAC: ${e.mac}")
is WakeException.NetworkError -> println("send failed: ${e.message}")
}
}
Wake.up(mac).getOrThrow() // or throw instead
// Cross a router that forwards directed broadcasts, or change the port:
Wake.up("aa-bb-cc-dd-ee-ff", broadcastAddress = "192.168.1.255", port = 7)Wake is a stateless object — there's nothing to construct or close; just call
Wake.up(...). MAC strings may use colons (AA:BB:CC:DD:EE:FF), hyphens
(aa-bb-cc-dd-ee-ff), or no separators (aabbccddeeff), case-insensitive.
import WakeKit
do {
try await Wake.up(mac: "AA:BB:CC:DD:EE:FF").get()
print("magic packet sent")
} catch let error as WakeException {
switch onEnum(of: error) {
case let .invalidMacAddress(e): print("bad MAC: \(e.mac)")
case let .networkError(e): print("send failed: \(e.message)")
}
}The Swift module / SPM product is WakeKit (the framework is named with a
"Kit" suffix so the module name doesn't collide with the Wake type).
Wake.up(mac:) returns the same result, as KotlinResult<KotlinUnit>; unwrap it
with get(), which returns
on success and throws the Kotlin WakeException itself as a Swift Error —
catch it by class and switch exhaustively with onEnum(of:), or assert
#expect(throws: WakeException.InvalidMacAddress.self) in Swift Testing. For a
value, name the type: let mac: String = try result.get() (or
get(as: String.self)); result.result(as:) gives a Swift.Result, and
KotlinResult<NSString>(value:) / KotlinResult<KotlinUnit>(failure:) build one
(handy in Swift test fakes). Task cancellation arrives as CancellationError.
You often know a device's IP but Wake-on-LAN needs its MAC. lookupMac(ip)
reads the host's ARP cache to resolve one — capture the MAC while the device is
awake, then wake it by MAC later (ARP entries age out once a host goes idle).
lookupMac("192.168.1.42") // Result<String>
.onSuccess { mac -> Wake.up(mac) } // feeds straight into up()
.onFailure { e ->
if (e is MacLookupException.NotInCache) println("no ARP entry — ping it first")
}// A global async function (not a Wake member):
do {
let mac: String = try await lookupMac(ip: "192.168.1.42").get()
try await Wake.up(mac: mac).get()
} catch is MacLookupException.NotInCache {
print("no ARP entry — ping it first")
}This is intentionally not a cross-platform API. Reading the ARP cache is
impossible on iOS (the kernel returns a spoofed address to sandboxed apps since
iOS 10.2) and on modern Android (/proc/net/arp is SELinux-blocked from API 29,
with no replacement). So lookupMac is declared only on the macOS and JVM
desktop targets — calling it from iOS or Android is a compile error, never a
fake result. The companion sample CLI (mise run cli -- --ip 192.168.1.42) uses
it for its "wake by IP" path.
| Target | Implementation |
|---|---|
iOS / macOS (iosArm64, iosSimulatorArm64, macosArm64) |
POSIX UDP sockets (socket / setsockopt(SO_BROADCAST) / sendto) via Kotlin/Native cinterop; iOS apps must declare NSLocalNetworkUsageDescription
|
Android (arm64-v8a, minSdk 30) |
java.net.DatagramSocket with broadcast enabled; declares android.permission.INTERNET
|
| JVM (desktop, JVM 21) |
java.net.DatagramSocket — the same java.net broadcaster as Android, shared via a jvmShared source set. No permission or manifest needed |
The same Wake.up(...) call shown above works on every target. Android and the
JVM desktop target share their entire UDP send path (one java.net broadcaster
in a jvmShared intermediate source set); the only difference is that Android
contributes an INTERNET-permission manifest entry, while a plain JVM process
needs no permission to open a broadcast socket. Native targets are ARM-only, no
x86 (CLAUDE.md §1). The library is headless — any UI lives in the consuming app.
On iOS the first broadcast send triggers the Local Network privacy prompt, so
the embedding app must supply NSLocalNetworkUsageDescription in its Info.plist
— without it iOS silently drops the packet and Wake.up still reports
success (the datagram was handed to the OS, not delivered). This is
the app's own Info.plist, not something the library can vendor — unlike Android's
INTERNET permission, which merges in from the library manifest. macOS does not
prompt. If broadcasts still don't leave the device, the app may additionally need
the com.apple.developer.networking.multicast entitlement.
Because Wake.up(...) is a static call, a feature you want to unit-test depends
on the small WakeSender interface instead — production wires Wake.asSender(),
tests wire FakeWake from :wake-testing. FakeWake records every up call
and returns a programmable Result without opening a socket:
import com.happycodelucky.kotlinresult.Result // not kotlin.Result
// Production: WakeMyDesktop(wake = Wake.asSender())
class WakeMyDesktop(private val wake: WakeSender = Wake.asSender()) {
suspend fun run() = wake.up("AA:BB:CC:DD:EE:FF")
}
// Test:
val fake = FakeWake(result = Result.failure(WakeException.NetworkError("no route to host")))
val result = WakeMyDesktop(wake = fake).run()
assertIs<WakeException.NetworkError>(result.exceptionOrNull())
assertEquals("AA:BB:CC:DD:EE:FF", fake.lastCall?.mac)Code that doesn't need a test seam can ignore WakeSender and call Wake.up(...)
directly.
In Swift, the same seam is the WakeSender protocol: production passes
Wake.asSender(), and tests pass the FakeWake that ships in WakeKit. It records
calls and returns a programmable KotlinResult:
import WakeKit
struct WakeMyDesktop {
let wake: WakeSender // Wake.asSender() in production
func run() async throws { try await wake.up(mac: "AA:BB:CC:DD:EE:FF").get() }
}
// Test:
let fake = FakeWake(result: KotlinResult(failure: WakeException.NetworkError(message: "no route", cause: nil)))
await #expect(throws: WakeException.NetworkError.self) { try await WakeMyDesktop(wake: fake).run() }
#expect(fake.lastCall?.mac == "AA:BB:CC:DD:EE:FF")Subclass FakeWake and override respond(to:) to script per-call results. Use
it rather than conforming to WakeSender by hand: SKIE renames the protocol's
requirement to __up(mac:broadcastAddress:port:), and FakeWake implements it
for you.
:apps:cli is a small JVM command-line tool that wakes a device by MAC or by IP
(IP → ARP-resolve → wake). It's a sample, not a published artifact — a plain
kotlin("jvm") app depending on :wake.
mise run cli -- AA:BB:CC:DD:EE:FF # wake by MAC
mise run cli -- <wifi-mac> <eth-mac> # wake several MACs (one packet each)
mise run cli -- --ip 192.168.1.42 # resolve the IP's MAC, then wake it
mise run cli -- --ip 192.168.1.42 --port 7 --broadcast 192.168.1.255
./gradlew :apps:cli:run --args="--help" # full usage (--help via the raw form)"Wake by IP" relies on lookupMac, so it works on a Linux or macOS desktop but
not, say, inside a container with no arp//proc/net/arp.
Passing several MACs sends a packet to each. Some devices listen on more than one interface — a Roku in deep sleep, for instance, has separate Wi-Fi and Ethernet MACs (visible under Settings → Network → About on the device), and you have to wake the interface it's actually on. Listing both is the reliable move.
mise install # provision JDK, Gradle, gh
mise run check # ktlint + detekt + all unit tests (all targets, both modules)
mise run build # assemble the release WakeKit.xcframework
mise run cli -- … # run the sample CLI (see above)See mise.toml for the full task surface and CLAUDE.md
for the project rules.
IP_BOUND_IF egress-interface binding for multi-interface hosts.WifiManager multicast lock + a Context-taking factory overload,
for chipsets that gate broadcast egress behind a held lock.MIT License. See LICENSE.