Skip to content

Commit e7c5e42

Browse files
authored
Add Source and Sink extensions for Apple's NSInputStream and NSOutputStream (#174)
* NSInputStream.asSource() and Source.asNSInputStream() * NSOutputStream.asSink() and Sink.asNSOutputStream() Modeled after JVM's InputStream.asSource() and Source.asInputStream(), add extension functions to similarly map to and from Apple's NSInputStream
1 parent 8a0ee2c commit e7c5e42

18 files changed

+1265
-14
lines changed

build-logic/src/main/kotlin/kotlinx/io/conventions/kotlinx-io-multiplatform.gradle.kts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,12 @@ kotlin {
5252
configureNativePlatforms()
5353

5454
val nativeTargets = nativeTargets()
55+
val appleTargets = appleTargets()
5556
sourceSets {
56-
createSourceSet("nativeMain", parent = commonMain.get(), children = nativeTargets)
57-
createSourceSet("nativeTest", parent = commonTest.get(), children = nativeTargets)
57+
val nativeMain = createSourceSet("nativeMain", parent = commonMain.get(), children = nativeTargets)
58+
val nativeTest = createSourceSet("nativeTest", parent = commonTest.get(), children = nativeTargets)
59+
createSourceSet("appleMain", parent = nativeMain, children = appleTargets)
60+
createSourceSet("appleTest", parent = nativeTest, children = appleTargets)
5861
}
5962
}
6063

@@ -126,7 +129,7 @@ fun KotlinMultiplatformExtension.configureNativePlatforms() {
126129
}
127130

128131
fun nativeTargets(): List<String> {
129-
return appleTargets() + linuxTargets() + mingwTargets() + androidTargets()
132+
return linuxTargets() + mingwTargets() + androidTargets()
130133
}
131134

