Skip to content

Commit 6778dfd

Browse files
fix: make V8 string to NSString conversions UTF-16 faithful (#392)
* Make V8 string to NSString conversions UTF-16 faithful V8 strings are UTF-16, but several bridge points round-tripped them through UTF-8 before building an NSString (and one reverse direction did too). That path corrupts lone surrogates, which get replaced with U+FFFD, and where it used C-string APIs (stringWithUTF8String:, [NSString UTF8String] folded into a std::string) it truncated at an embedded NUL. Rework tns::ToUtf16String to read the V8 string's native two-byte buffer directly, which also drops the deprecated std::codecvt_utf8_utf16. Switch the DictionaryAdapter, Interop and ArgConverter string sites to ToUtf16String + stringWithCharacters:length:, and pass NSString straight to ToV8String instead of going through a UTF8String C string. Add TestRunner cases asserting lone surrogates survive the JS to NSString bridge: each reads the bridged string's first UTF-16 code unit straight out of its buffer and checks it is unchanged (high U+D834 and low U+DC00), whereas the old UTF-8 round trip would have turned either into U+FFFD. * Routed JS-to-NSString bridge through a UTF-16-faithful ToNSString Made tns::ToNSString read the V8 string's UTF-16 buffer directly and pointed the six bridge sites at it, instead of building a std::u16string and copying it into NSString at each one. That drops the extra copy and the repeated stringWithCharacters boilerplate, keeps the conversion in one place, and brings Interop back to matching vanilla. Kept the ToUtf16String rewrite as the general accessor. Added an embedded-NUL test next to the lone-surrogate ones, since the bridge now has to preserve NUL bytes too.
1 parent e40de8a commit 6778dfd

5 files changed

Lines changed: 80 additions & 20 deletions

File tree

NativeScript/runtime/ArgConverter.mm

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -929,8 +929,8 @@
929929
}
930930

931931
if ([obj isKindOfClass:[NSString class]]) {
932-
const char* str = [obj UTF8String];
933-
args.GetReturnValue().Set(tns::ToV8String(isolate, str));
932+
NSString* nativeStr = (NSString*)obj;
933+
args.GetReturnValue().Set(tns::ToV8String(isolate, nativeStr));
934934
return;
935935
}
936936

NativeScript/runtime/DictionaryAdapter.mm

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,7 @@ - (id)nextObject {
116116
bool success = properties->Get(context, (uint)self->index_).ToLocal(&value);
117117
tns::Assert(success, isolate);
118118
self->index_++;
119-
std::string result = tns::ToString(isolate, value);
120-
return [NSString stringWithUTF8String:result.c_str()];
119+
return tns::ToNSString(isolate, value);
121120
}
122121

123122
return nil;
@@ -139,8 +138,7 @@ - (NSArray*)allObjects {
139138
Local<Value> value;
140139
bool success = properties->Get(context, i).ToLocal(&value);
141140
tns::Assert(success, isolate);
142-
std::string result = tns::ToString(isolate, value);
143-
[array addObject:[NSString stringWithUTF8String:result.c_str()]];
141+
[array addObject:tns::ToNSString(isolate, value)];
144142
}
145143

146144
return array;
@@ -214,7 +212,7 @@ - (id)objectForKey:(id)aKey {
214212
bool success = obj->Get(context, key).ToLocal(&value);
215213
tns::Assert(success, isolate);
216214
} else if ([aKey isKindOfClass:[NSString class]]) {
217-
const char* key = [aKey UTF8String];
215+
NSString* key = (NSString*)aKey;
218216
Local<v8::String> keyV8Str = tns::ToV8String(isolate, key);
219217

220218
if (obj->IsMap()) {

NativeScript/runtime/Helpers.h

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ inline NSString* ToNSString(const std::string& v) {
106106
length:v.length()
107107
encoding:NSUTF8StringEncoding] S_AUTORELEASE];
108108
}
109-
// this method is a copy of ToString to avoid needless std::string<->NSString
110-
// conversions
109+
// Reads the V8 string's native UTF-16 buffer directly so lone surrogates and
110+
// embedded NUL survive the bridge; a UTF-8 round-trip loses both.
111111
inline NSString* ToNSString(v8::Isolate* isolate,
112112
const v8::Local<v8::Value>& value) {
113113
if (value.IsEmpty()) {
@@ -119,16 +119,15 @@ inline NSString* ToNSString(v8::Isolate* isolate,
119119
return ToNSString(isolate, obj);
120120
}
121121

122-
v8::String::Utf8Value result(isolate, value);
122+
v8::String::Value result(isolate, value);
123123

124-
const char* val = *result;
124+
const uint16_t* val = *result;
125125
if (val == nullptr) {
126126
return @"";
127127
}
128128

129-
return [[[NSString alloc] initWithBytes:*result
130-
length:result.length()
131-
encoding:NSUTF8StringEncoding] S_AUTORELEASE];
129+
return [NSString stringWithCharacters:(const unichar*)val
130+
length:result.length()];
132131
}
133132
#endif
134133
std::u16string ToUtf16String(v8::Isolate* isolate,

NativeScript/runtime/Helpers.mm

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,26 @@
2424
} // namespace
2525

2626
std::u16string tns::ToUtf16String(Isolate* isolate, const Local<Value>& value) {
27-
std::string valueStr = tns::ToString(isolate, value);
28-
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
29-
// FIXME: std::codecvt_utf8_utf16 is deprecated
30-
std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t> convert;
31-
std::u16string value16 = convert.from_bytes(valueStr);
27+
// Read the V8 string's native UTF-16 buffer directly instead of round-tripping
28+
// through UTF-8, which corrupts lone surrogates (replaced with U+FFFD) and is
29+
// slower. This also drops the deprecated std::codecvt_utf8_utf16.
30+
if (value.IsEmpty()) {
31+
return std::u16string();
32+
}
33+
34+
if (value->IsStringObject()) {
35+
Local<v8::String> obj = value.As<StringObject>()->ValueOf();
36+
return tns::ToUtf16String(isolate, obj);
37+
}
38+
39+
v8::String::Value result(isolate, value);
40+
41+
uint16_t* val = *result;
42+
if (val == nullptr) {
43+
return std::u16string();
44+
}
3245

33-
return value16;
46+
return std::u16string((char16_t*)val, result.length());
3447
}
3548

3649
std::vector<uint16_t> tns::ToVector(const std::string& value) {

TestRunner/app/tests/ApiTests.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,56 @@ describe(module.id, function () {
1212
expect(object.hash).toBe(3);
1313
});
1414

15+
it("preserves a lone high surrogate when bridging a JS string to NSString", function () {
16+
// A lone high surrogate (U+D834, range U+D800-U+DBFF) is a valid JS string
17+
// code unit but has no UTF-8 encoding. The old UTF-8 round-trip replaced it
18+
// with U+FFFD; faithful UTF-16 bridging keeps it. Read the code unit straight
19+
// out of the bridged string's UTF-16 buffer as a number: reading it back as a
20+
// JS string would re-corrupt a lone surrogate, and converting it to UTF-8 to
21+
// measure it is not reliable across OS versions.
22+
var ns = NSString.stringWithString("\uD834");
23+
expect(ns.length).toBe(1);
24+
25+
var buffer = interop.alloc(interop.sizeof(interop.types.uint16));
26+
ns.getCharactersRange(buffer, NSMakeRange(0, 1));
27+
var codeUnit = new interop.Reference(interop.types.uint16, buffer).value;
28+
interop.free(buffer);
29+
30+
expect(codeUnit).toBe(0xD834); // 0xFFFD (65533) after a lossy UTF-8 round-trip
31+
});
32+
33+
it("preserves a lone low surrogate when bridging a JS string to NSString", function () {
34+
// The low surrogate range (U+DC00-U+DFFF) is a different bit pattern that also
35+
// has no UTF-8 encoding and must survive the bridge intact; observed the same
36+
// way as the high-surrogate case above.
37+
var ns = NSString.stringWithString("\uDC00");
38+
expect(ns.length).toBe(1);
39+
40+
var buffer = interop.alloc(interop.sizeof(interop.types.uint16));
41+
ns.getCharactersRange(buffer, NSMakeRange(0, 1));
42+
var codeUnit = new interop.Reference(interop.types.uint16, buffer).value;
43+
interop.free(buffer);
44+
45+
expect(codeUnit).toBe(0xDC00); // 0xFFFD (65533) after a lossy UTF-8 round-trip
46+
});
47+
48+
it("preserves an embedded NUL when bridging a JS string to NSString", function () {
49+
// U+0000 is a valid JS code unit but terminates a C string, so a bridge
50+
// that went through char* would cut "a\0b" down to "a". Faithful UTF-16
51+
// bridging keeps all three units. Read the NUL unit straight out of the
52+
// NSString's buffer so the check does not lean on a native-to-JS conversion.
53+
var withNul = "a" + String.fromCharCode(0) + "b";
54+
var ns = NSString.stringWithString(withNul);
55+
expect(ns.length).toBe(3);
56+
57+
var buffer = interop.alloc(interop.sizeof(interop.types.uint16));
58+
ns.getCharactersRange(buffer, NSMakeRange(1, 1));
59+
var codeUnit = new interop.Reference(interop.types.uint16, buffer).value;
60+
interop.free(buffer);
61+
62+
expect(codeUnit).toBe(0x0000); // a char* bridge would have stopped before this
63+
});
64+
1565
it("NSArray from native (uncached) array access", function () {
1666
const res = TNSObjCTypes.new().getNSArrayOfNSURLs();
1767
console.log(res);

0 commit comments

Comments
 (0)