132135
fun appleTargets() = listOf(

core/apple/src/-Util.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2017-2023 JetBrains s.r.o. and respective authors and developers.
3+
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENCE file.
4+
*/
5+
6+
package kotlinx.io
7+
8+
import kotlinx.cinterop.UnsafeNumber
9+
import platform.Foundation.NSError
10+
import platform.Foundation.NSLocalizedDescriptionKey
11+
import platform.Foundation.NSUnderlyingErrorKey
12+
13+
@OptIn(UnsafeNumber::class)
14+
internal fun Exception.toNSError() = NSError(
15+
domain = "Kotlin",
16+
code = 0,
17+
userInfo = mapOf(
18+
NSLocalizedDescriptionKey to message,
19+
NSUnderlyingErrorKey to this
20+
)
21+
)

core/apple/src/AppleCore.kt

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright 2017-2023 JetBrains s.r.o. and respective authors and developers.
3+
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENCE file.
4+
*/
5+
6+
@file:OptIn(UnsafeNumber::class)
7+
8+
package kotlinx.io
9+
10+
import kotlinx.cinterop.*
11+
import platform.Foundation.NSInputStream
12+
import platform.Foundation.NSOutputStream
13+
import platform.Foundation.NSStreamStatusClosed
14+
import platform.Foundation.NSStreamStatusNotOpen
15+
import platform.posix.uint8_tVar
16+
17+
/**
18+
* Returns [RawSink] that writes to an output stream.
19+
*
20+
* Use [RawSink.buffered] to create a buffered sink from it.
21+
*
22+
* @sample kotlinx.io.samples.KotlinxIoSamplesApple.outputStreamAsSink
23+
*/
24+
public fun NSOutputStream.asSink(): RawSink = OutputStreamSink(this)
25+
26+
private open class OutputStreamSink(
27+
private val out: NSOutputStream,
28+
) : RawSink {
29+
30+
init {
31+
if (out.streamStatus == NSStreamStatusNotOpen) out.open()
32+
}
33+
34+
override fun write(source: Buffer, byteCount: Long) {
35+
if (out.streamStatus == NSStreamStatusClosed) throw IOException("Stream Closed")
36+
37+
checkOffsetAndCount(source.size, 0, byteCount)
38+
var remaining = byteCount
39+
while (remaining > 0) {
40+
val head = source.head!!
41+
val toCopy = minOf(remaining, head.limit - head.pos).toInt()
42+
val bytesWritten = head.data.usePinned {
43+
val bytes = it.addressOf(head.pos).reinterpret<uint8_tVar>()
44+
out.write(bytes, toCopy.convert()).toLong()
45+
}
46+
47+
if (bytesWritten < 0L) throw IOException(out.streamError?.localizedDescription ?: "Unknown error")
48+
if (bytesWritten == 0L) throw IOException("NSOutputStream reached capacity")
49+
50+
head.pos += bytesWritten.toInt()
51+
remaining -= bytesWritten
52+
source.size -= bytesWritten
53+
54+
if (head.pos == head.limit) {
55+
source.head = head.pop()
56+
SegmentPool.recycle(head)
57+
}
58+
}
59+
}
60+
61+
override fun flush() {
62+
// no-op
63+
}
64+
65+
override fun close() = out.close()
66+
67+
override fun toString() = "RawSink($out)"
68+
}
69+
70+
/**
71+
* Returns [RawSource] that reads from an input stream.
72+
*
73+
* Use [RawSource.buffered] to create a buffered source from it.
74+
*
75+
* @sample kotlinx.io.samples.KotlinxIoSamplesApple.inputStreamAsSource
76+
*/
77+
public fun NSInputStream.asSource(): RawSource = NSInputStreamSource(this)
78+
79+
private open class NSInputStreamSource(
80+
private val input: NSInputStream,
81+
) : RawSource {
82+
83+
init {
84+
if (input.streamStatus == NSStreamStatusNotOpen) input.open()
85+
}
86+
87+
override fun readAtMostTo(sink: Buffer, byteCount: Long): Long {
88+
if (input.streamStatus == NSStreamStatusClosed) throw IOException("Stream Closed")
89+
90+
if (byteCount == 0L) return 0L
91+
checkByteCount(byteCount)
92+
93+
val tail = sink.writableSegment(1)
94+
val maxToCopy = minOf(byteCount, Segment.SIZE - tail.limit)
95+
val bytesRead = tail.data.usePinned {
96+
val bytes = it.addressOf(tail.limit).reinterpret<uint8_tVar>()
97+
input.read(bytes, maxToCopy.convert()).toLong()
98+
}
99+
100+
if (bytesRead < 0L) throw IOException(input.streamError?.localizedDescription ?: "Unknown error")
101+
if (bytesRead == 0L) {
102+
if (tail.pos == tail.limit) {
103+
// We allocated a tail segment, but didn't end up needing it. Recycle!
104+
sink.head = tail.pop()
105+
SegmentPool.recycle(tail)
106+
}
107+
return -1
108+
}
109+
tail.limit += bytesRead.toInt()
110+
sink.size += bytesRead
111+
return bytesRead
112+
}
113+
114+
override fun close() = input.close()
115+
116+
override fun toString() = "RawSource($input)"
117+
}

core/apple/src/BuffersApple.kt

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright 2017-2023 JetBrains s.r.o. and respective authors and developers.
3+
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENCE file.
4+
*/
5+
6+
@file:OptIn(UnsafeNumber::class)
7+
8+
package kotlinx.io
9+
10+
import kotlinx.cinterop.*
11+
import platform.Foundation.*
12+
import platform.darwin.ByteVar
13+
import platform.darwin.NSUIntegerMax
14+
import platform.posix.*
15+
16+
internal fun Buffer.write(source: CPointer<uint8_tVar>, maxLength: Int) {
17+
require(maxLength >= 0) { "maxLength ($maxLength) must not be negative" }
18+
19+
var currentOffset = 0
20+
while (currentOffset < maxLength) {
21+
val tail = writableSegment(1)
22+
23+
val toCopy = minOf(maxLength - currentOffset, Segment.SIZE - tail.limit)
24+
tail.data.usePinned {
25+
memcpy(it.addressOf(tail.pos), source + currentOffset, toCopy.convert())
26+
}
27+
28+
currentOffset += toCopy
29+
tail.limit += toCopy
30+
}
31+
size += maxLength
32+
}
33+
34+
internal fun Buffer.readAtMostTo(sink: CPointer<uint8_tVar>, maxLength: Int): Int {
35+
require(maxLength >= 0) { "maxLength ($maxLength) must not be negative" }
36+
37+
val s = head ?: return 0
38+
val toCopy = minOf(maxLength, s.limit - s.pos)
39+
s.data.usePinned {
40+
memcpy(sink, it.addressOf(s.pos), toCopy.convert())
41+
}
42+
43+
s.pos += toCopy
44+
size -= toCopy.toLong()
45+
46+
if (s.pos == s.limit) {
47+
head = s.pop()
48+
SegmentPool.recycle(s)
49+
}
50+
51+
return toCopy
52+
}
53+
54+
internal fun Buffer.snapshotAsNSData(): NSData {
55+
if (size == 0L) return NSData.data()
56+
57+
check(size.toULong() <= NSUIntegerMax) { "Buffer is too long ($size) to be converted into NSData." }
58+
59+
val bytes = malloc(size.convert())?.reinterpret<uint8_tVar>()
60+
?: throw Error("malloc failed: ${strerror(errno)?.toKString()}")
61+
var curr = head
62+
var index = 0
63+
do {
64+
check(curr != null) { "Current segment is null" }
65+
val pos = curr.pos
66+
val length = curr.limit - pos
67+
curr.data.usePinned {
68+
memcpy(bytes + index, it.addressOf(pos), length.convert())
69+
}
70+
curr = curr.next
71+
index += length
72+
} while (curr !== head)
73+
return NSData.create(bytesNoCopy = bytes, length = size.convert())
74+
}

core/apple/src/SinksApple.kt

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
* Copyright 2017-2023 JetBrains s.r.o. and respective authors and developers.
3+
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENCE file.
4+
*/
5+
6+
package kotlinx.io
7+
8+
import kotlinx.cinterop.*
9+
import platform.Foundation.*
10+
import platform.darwin.NSInteger
11+
import platform.darwin.NSUInteger
12+
import platform.posix.uint8_tVar
13+
import kotlin.native.ref.WeakReference
14+
15+
/**
16+
* Returns an output stream that writes to this sink. Closing the stream will also close this sink.
17+
*
18+
* The stream supports both polling and run-loop scheduling, please check
19+
* [Apple's documentation](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Streams/Articles/PollingVersusRunloop.html)
20+
* for information about stream events handling.
21+
*
22+
* The stream does not implement initializers
23+
* ([NSOutputStream.initToBuffer](https://developer.apple.com/documentation/foundation/nsoutputstream/1410805-inittobuffer),
24+
* [NSOutputStream.initToMemory](https://developer.apple.com/documentation/foundation/nsoutputstream/1409909-inittomemory),
25+
* [NSOutputStream.initWithURL](https://developer.apple.com/documentation/foundation/nsoutputstream/1414446-initwithurl),
26+
* [NSOutputStream.initToFileAtPath](https://developer.apple.com/documentation/foundation/nsoutputstream/1416367-inittofileatpath)),
27+
* their use will result in a runtime error.
28+
*
29+
* @sample kotlinx.io.samples.KotlinxIoSamplesApple.asStream
30+
*/
31+
public fun Sink.asNSOutputStream(): NSOutputStream = SinkNSOutputStream(this)
32+
33+
@OptIn(UnsafeNumber::class)
34+
private class SinkNSOutputStream(
35+
private val sink: Sink
36+
) : NSOutputStream(toMemory = Unit), NSStreamDelegateProtocol {
37+
38+
private val isClosed: () -> Boolean = when (sink) {
39+
is RealSink -> sink::closed
40+
is Buffer -> {
41+
{ false }
42+
}
43+
}
44+
45+
private var status = NSStreamStatusNotOpen
46+
private var error: NSError? = null
47+
set(value) {
48+
status = NSStreamStatusError
49+
field = value
50+
postEvent(NSStreamEventErrorOccurred)
51+
sink.close()
52+
}
53+
54+
override fun streamStatus() = if (status != NSStreamStatusError && isClosed()) NSStreamStatusClosed else status
55+
56+
override fun streamError() = error
57+
58+
override fun open() {
59+
if (status == NSStreamStatusNotOpen) {
60+
status = NSStreamStatusOpening
61+
status = NSStreamStatusOpen
62+
postEvent(NSStreamEventOpenCompleted)
63+
postEvent(NSStreamEventHasSpaceAvailable)
64+
}
65+
}
66+
67+
override fun close() {
68+
if (status == NSStreamStatusError || status == NSStreamStatusNotOpen) return
69+
status = NSStreamStatusClosed
70+
runLoop = null
71+
runLoopModes = listOf()
72+
sink.close()
73+
}
74+
75+
@OptIn(DelicateIoApi::class)
76+
override fun write(buffer: CPointer<uint8_tVar>?, maxLength: NSUInteger): NSInteger {
77+
if (streamStatus != NSStreamStatusOpen || buffer == null) return -1
78+
status = NSStreamStatusWriting
79+
val toWrite = minOf(maxLength, Int.MAX_VALUE.convert()).toInt()
80+
return try {
81+
sink.writeToInternalBuffer {
82+
it.write(buffer, toWrite)
83+
}
84+
status = NSStreamStatusOpen
85+
toWrite.convert()
86+
} catch (e: Exception) {
87+
error = e.toNSError()
88+
-1
89+
}
90+
}
91+
92+
override fun hasSpaceAvailable() = !isFinished
93+
94+
private val isFinished
95+
get() = when (streamStatus) {
96+
NSStreamStatusClosed, NSStreamStatusError -> true
97+
else -> false
98+
}
99+
100+
@OptIn(InternalIoApi::class)
101+
override fun propertyForKey(key: NSStreamPropertyKey): Any? = when (key) {
102+
NSStreamDataWrittenToMemoryStreamKey -> sink.buffer.snapshotAsNSData()
103+
else -> null
104+
}
105+
106+
override fun setProperty(property: Any?, forKey: NSStreamPropertyKey) = false
107+
108+
// WeakReference as delegate should not be retained
109+
// https://developer.apple.com/documentation/foundation/nsstream/1418423-delegate
110+
private var _delegate: WeakReference<NSStreamDelegateProtocol>? = null
111+
private var runLoop: NSRunLoop? = null
112+
private var runLoopModes = listOf<NSRunLoopMode>()
113+
114+
private fun postEvent(event: NSStreamEvent) {
115+
val runLoop = runLoop ?: return
116+
runLoop.performInModes(runLoopModes) {
117+
if (runLoop == this.runLoop) {
118+
delegateOrSelf.stream(this, event)
119+
}
120+
}
121+
}
122+
123+
override fun delegate() = _delegate?.value
124+
125+
private val delegateOrSelf get() = delegate ?: this
126+
127+
override fun setDelegate(delegate: NSStreamDelegateProtocol?) {
128+
_delegate = delegate?.let { WeakReference(it) }
129+
}
130+
131+
override fun stream(aStream: NSStream, handleEvent: NSStreamEvent) {
132+
// no-op
133+
}
134+
135+
override fun scheduleInRunLoop(aRunLoop: NSRunLoop, forMode: NSRunLoopMode) {
136+
if (runLoop == null) {
137+
runLoop = aRunLoop
138+
}
139+
if (runLoop == aRunLoop) {
140+
runLoopModes += forMode
141+
}
142+
if (status == NSStreamStatusOpen) {
143+
postEvent(NSStreamEventHasSpaceAvailable)
144+
}
145+
}
146+
147+
override fun removeFromRunLoop(aRunLoop: NSRunLoop, forMode: NSRunLoopMode) {
148+
if (aRunLoop == runLoop) {
149+
runLoopModes -= forMode
150+
if (runLoopModes.isEmpty()) {
151+
runLoop = null
152+
}
153+
}
154+
}
155+
156+
override fun description() = "$sink.asNSOutputStream()"
157+
}

0 commit comments

Comments
 (0)