Rebrand application as Qlyra
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-08-30 13:16:17 +03:00
parent 0245f792de
commit f74057ce9b
592 changed files with 38233 additions and 1899 deletions
+42
View File
@@ -0,0 +1,42 @@
# Changelog
## 0.1.4
- Handshake fields that are not set are now omitted from `sessionInit` instead
of being sent empty: `arch` (empty string), `buildNumber` (0), `instanceId`
(empty string) and `clientSessionId` (0). A web-type session can therefore
produce a payload byte-identical to the MAX web client, which sends none of
them. Mobile sessions pass real values and are unaffected.
## 0.1.3
- `SessionOptions` gains `isPwa` and `headerUserAgent`. Both are optional: when
left unset the key is absent from the handshake `userAgent` map entirely, so
existing sessions send exactly the same payload as before.
- These are the two fields the MAX web client sends that mobile clients did not.
The server routes web push (`sw-web-push`, opcode 22 subscriptions) only to
sessions that look like an installed web app, so a client wanting web push
must send `deviceType: WEB`, `pushDeviceType: WEBPUSH` and `isPwa: true`.
## 0.1.2
- Expose `setTrustMincifryCa` / `trustMincifryCa`: opt in to the bundled
Минцифры root (Russian Trusted Root + Sub CA) for hosts that chain to it,
such as `api2.oneme.ru`. Off by default; covers the session socket, media
uploads and ws2 call signaling.
- Depend on `kolibri-net` by git tag instead of a workspace path, so the Rust
core resolves when the package is consumed from pub.dev. Building an app that
uses this package now needs git access to the repository.
## 0.1.1
- Upgrade to `freezed` / `freezed_annotation` 3.x.
- Add a canonical `example/example.dart` and an `example/README.md`.
- Rewrite the README for pub.dev with quick-start, requirements and API notes.
## 0.1.0
- Initial release: Dart/Flutter bindings for the Kolibri messaging protocol.
- FFI plugin with a Rust core built via cargokit (requires a Rust toolchain
on the consumer's machine).
- Platforms: Android, iOS, macOS, Linux, Windows.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 klockky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+108
View File
@@ -0,0 +1,108 @@
# kolibri
Dart/Flutter bindings for the **Kolibri** messaging protocol, powered by a Rust
core (`kolibri-net`) via
[`flutter_rust_bridge`](https://github.com/fzyzcjy/flutter_rust_bridge) v2.
Async Rust maps to Dart `Future`s and server pushes to a Dart `Stream`. Payloads
cross the boundary as MessagePack — either as raw bytes (`Uint8List`) or, via the
built-in helpers, as plain Dart `Map`s.
- **Full protocol session** — handshake, request/response, server pushes, ping,
auto-reconnect.
- **Media upload** as a progress `Stream<UploadEvent>`.
- **Call signaling** over WebSocket, with an anti-spoof device fingerprint.
- **Bundled Минцифры root** behind `setTrustMincifryCa(enabled: true)`, for hosts
that chain to Russian Trusted Root/Sub CA. Off by default.
- **All native platforms** — Android, iOS, macOS, Linux, Windows.
## Requirements
This is an FFI plugin whose native library is compiled from Rust **on the
consumer's machine at app build time** (via [cargokit](https://github.com/irondash/cargokit)).
Anyone building an app that depends on `kolibri` therefore needs a
[Rust toolchain](https://rustup.rs) installed, plus `git` and network access to
this repository — the Rust core (`kolibri-net`) is pulled from a git tag. For
Android you also need the NDK and the relevant `rustup` targets (e.g.
`aarch64-linux-android`).
## Install
```yaml
dependencies:
kolibri: ^0.1.2
```
```bash
flutter pub get
```
## Quick start (Flutter)
```dart
import 'package:kolibri/kolibri.dart';
// On Flutter the bundled native library is found automatically.
await initKolibri();
final session = openSession(host: 'api.oneme.ru'); // override device fields to spoof
final info = await session.connect(); // sessionInit handshake
print(info.callsSeed);
// Request/response with Map payloads (msgpack handled by the core).
final resp = await session.requestMap(64, {'text': 'hello'});
print(resp);
// Server pushes as a stream of (opcode, payload) records.
session.pushesMap().listen((push) {
final (opcode, payload) = push;
print('push $opcode: $payload');
});
session.disconnect();
```
Prefer raw bytes? `session.request(opcode: 64, payload: msgpackBytes)` returns a
`Uint8List` — "bytes in, bytes out", matching the core.
## Media upload
```dart
await for (final event in session.uploadFile(url: cdnUrl, data: bytes, filename: 'clip.mp4')) {
switch (event) {
case UploadEvent_Progress(:final sent, :final total): print('$sent / $total');
case UploadEvent_Done(:final status, :final body): print('done $status');
case UploadEvent_Error(:final message): print('error $message');
}
}
```
## Pure Dart (no Flutter)
Build the native library yourself and pass its path to `initKolibri`:
```bash
cargo build --manifest-path rust/Cargo.toml
dart run example/example.dart # loads rust/target/debug/libkolibri_dart.dylib
```
```dart
await initKolibri(libraryPath: '/path/to/libkolibri_dart.dylib');
```
See the [`example/`](example) directory for runnable scripts covering the
handshake, uploads, call signaling and the device fingerprint.
## Regenerating the bindings
The Dart bindings and freezed classes are checked in. Regenerate them after
changing the Rust API:
```bash
flutter_rust_bridge_codegen generate # bindings from rust/src/api
dart run build_runner build # freezed classes (UploadEvent)
```
## License
MIT. See [LICENSE](LICENSE).
+5
View File
@@ -0,0 +1,5 @@
analyzer:
exclude:
# Vendored cargokit build tool — a separate Dart package with its own
# pubspec/deps; not part of this plugin's sources.
- cargokit/**
+56
View File
@@ -0,0 +1,56 @@
// The Android Gradle Plugin builds the native code with the Android NDK.
group 'ru.kolibri'
version '1.0'
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
// The Android Gradle Plugin knows how to build native code with the NDK.
classpath 'com.android.tools.build:gradle:7.3.0'
}
}
rootProject.allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: 'com.android.library'
android {
if (project.android.hasProperty("namespace")) {
namespace 'ru.kolibri'
}
// Bumping the plugin compileSdkVersion requires all clients of this plugin
// to bump the version in their app.
compileSdkVersion 33
// Use the NDK version
// declared in /android/app/build.gradle file of the Flutter project.
// Replace it with a version number if this plugin requires a specfic NDK version.
// (e.g. ndkVersion "23.1.7779620")
ndkVersion android.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
minSdkVersion 19
}
}
apply from: "../cargokit/gradle/plugin.gradle"
cargokit {
manifestDir = "../rust"
libname = "kolibri_dart"
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'kolibri'
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
+41
View File
@@ -0,0 +1,41 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
Copyright 2022 Matej Knopp
================================================================================
MIT LICENSE
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
APACHE LICENSE, VERSION 2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+10
View File
@@ -0,0 +1,10 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
Experimental repository to provide glue for seamlessly integrating cargo build
with flutter plugins and packages.
See https://matejknopp.com/post/flutter_plugin_in_rust_with_no_prebuilt_binaries/
for a tutorial on how to use Cargokit.
Example plugin available at https://github.com/irondash/hello_rust_ffi_plugin.
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
set -e
BASEDIR=$(dirname "$0")
# Workaround for https://github.com/dart-lang/pub/issues/4010
BASEDIR=$(cd "$BASEDIR" ; pwd -P)
# Remove XCode SDK from path. Otherwise this breaks tool compilation when building iOS project
NEW_PATH=`echo $PATH | tr ":" "\n" | grep -v "Contents/Developer/" | tr "\n" ":"`
export PATH=${NEW_PATH%?} # remove trailing :
env
# Platform name (macosx, iphoneos, iphonesimulator)
export CARGOKIT_DARWIN_PLATFORM_NAME=$PLATFORM_NAME
# Arctive architectures (arm64, armv7, x86_64), space separated.
export CARGOKIT_DARWIN_ARCHS=$ARCHS
# Current build configuration (Debug, Release)
export CARGOKIT_CONFIGURATION=$CONFIGURATION
# Path to directory containing Cargo.toml.
export CARGOKIT_MANIFEST_DIR=$PODS_TARGET_SRCROOT/$1
# Temporary directory for build artifacts.
export CARGOKIT_TARGET_TEMP_DIR=$TARGET_TEMP_DIR
# Output directory for final artifacts.
export CARGOKIT_OUTPUT_DIR=$PODS_CONFIGURATION_BUILD_DIR/$PRODUCT_NAME
# Directory to store built tool artifacts.
export CARGOKIT_TOOL_TEMP_DIR=$TARGET_TEMP_DIR/build_tool
# Directory inside root project. Not necessarily the top level directory of root project.
export CARGOKIT_ROOT_PROJECT_DIR=$SRCROOT
FLUTTER_EXPORT_BUILD_ENVIRONMENT=(
"$PODS_ROOT/../Flutter/ephemeral/flutter_export_environment.sh" # macOS
"$PODS_ROOT/../Flutter/flutter_export_environment.sh" # iOS
)
for path in "${FLUTTER_EXPORT_BUILD_ENVIRONMENT[@]}"
do
if [[ -f "$path" ]]; then
source "$path"
fi
done
sh "$BASEDIR/run_build_tool.sh" build-pod "$@"
# Make a symlink from built framework to phony file, which will be used as input to
# build script. This should force rebuild (podspec currently doesn't support alwaysOutOfDate
# attribute on custom build phase)
ln -fs "$OBJROOT/XCBuildData/build.db" "${BUILT_PRODUCTS_DIR}/cargokit_phony"
ln -fs "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}" "${BUILT_PRODUCTS_DIR}/cargokit_phony_out"
+5
View File
@@ -0,0 +1,5 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
A sample command-line application with an entrypoint in `bin/`, library code
in `lib/`, and example unit test in `test/`.
@@ -0,0 +1,34 @@
# This is copied from Cargokit (which is the official way to use it currently)
# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
linter:
rules:
- prefer_relative_imports
- directives_ordering
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options
@@ -0,0 +1,8 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'package:build_tool/build_tool.dart' as build_tool;
void main(List<String> arguments) {
build_tool.runMain(arguments);
}
@@ -0,0 +1,8 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'src/build_tool.dart' as build_tool;
Future<void> runMain(List<String> args) async {
return build_tool.runMain(args);
}
@@ -0,0 +1,195 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'dart:isolate';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
import 'package:version/version.dart';
import 'target.dart';
import 'util.dart';
class AndroidEnvironment {
AndroidEnvironment({
required this.sdkPath,
required this.ndkVersion,
required this.minSdkVersion,
required this.targetTempDir,
required this.target,
});
static void clangLinkerWrapper(List<String> args) {
final clang = Platform.environment['_CARGOKIT_NDK_LINK_CLANG'];
if (clang == null) {
throw Exception(
"cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_CLANG env var");
}
final target = Platform.environment['_CARGOKIT_NDK_LINK_TARGET'];
if (target == null) {
throw Exception(
"cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_TARGET env var");
}
runCommand(clang, [
target,
...args,
]);
}
/// Full path to Android SDK.
final String sdkPath;
/// Full version of Android NDK.
final String ndkVersion;
/// Minimum supported SDK version.
final int minSdkVersion;
/// Target directory for build artifacts.
final String targetTempDir;
/// Target being built.
final Target target;
bool ndkIsInstalled() {
final ndkPath = path.join(sdkPath, 'ndk', ndkVersion);
final ndkPackageXml = File(path.join(ndkPath, 'package.xml'));
return ndkPackageXml.existsSync();
}
void installNdk({
required String javaHome,
}) {
final sdkManagerExtension = Platform.isWindows ? '.bat' : '';
final sdkManager = path.join(
sdkPath,
'cmdline-tools',
'latest',
'bin',
'sdkmanager$sdkManagerExtension',
);
log.info('Installing NDK $ndkVersion');
runCommand(sdkManager, [
'--install',
'ndk;$ndkVersion',
], environment: {
'JAVA_HOME': javaHome,
});
}
Future<Map<String, String>> buildEnvironment() async {
final hostArch = Platform.isMacOS
? "darwin-x86_64"
: (Platform.isLinux ? "linux-x86_64" : "windows-x86_64");
final ndkPath = path.join(sdkPath, 'ndk', ndkVersion);
final toolchainPath = path.join(
ndkPath,
'toolchains',
'llvm',
'prebuilt',
hostArch,
'bin',
);
final minSdkVersion =
math.max(target.androidMinSdkVersion!, this.minSdkVersion);
final exe = Platform.isWindows ? '.exe' : '';
final arKey = 'AR_${target.rust}';
final arValue = ['${target.rust}-ar', 'llvm-ar', 'llvm-ar.exe']
.map((e) => path.join(toolchainPath, e))
.firstWhereOrNull((element) => File(element).existsSync());
if (arValue == null) {
throw Exception('Failed to find ar for $target in $toolchainPath');
}
final targetArg = '--target=${target.rust}$minSdkVersion';
final ccKey = 'CC_${target.rust}';
final ccValue = path.join(toolchainPath, 'clang$exe');
final cfFlagsKey = 'CFLAGS_${target.rust}';
final cFlagsValue = targetArg;
final cxxKey = 'CXX_${target.rust}';
final cxxValue = path.join(toolchainPath, 'clang++$exe');
final cxxFlagsKey = 'CXXFLAGS_${target.rust}';
final cxxFlagsValue = targetArg;
final linkerKey =
'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase();
final ranlibKey = 'RANLIB_${target.rust}';
final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe');
final ndkVersionParsed = Version.parse(ndkVersion);
final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS';
final rustFlagsValue = _libGccWorkaround(targetTempDir, ndkVersionParsed);
final runRustTool =
Platform.isWindows ? 'run_build_tool.cmd' : 'run_build_tool.sh';
final packagePath = (await Isolate.resolvePackageUri(
Uri.parse('package:build_tool/buildtool.dart')))!
.toFilePath();
final selfPath = path.canonicalize(path.join(
packagePath,
'..',
'..',
'..',
runRustTool,
));
// Make sure that run_build_tool is working properly even initially launched directly
// through dart run.
final toolTempDir =
Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir;
return {
arKey: arValue,
ccKey: ccValue,
cfFlagsKey: cFlagsValue,
cxxKey: cxxValue,
cxxFlagsKey: cxxFlagsValue,
ranlibKey: ranlibValue,
rustFlagsKey: rustFlagsValue,
linkerKey: selfPath,
// Recognized by main() so we know when we're acting as a wrapper
'_CARGOKIT_NDK_LINK_TARGET': targetArg,
'_CARGOKIT_NDK_LINK_CLANG': ccValue,
'CARGOKIT_TOOL_TEMP_DIR': toolTempDir,
};
}
// Workaround for libgcc missing in NDK23, inspired by cargo-ndk
String _libGccWorkaround(String buildDir, Version ndkVersion) {
final workaroundDir = path.join(
buildDir,
'cargokit',
'libgcc_workaround',
'${ndkVersion.major}',
);
Directory(workaroundDir).createSync(recursive: true);
if (ndkVersion.major >= 23) {
File(path.join(workaroundDir, 'libgcc.a'))
.writeAsStringSync('INPUT(-lunwind)');
} else {
// Other way around, untested, forward libgcc.a from libunwind once Rust
// gets updated for NDK23+.
File(path.join(workaroundDir, 'libunwind.a'))
.writeAsStringSync('INPUT(-lgcc)');
}
var rustFlags = Platform.environment['CARGO_ENCODED_RUSTFLAGS'] ?? '';
if (rustFlags.isNotEmpty) {
rustFlags = '$rustFlags\x1f';
}
rustFlags = '$rustFlags-L\x1f$workaroundDir';
return rustFlags;
}
}
@@ -0,0 +1,266 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:http/http.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'builder.dart';
import 'crate_hash.dart';
import 'options.dart';
import 'precompile_binaries.dart';
import 'rustup.dart';
import 'target.dart';
class Artifact {
/// File system location of the artifact.
final String path;
/// Actual file name that the artifact should have in destination folder.
final String finalFileName;
AritifactType get type {
if (finalFileName.endsWith('.dll') ||
finalFileName.endsWith('.dll.lib') ||
finalFileName.endsWith('.pdb') ||
finalFileName.endsWith('.so') ||
finalFileName.endsWith('.dylib')) {
return AritifactType.dylib;
} else if (finalFileName.endsWith('.lib') || finalFileName.endsWith('.a')) {
return AritifactType.staticlib;
} else {
throw Exception('Unknown artifact type for $finalFileName');
}
}
Artifact({
required this.path,
required this.finalFileName,
});
}
final _log = Logger('artifacts_provider');
class ArtifactProvider {
ArtifactProvider({
required this.environment,
required this.userOptions,
});
final BuildEnvironment environment;
final CargokitUserOptions userOptions;
Future<Map<Target, List<Artifact>>> getArtifacts(List<Target> targets) async {
final result = await _getPrecompiledArtifacts(targets);
final pendingTargets = List.of(targets);
pendingTargets.removeWhere((element) => result.containsKey(element));
if (pendingTargets.isEmpty) {
return result;
}
final rustup = Rustup();
for (final target in targets) {
final builder = RustBuilder(target: target, environment: environment);
builder.prepare(rustup);
_log.info('Building ${environment.crateInfo.packageName} for $target');
final targetDir = await builder.build();
// For local build accept both static and dynamic libraries.
final artifactNames = <String>{
...getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
aritifactType: AritifactType.dylib,
remote: false,
),
...getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
aritifactType: AritifactType.staticlib,
remote: false,
)
};
final artifacts = artifactNames
.map((artifactName) => Artifact(
path: path.join(targetDir, artifactName),
finalFileName: artifactName,
))
.where((element) => File(element.path).existsSync())
.toList();
result[target] = artifacts;
}
return result;
}
Future<Map<Target, List<Artifact>>> _getPrecompiledArtifacts(
List<Target> targets) async {
if (userOptions.usePrecompiledBinaries == false) {
_log.info('Precompiled binaries are disabled');
return {};
}
if (environment.crateOptions.precompiledBinaries == null) {
_log.fine('Precompiled binaries not enabled for this crate');
return {};
}
final start = Stopwatch()..start();
final crateHash = CrateHash.compute(environment.manifestDir,
tempStorage: environment.targetTempDir);
_log.fine(
'Computed crate hash $crateHash in ${start.elapsedMilliseconds}ms');
final downloadedArtifactsDir =
path.join(environment.targetTempDir, 'precompiled', crateHash);
Directory(downloadedArtifactsDir).createSync(recursive: true);
final res = <Target, List<Artifact>>{};
for (final target in targets) {
final requiredArtifacts = getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
remote: true,
);
final artifactsForTarget = <Artifact>[];
for (final artifact in requiredArtifacts) {
final fileName = PrecompileBinaries.fileName(target, artifact);
final downloadedPath = path.join(downloadedArtifactsDir, fileName);
if (!File(downloadedPath).existsSync()) {
final signatureFileName =
PrecompileBinaries.signatureFileName(target, artifact);
await _tryDownloadArtifacts(
crateHash: crateHash,
fileName: fileName,
signatureFileName: signatureFileName,
finalPath: downloadedPath,
);
}
if (File(downloadedPath).existsSync()) {
artifactsForTarget.add(Artifact(
path: downloadedPath,
finalFileName: artifact,
));
} else {
break;
}
}
// Only provide complete set of artifacts.
if (artifactsForTarget.length == requiredArtifacts.length) {
_log.fine('Found precompiled artifacts for $target');
res[target] = artifactsForTarget;
}
}
return res;
}
static Future<Response> _get(Uri url, {Map<String, String>? headers}) async {
int attempt = 0;
const maxAttempts = 10;
while (true) {
try {
return await get(url, headers: headers);
} on SocketException catch (e) {
// Try to detect reset by peer error and retry.
if (attempt++ < maxAttempts &&
(e.osError?.errorCode == 54 || e.osError?.errorCode == 10054)) {
_log.severe(
'Failed to download $url: $e, attempt $attempt of $maxAttempts, will retry...');
await Future.delayed(Duration(seconds: 1));
continue;
} else {
rethrow;
}
}
}
}
Future<void> _tryDownloadArtifacts({
required String crateHash,
required String fileName,
required String signatureFileName,
required String finalPath,
}) async {
final precompiledBinaries = environment.crateOptions.precompiledBinaries!;
final prefix = precompiledBinaries.uriPrefix;
final url = Uri.parse('$prefix$crateHash/$fileName');
final signatureUrl = Uri.parse('$prefix$crateHash/$signatureFileName');
_log.fine('Downloading signature from $signatureUrl');
final signature = await _get(signatureUrl);
if (signature.statusCode == 404) {
_log.warning(
'Precompiled binaries not available for crate hash $crateHash ($fileName)');
return;
}
if (signature.statusCode != 200) {
_log.severe(
'Failed to download signature $signatureUrl: status ${signature.statusCode}');
return;
}
_log.fine('Downloading binary from $url');
final res = await _get(url);
if (res.statusCode != 200) {
_log.severe('Failed to download binary $url: status ${res.statusCode}');
return;
}
if (verify(
precompiledBinaries.publicKey, res.bodyBytes, signature.bodyBytes)) {
File(finalPath).writeAsBytesSync(res.bodyBytes);
} else {
_log.shout('Signature verification failed! Ignoring binary.');
}
}
}
enum AritifactType {
staticlib,
dylib,
}
AritifactType artifactTypeForTarget(Target target) {
if (target.darwinPlatform != null) {
return AritifactType.staticlib;
} else {
return AritifactType.dylib;
}
}
List<String> getArtifactNames({
required Target target,
required String libraryName,
required bool remote,
AritifactType? aritifactType,
}) {
aritifactType ??= artifactTypeForTarget(target);
if (target.darwinArch != null) {
if (aritifactType == AritifactType.staticlib) {
return ['lib$libraryName.a'];
} else {
return ['lib$libraryName.dylib'];
}
} else if (target.rust.contains('-windows-')) {
if (aritifactType == AritifactType.staticlib) {
return ['$libraryName.lib'];
} else {
return [
'$libraryName.dll',
'$libraryName.dll.lib',
if (!remote) '$libraryName.pdb'
];
}
} else if (target.rust.contains('-linux-')) {
if (aritifactType == AritifactType.staticlib) {
return ['lib$libraryName.a'];
} else {
return ['lib$libraryName.so'];
}
} else {
throw Exception("Unsupported target: ${target.rust}");
}
}
@@ -0,0 +1,40 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'environment.dart';
import 'options.dart';
import 'target.dart';
class BuildCMake {
final CargokitUserOptions userOptions;
BuildCMake({required this.userOptions});
Future<void> build() async {
final targetPlatform = Environment.targetPlatform;
final target = Target.forFlutterName(Environment.targetPlatform);
if (target == null) {
throw Exception("Unknown target platform: $targetPlatform");
}
final environment = BuildEnvironment.fromEnvironment(isAndroid: false);
final provider =
ArtifactProvider(environment: environment, userOptions: userOptions);
final artifacts = await provider.getArtifacts([target]);
final libs = artifacts[target]!;
for (final lib in libs) {
if (lib.type == AritifactType.dylib) {
File(lib.path)
.copySync(path.join(Environment.outputDir, lib.finalFileName));
}
}
}
}
@@ -0,0 +1,49 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'environment.dart';
import 'options.dart';
import 'target.dart';
final log = Logger('build_gradle');
class BuildGradle {
BuildGradle({required this.userOptions});
final CargokitUserOptions userOptions;
Future<void> build() async {
final targets = Environment.targetPlatforms.map((arch) {
final target = Target.forFlutterName(arch);
if (target == null) {
throw Exception(
"Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}");
}
return target;
}).toList();
final environment = BuildEnvironment.fromEnvironment(isAndroid: true);
final provider =
ArtifactProvider(environment: environment, userOptions: userOptions);
final artifacts = await provider.getArtifacts(targets);
for (final target in targets) {
final libs = artifacts[target]!;
final outputDir = path.join(Environment.outputDir, target.android!);
Directory(outputDir).createSync(recursive: true);
for (final lib in libs) {
if (lib.type == AritifactType.dylib) {
File(lib.path).copySync(path.join(outputDir, lib.finalFileName));
}
}
}
}
}
@@ -0,0 +1,89 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'environment.dart';
import 'options.dart';
import 'target.dart';
import 'util.dart';
class BuildPod {
BuildPod({required this.userOptions});
final CargokitUserOptions userOptions;
Future<void> build() async {
final targets = Environment.darwinArchs.map((arch) {
final target = Target.forDarwin(
platformName: Environment.darwinPlatformName, darwinAarch: arch);
if (target == null) {
throw Exception(
"Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}");
}
return target;
}).toList();
final environment = BuildEnvironment.fromEnvironment(isAndroid: false);
final provider =
ArtifactProvider(environment: environment, userOptions: userOptions);
final artifacts = await provider.getArtifacts(targets);
void performLipo(String targetFile, Iterable<String> sourceFiles) {
runCommand("lipo", [
'-create',
...sourceFiles,
'-output',
targetFile,
]);
}
final outputDir = Environment.outputDir;
Directory(outputDir).createSync(recursive: true);
final staticLibs = artifacts.values
.expand((element) => element)
.where((element) => element.type == AritifactType.staticlib)
.toList();
final dynamicLibs = artifacts.values
.expand((element) => element)
.where((element) => element.type == AritifactType.dylib)
.toList();
final libName = environment.crateInfo.packageName;
// If there is static lib, use it and link it with pod
if (staticLibs.isNotEmpty) {
final finalTargetFile = path.join(outputDir, "lib$libName.a");
performLipo(finalTargetFile, staticLibs.map((e) => e.path));
} else {
// Otherwise try to replace bundle dylib with our dylib
final bundlePaths = [
'$libName.framework/Versions/A/$libName',
'$libName.framework/$libName',
];
for (final bundlePath in bundlePaths) {
final targetFile = path.join(outputDir, bundlePath);
if (File(targetFile).existsSync()) {
performLipo(targetFile, dynamicLibs.map((e) => e.path));
// Replace absolute id with @rpath one so that it works properly
// when moved to Frameworks.
runCommand("install_name_tool", [
'-id',
'@rpath/$bundlePath',
targetFile,
]);
return;
}
}
throw Exception('Unable to find bundle for dynamic library');
}
}
}
@@ -0,0 +1,276 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:github/github.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'android_environment.dart';
import 'build_cmake.dart';
import 'build_gradle.dart';
import 'build_pod.dart';
import 'logging.dart';
import 'options.dart';
import 'precompile_binaries.dart';
import 'target.dart';
import 'util.dart';
import 'verify_binaries.dart';
final log = Logger('build_tool');
abstract class BuildCommand extends Command {
Future<void> runBuildCommand(CargokitUserOptions options);
@override
Future<void> run() async {
final options = CargokitUserOptions.load();
if (options.verboseLogging ||
Platform.environment['CARGOKIT_VERBOSE'] == '1') {
enableVerboseLogging();
}
await runBuildCommand(options);
}
}
class BuildPodCommand extends BuildCommand {
@override
final name = 'build-pod';
@override
final description = 'Build cocoa pod library';
@override
Future<void> runBuildCommand(CargokitUserOptions options) async {
final build = BuildPod(userOptions: options);
await build.build();
}
}
class BuildGradleCommand extends BuildCommand {
@override
final name = 'build-gradle';
@override
final description = 'Build android library';
@override
Future<void> runBuildCommand(CargokitUserOptions options) async {
final build = BuildGradle(userOptions: options);
await build.build();
}
}
class BuildCMakeCommand extends BuildCommand {
@override
final name = 'build-cmake';
@override
final description = 'Build CMake library';
@override
Future<void> runBuildCommand(CargokitUserOptions options) async {
final build = BuildCMake(userOptions: options);
await build.build();
}
}
class GenKeyCommand extends Command {
@override
final name = 'gen-key';
@override
final description = 'Generate key pair for signing precompiled binaries';
@override
void run() {
final kp = generateKey();
final private = HEX.encode(kp.privateKey.bytes);
final public = HEX.encode(kp.publicKey.bytes);
print("Private Key: $private");
print("Public Key: $public");
}
}
class PrecompileBinariesCommand extends Command {
PrecompileBinariesCommand() {
argParser
..addOption(
'repository',
mandatory: true,
help: 'Github repository slug in format owner/name',
)
..addOption(
'manifest-dir',
mandatory: true,
help: 'Directory containing Cargo.toml',
)
..addMultiOption('target',
help: 'Rust target triple of artifact to build.\n'
'Can be specified multiple times or omitted in which case\n'
'all targets for current platform will be built.')
..addOption(
'android-sdk-location',
help: 'Location of Android SDK (if available)',
)
..addOption(
'android-ndk-version',
help: 'Android NDK version (if available)',
)
..addOption(
'android-min-sdk-version',
help: 'Android minimum rquired version (if available)',
)
..addOption(
'temp-dir',
help: 'Directory to store temporary build artifacts',
)
..addOption(
'glibc-version',
help: 'GLIBC version to use for linux builds',
)
..addFlag(
"verbose",
abbr: "v",
defaultsTo: false,
help: "Enable verbose logging",
);
}
@override
final name = 'precompile-binaries';
@override
final description = 'Prebuild and upload binaries\n'
'Private key must be passed through PRIVATE_KEY environment variable. '
'Use gen_key through generate priave key.\n'
'Github token must be passed as GITHUB_TOKEN environment variable.\n';
@override
Future<void> run() async {
final verbose = argResults!['verbose'] as bool;
if (verbose) {
enableVerboseLogging();
}
final privateKeyString = Platform.environment['PRIVATE_KEY'];
if (privateKeyString == null) {
throw ArgumentError('Missing PRIVATE_KEY environment variable');
}
final githubToken = Platform.environment['GITHUB_TOKEN'];
if (githubToken == null) {
throw ArgumentError('Missing GITHUB_TOKEN environment variable');
}
final privateKey = HEX.decode(privateKeyString);
if (privateKey.length != 64) {
throw ArgumentError('Private key must be 64 bytes long');
}
final manifestDir = argResults!['manifest-dir'] as String;
if (!Directory(manifestDir).existsSync()) {
throw ArgumentError('Manifest directory does not exist: $manifestDir');
}
String? androidMinSdkVersionString =
argResults!['android-min-sdk-version'] as String?;
int? androidMinSdkVersion;
if (androidMinSdkVersionString != null) {
androidMinSdkVersion = int.tryParse(androidMinSdkVersionString);
if (androidMinSdkVersion == null) {
throw ArgumentError(
'Invalid android-min-sdk-version: $androidMinSdkVersionString');
}
}
final targetStrigns = argResults!['target'] as List<String>;
final targets = targetStrigns.map((target) {
final res = Target.forRustTriple(target);
if (res == null) {
throw ArgumentError('Invalid target: $target');
}
return res;
}).toList(growable: false);
final precompileBinaries = PrecompileBinaries(
privateKey: PrivateKey(privateKey),
githubToken: githubToken,
manifestDir: manifestDir,
repositorySlug: RepositorySlug.full(argResults!['repository'] as String),
targets: targets,
androidSdkLocation: argResults!['android-sdk-location'] as String?,
androidNdkVersion: argResults!['android-ndk-version'] as String?,
androidMinSdkVersion: androidMinSdkVersion,
tempDir: argResults!['temp-dir'] as String?,
glibcVersion: argResults!['glibc-version'] as String?,
);
await precompileBinaries.run();
}
}
class VerifyBinariesCommand extends Command {
VerifyBinariesCommand() {
argParser.addOption(
'manifest-dir',
mandatory: true,
help: 'Directory containing Cargo.toml',
);
}
@override
final name = "verify-binaries";
@override
final description = 'Verifies published binaries\n'
'Checks whether there is a binary published for each targets\n'
'and checks the signature.';
@override
Future<void> run() async {
final manifestDir = argResults!['manifest-dir'] as String;
final verifyBinaries = VerifyBinaries(
manifestDir: manifestDir,
);
await verifyBinaries.run();
}
}
Future<void> runMain(List<String> args) async {
try {
// Init logging before options are loaded
initLogging();
if (Platform.environment['_CARGOKIT_NDK_LINK_TARGET'] != null) {
return AndroidEnvironment.clangLinkerWrapper(args);
}
final runner = CommandRunner('build_tool', 'Cargokit built_tool')
..addCommand(BuildPodCommand())
..addCommand(BuildGradleCommand())
..addCommand(BuildCMakeCommand())
..addCommand(GenKeyCommand())
..addCommand(PrecompileBinariesCommand())
..addCommand(VerifyBinariesCommand());
await runner.run(args);
} on ArgumentError catch (e) {
stderr.writeln(e.toString());
exit(1);
} catch (e, s) {
log.severe(kDoubleSeparator);
log.severe('Cargokit BuildTool failed with error:');
log.severe(kSeparator);
log.severe(e);
// This tells user to install Rust, there's no need to pollute the log with
// stack trace.
if (e is! RustupNotFoundException) {
log.severe(kSeparator);
log.severe(s);
log.severe(kSeparator);
log.severe('BuildTool arguments: $args');
}
log.severe(kDoubleSeparator);
exit(1);
}
}
@@ -0,0 +1,209 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'android_environment.dart';
import 'cargo.dart';
import 'environment.dart';
import 'options.dart';
import 'rustup.dart';
import 'target.dart';
import 'util.dart';
final _log = Logger('builder');
enum BuildConfiguration {
debug,
release,
profile,
}
extension on BuildConfiguration {
bool get isDebug => this == BuildConfiguration.debug;
String get rustName => switch (this) {
BuildConfiguration.debug => 'debug',
BuildConfiguration.release => 'release',
BuildConfiguration.profile => 'release',
};
}
class BuildException implements Exception {
final String message;
BuildException(this.message);
@override
String toString() {
return 'BuildException: $message';
}
}
class BuildEnvironment {
final BuildConfiguration configuration;
final CargokitCrateOptions crateOptions;
final String targetTempDir;
final String manifestDir;
final CrateInfo crateInfo;
final bool isAndroid;
final String? androidSdkPath;
final String? androidNdkVersion;
final int? androidMinSdkVersion;
final String? javaHome;
final String? glibcVersion;
BuildEnvironment({
required this.configuration,
required this.crateOptions,
required this.targetTempDir,
required this.manifestDir,
required this.crateInfo,
required this.isAndroid,
this.androidSdkPath,
this.androidNdkVersion,
this.androidMinSdkVersion,
this.javaHome,
this.glibcVersion,
});
static BuildConfiguration parseBuildConfiguration(String value) {
// XCode configuration adds the flavor to configuration name.
final firstSegment = value.split('-').first;
final buildConfiguration = BuildConfiguration.values.firstWhereOrNull(
(e) => e.name == firstSegment,
);
if (buildConfiguration == null) {
_log.warning('Unknown build configuraiton $value, will assume release');
return BuildConfiguration.release;
}
return buildConfiguration;
}
static BuildEnvironment fromEnvironment({
required bool isAndroid,
}) {
final buildConfiguration =
parseBuildConfiguration(Environment.configuration);
final manifestDir = Environment.manifestDir;
final crateOptions = CargokitCrateOptions.load(
manifestDir: manifestDir,
);
final crateInfo = CrateInfo.load(manifestDir);
return BuildEnvironment(
configuration: buildConfiguration,
crateOptions: crateOptions,
targetTempDir: Environment.targetTempDir,
manifestDir: manifestDir,
crateInfo: crateInfo,
isAndroid: isAndroid,
androidSdkPath: isAndroid ? Environment.sdkPath : null,
androidNdkVersion: isAndroid ? Environment.ndkVersion : null,
androidMinSdkVersion:
isAndroid ? int.parse(Environment.minSdkVersion) : null,
javaHome: isAndroid ? Environment.javaHome : null,
);
}
}
class RustBuilder {
final Target target;
final BuildEnvironment environment;
RustBuilder({
required this.target,
required this.environment,
});
void prepare(
Rustup rustup,
) {
final toolchain = _toolchain;
if (rustup.installedTargets(toolchain) == null) {
rustup.installToolchain(toolchain);
}
if (toolchain == 'nightly') {
rustup.installRustSrcForNightly();
}
if (!rustup.installedTargets(toolchain)!.contains(target.rust)) {
rustup.installTarget(target.rust, toolchain: toolchain);
}
if (environment.glibcVersion != null) {
rustup.installZigBuild(toolchain);
}
}
CargoBuildOptions? get _buildOptions =>
environment.crateOptions.cargo[environment.configuration];
String get _toolchain => _buildOptions?.toolchain.name ?? 'stable';
/// Returns the path of directory containing build artifacts.
Future<String> build() async {
final extraArgs = _buildOptions?.flags ?? [];
final manifestPath = path.join(environment.manifestDir, 'Cargo.toml');
runCommand(
'rustup',
[
'run',
_toolchain,
'cargo',
(target.android == null && environment.glibcVersion != null)
? 'zigbuild'
: 'build',
...extraArgs,
'--manifest-path',
manifestPath,
'-p',
environment.crateInfo.packageName,
if (!environment.configuration.isDebug) '--release',
'--target',
target.rust +
((target.android == null && environment.glibcVersion != null)
? '.${environment.glibcVersion!}'
: ""),
'--target-dir',
environment.targetTempDir,
],
environment: await _buildEnvironment(),
);
return path.join(
environment.targetTempDir,
target.rust,
environment.configuration.rustName,
);
}
Future<Map<String, String>> _buildEnvironment() async {
if (target.android == null) {
return {};
} else {
final sdkPath = environment.androidSdkPath;
final ndkVersion = environment.androidNdkVersion;
final minSdkVersion = environment.androidMinSdkVersion;
if (sdkPath == null) {
throw BuildException('androidSdkPath is not set');
}
if (ndkVersion == null) {
throw BuildException('androidNdkVersion is not set');
}
if (minSdkVersion == null) {
throw BuildException('androidMinSdkVersion is not set');
}
final env = AndroidEnvironment(
sdkPath: sdkPath,
ndkVersion: ndkVersion,
minSdkVersion: minSdkVersion,
targetTempDir: environment.targetTempDir,
target: target,
);
if (!env.ndkIsInstalled() && environment.javaHome != null) {
env.installNdk(javaHome: environment.javaHome!);
}
return env.buildEnvironment();
}
}
}
@@ -0,0 +1,48 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:path/path.dart' as path;
import 'package:toml/toml.dart';
class ManifestException {
ManifestException(this.message, {required this.fileName});
final String? fileName;
final String message;
@override
String toString() {
if (fileName != null) {
return 'Failed to parse package manifest at $fileName: $message';
} else {
return 'Failed to parse package manifest: $message';
}
}
}
class CrateInfo {
CrateInfo({required this.packageName});
final String packageName;
static CrateInfo parseManifest(String manifest, {final String? fileName}) {
final toml = TomlDocument.parse(manifest);
final package = toml.toMap()['package'];
if (package == null) {
throw ManifestException('Missing package section', fileName: fileName);
}
final name = package['name'];
if (name == null) {
throw ManifestException('Missing package name', fileName: fileName);
}
return CrateInfo(packageName: name);
}
static CrateInfo load(String manifestDir) {
final manifestFile = File(path.join(manifestDir, 'Cargo.toml'));
final manifest = manifestFile.readAsStringSync();
return parseManifest(manifest, fileName: manifestFile.path);
}
}
@@ -0,0 +1,124 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as path;
class CrateHash {
/// Computes a hash uniquely identifying crate content. This takes into account
/// content all all .rs files inside the src directory, as well as Cargo.toml,
/// Cargo.lock, build.rs and cargokit.yaml.
///
/// If [tempStorage] is provided, computed hash is stored in a file in that directory
/// and reused on subsequent calls if the crate content hasn't changed.
static String compute(String manifestDir, {String? tempStorage}) {
return CrateHash._(
manifestDir: manifestDir,
tempStorage: tempStorage,
)._compute();
}
CrateHash._({
required this.manifestDir,
required this.tempStorage,
});
String _compute() {
final files = getFiles();
final tempStorage = this.tempStorage;
if (tempStorage != null) {
final quickHash = _computeQuickHash(files);
final quickHashFolder = Directory(path.join(tempStorage, 'crate_hash'));
quickHashFolder.createSync(recursive: true);
final quickHashFile = File(path.join(quickHashFolder.path, quickHash));
if (quickHashFile.existsSync()) {
return quickHashFile.readAsStringSync();
}
final hash = _computeHash(files);
quickHashFile.writeAsStringSync(hash);
return hash;
} else {
return _computeHash(files);
}
}
/// Computes a quick hash based on files stat (without reading contents). This
/// is used to cache the real hash, which is slower to compute since it involves
/// reading every single file.
String _computeQuickHash(List<File> files) {
final output = AccumulatorSink<Digest>();
final input = sha256.startChunkedConversion(output);
final data = ByteData(8);
for (final file in files) {
input.add(utf8.encode(file.path));
final stat = file.statSync();
data.setUint64(0, stat.size);
input.add(data.buffer.asUint8List());
data.setUint64(0, stat.modified.millisecondsSinceEpoch);
input.add(data.buffer.asUint8List());
}
input.close();
return base64Url.encode(output.events.single.bytes);
}
String _computeHash(List<File> files) {
final output = AccumulatorSink<Digest>();
final input = sha256.startChunkedConversion(output);
void addTextFile(File file) {
// text Files are hashed by lines in case we're dealing with github checkout
// that auto-converts line endings.
final splitter = LineSplitter();
if (file.existsSync()) {
final data = file.readAsStringSync();
final lines = splitter.convert(data);
for (final line in lines) {
input.add(utf8.encode(line));
}
}
}
for (final file in files) {
addTextFile(file);
}
input.close();
final res = output.events.single;
// Truncate to 128bits.
final hash = res.bytes.sublist(0, 16);
return hex.encode(hash);
}
List<File> getFiles() {
final src = Directory(path.join(manifestDir, 'src'));
final files = src
.listSync(recursive: true, followLinks: false)
.whereType<File>()
.toList();
files.sortBy((element) => element.path);
void addFile(String relative) {
final file = File(path.join(manifestDir, relative));
if (file.existsSync()) {
files.add(file);
}
}
addFile('Cargo.toml');
addFile('Cargo.lock');
addFile('build.rs');
addFile('cargokit.yaml');
return files;
}
final String manifestDir;
final String? tempStorage;
}
@@ -0,0 +1,68 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
extension on String {
String resolveSymlink() => File(this).resolveSymbolicLinksSync();
}
class Environment {
/// Current build configuration (debug or release).
static String get configuration =>
_getEnv("CARGOKIT_CONFIGURATION").toLowerCase();
static bool get isDebug => configuration == 'debug';
static bool get isRelease => configuration == 'release';
/// Temporary directory where Rust build artifacts are placed.
static String get targetTempDir => _getEnv("CARGOKIT_TARGET_TEMP_DIR");
/// Final output directory where the build artifacts are placed.
static String get outputDir => _getEnvPath('CARGOKIT_OUTPUT_DIR');
/// Path to the crate manifest (containing Cargo.toml).
static String get manifestDir => _getEnvPath('CARGOKIT_MANIFEST_DIR');
/// Directory inside root project. Not necessarily root folder. Symlinks are
/// not resolved on purpose.
static String get rootProjectDir => _getEnv('CARGOKIT_ROOT_PROJECT_DIR');
// Pod
/// Platform name (macosx, iphoneos, iphonesimulator).
static String get darwinPlatformName =>
_getEnv("CARGOKIT_DARWIN_PLATFORM_NAME");
/// List of architectures to build for (arm64, armv7, x86_64).
static List<String> get darwinArchs =>
_getEnv("CARGOKIT_DARWIN_ARCHS").split(' ');
// Gradle
static String get minSdkVersion => _getEnv("CARGOKIT_MIN_SDK_VERSION");
static String get ndkVersion => _getEnv("CARGOKIT_NDK_VERSION");
static String get sdkPath => _getEnvPath("CARGOKIT_SDK_DIR");
static String get javaHome => _getEnvPath("CARGOKIT_JAVA_HOME");
static List<String> get targetPlatforms =>
_getEnv("CARGOKIT_TARGET_PLATFORMS").split(',');
// CMAKE
static String get targetPlatform => _getEnv("CARGOKIT_TARGET_PLATFORM");
static String _getEnv(String key) {
final res = Platform.environment[key];
if (res == null) {
throw Exception("Missing environment variable $key");
}
return res;
}
static String _getEnvPath(String key) {
final res = _getEnv(key);
if (Directory(res).existsSync()) {
return res.resolveSymlink();
} else {
return res;
}
}
}
@@ -0,0 +1,52 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:logging/logging.dart';
const String kSeparator = "--";
const String kDoubleSeparator = "==";
bool _lastMessageWasSeparator = false;
void _log(LogRecord rec) {
final prefix = '${rec.level.name}: ';
final out = rec.level == Level.SEVERE ? stderr : stdout;
if (rec.message == kSeparator) {
if (!_lastMessageWasSeparator) {
out.write(prefix);
out.writeln('-' * 80);
_lastMessageWasSeparator = true;
}
return;
} else if (rec.message == kDoubleSeparator) {
out.write(prefix);
out.writeln('=' * 80);
_lastMessageWasSeparator = true;
return;
}
out.write(prefix);
out.writeln(rec.message);
_lastMessageWasSeparator = false;
}
void initLogging() {
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen((LogRecord rec) {
final lines = rec.message.split('\n');
for (final line in lines) {
if (line.isNotEmpty || lines.length == 1 || line != lines.last) {
_log(LogRecord(
rec.level,
line,
rec.loggerName,
));
}
}
});
}
void enableVerboseLogging() {
Logger.root.level = Level.ALL;
}
@@ -0,0 +1,309 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'package:source_span/source_span.dart';
import 'package:yaml/yaml.dart';
import 'builder.dart';
import 'environment.dart';
import 'rustup.dart';
final _log = Logger('options');
/// A class for exceptions that have source span information attached.
class SourceSpanException implements Exception {
// This is a getter so that subclasses can override it.
/// A message describing the exception.
String get message => _message;
final String _message;
// This is a getter so that subclasses can override it.
/// The span associated with this exception.
///
/// This may be `null` if the source location can't be determined.
SourceSpan? get span => _span;
final SourceSpan? _span;
SourceSpanException(this._message, this._span);
/// Returns a string representation of `this`.
///
/// [color] may either be a [String], a [bool], or `null`. If it's a string,
/// it indicates an ANSI terminal color escape that should be used to
/// highlight the span's text. If it's `true`, it indicates that the text
/// should be highlighted using the default color. If it's `false` or `null`,
/// it indicates that the text shouldn't be highlighted.
@override
String toString({Object? color}) {
if (span == null) return message;
return 'Error on ${span!.message(message, color: color)}';
}
}
enum Toolchain {
stable,
beta,
nightly,
}
class CargoBuildOptions {
final Toolchain toolchain;
final List<String> flags;
CargoBuildOptions({
required this.toolchain,
required this.flags,
});
static Toolchain _toolchainFromNode(YamlNode node) {
if (node case YamlScalar(value: String name)) {
final toolchain =
Toolchain.values.firstWhereOrNull((element) => element.name == name);
if (toolchain != null) {
return toolchain;
}
}
throw SourceSpanException(
'Unknown toolchain. Must be one of ${Toolchain.values.map((e) => e.name)}.',
node.span);
}
static CargoBuildOptions parse(YamlNode node) {
if (node is! YamlMap) {
throw SourceSpanException('Cargo options must be a map', node.span);
}
Toolchain toolchain = Toolchain.stable;
List<String> flags = [];
for (final MapEntry(:key, :value) in node.nodes.entries) {
if (key case YamlScalar(value: 'toolchain')) {
toolchain = _toolchainFromNode(value);
} else if (key case YamlScalar(value: 'extra_flags')) {
if (value case YamlList(nodes: List<YamlNode> list)) {
if (list.every((element) {
if (element case YamlScalar(value: String _)) {
return true;
}
return false;
})) {
flags = list.map((e) => e.value as String).toList();
continue;
}
}
throw SourceSpanException(
'Extra flags must be a list of strings', value.span);
} else {
throw SourceSpanException(
'Unknown cargo option type. Must be "toolchain" or "extra_flags".',
key.span);
}
}
return CargoBuildOptions(toolchain: toolchain, flags: flags);
}
}
extension on YamlMap {
/// Map that extracts keys so that we can do map case check on them.
Map<dynamic, YamlNode> get valueMap =>
nodes.map((key, value) => MapEntry(key.value, value));
}
class PrecompiledBinaries {
final String uriPrefix;
final PublicKey publicKey;
PrecompiledBinaries({
required this.uriPrefix,
required this.publicKey,
});
static PublicKey _publicKeyFromHex(String key, SourceSpan? span) {
final bytes = HEX.decode(key);
if (bytes.length != 32) {
throw SourceSpanException(
'Invalid public key. Must be 32 bytes long.', span);
}
return PublicKey(bytes);
}
static PrecompiledBinaries parse(YamlNode node) {
if (node case YamlMap(valueMap: Map<dynamic, YamlNode> map)) {
if (map
case {
'url_prefix': YamlNode urlPrefixNode,
'public_key': YamlNode publicKeyNode,
}) {
final urlPrefix = switch (urlPrefixNode) {
YamlScalar(value: String urlPrefix) => urlPrefix,
_ => throw SourceSpanException(
'Invalid URL prefix value.', urlPrefixNode.span),
};
final publicKey = switch (publicKeyNode) {
YamlScalar(value: String publicKey) =>
_publicKeyFromHex(publicKey, publicKeyNode.span),
_ => throw SourceSpanException(
'Invalid public key value.', publicKeyNode.span),
};
return PrecompiledBinaries(
uriPrefix: urlPrefix,
publicKey: publicKey,
);
}
}
throw SourceSpanException(
'Invalid precompiled binaries value. '
'Expected Map with "url_prefix" and "public_key".',
node.span);
}
}
/// Cargokit options specified for Rust crate.
class CargokitCrateOptions {
CargokitCrateOptions({
this.cargo = const {},
this.precompiledBinaries,
});
final Map<BuildConfiguration, CargoBuildOptions> cargo;
final PrecompiledBinaries? precompiledBinaries;
static CargokitCrateOptions parse(YamlNode node) {
if (node is! YamlMap) {
throw SourceSpanException('Cargokit options must be a map', node.span);
}
final options = <BuildConfiguration, CargoBuildOptions>{};
PrecompiledBinaries? precompiledBinaries;
for (final entry in node.nodes.entries) {
if (entry
case MapEntry(
key: YamlScalar(value: 'cargo'),
value: YamlNode node,
)) {
if (node is! YamlMap) {
throw SourceSpanException('Cargo options must be a map', node.span);
}
for (final MapEntry(:YamlNode key, :value) in node.nodes.entries) {
if (key case YamlScalar(value: String name)) {
final configuration = BuildConfiguration.values
.firstWhereOrNull((element) => element.name == name);
if (configuration != null) {
options[configuration] = CargoBuildOptions.parse(value);
continue;
}
}
throw SourceSpanException(
'Unknown build configuration. Must be one of ${BuildConfiguration.values.map((e) => e.name)}.',
key.span);
}
} else if (entry.key case YamlScalar(value: 'precompiled_binaries')) {
precompiledBinaries = PrecompiledBinaries.parse(entry.value);
} else {
throw SourceSpanException(
'Unknown cargokit option type. Must be "cargo" or "precompiled_binaries".',
entry.key.span);
}
}
return CargokitCrateOptions(
cargo: options,
precompiledBinaries: precompiledBinaries,
);
}
static CargokitCrateOptions load({
required String manifestDir,
}) {
final uri = Uri.file(path.join(manifestDir, "cargokit.yaml"));
final file = File.fromUri(uri);
if (file.existsSync()) {
final contents = loadYamlNode(file.readAsStringSync(), sourceUrl: uri);
return parse(contents);
} else {
return CargokitCrateOptions();
}
}
}
class CargokitUserOptions {
// When Rustup is installed always build locally unless user opts into
// using precompiled binaries.
static bool defaultUsePrecompiledBinaries() {
return Rustup.executablePath() == null;
}
CargokitUserOptions({
required this.usePrecompiledBinaries,
required this.verboseLogging,
});
CargokitUserOptions._()
: usePrecompiledBinaries = defaultUsePrecompiledBinaries(),
verboseLogging = false;
static CargokitUserOptions parse(YamlNode node) {
if (node is! YamlMap) {
throw SourceSpanException('Cargokit options must be a map', node.span);
}
bool usePrecompiledBinaries = defaultUsePrecompiledBinaries();
bool verboseLogging = false;
for (final entry in node.nodes.entries) {
if (entry.key case YamlScalar(value: 'use_precompiled_binaries')) {
if (entry.value case YamlScalar(value: bool value)) {
usePrecompiledBinaries = value;
continue;
}
throw SourceSpanException(
'Invalid value for "use_precompiled_binaries". Must be a boolean.',
entry.value.span);
} else if (entry.key case YamlScalar(value: 'verbose_logging')) {
if (entry.value case YamlScalar(value: bool value)) {
verboseLogging = value;
continue;
}
throw SourceSpanException(
'Invalid value for "verbose_logging". Must be a boolean.',
entry.value.span);
} else {
throw SourceSpanException(
'Unknown cargokit option type. Must be "use_precompiled_binaries" or "verbose_logging".',
entry.key.span);
}
}
return CargokitUserOptions(
usePrecompiledBinaries: usePrecompiledBinaries,
verboseLogging: verboseLogging,
);
}
static CargokitUserOptions load() {
String fileName = "cargokit_options.yaml";
var userProjectDir = Directory(Environment.rootProjectDir);
while (userProjectDir.parent.path != userProjectDir.path) {
final configFile = File(path.join(userProjectDir.path, fileName));
if (configFile.existsSync()) {
final contents = loadYamlNode(
configFile.readAsStringSync(),
sourceUrl: configFile.uri,
);
final res = parse(contents);
if (res.verboseLogging) {
_log.info('Found user options file at ${configFile.path}');
}
return res;
}
userProjectDir = userProjectDir.parent;
}
return CargokitUserOptions._();
}
final bool usePrecompiledBinaries;
final bool verboseLogging;
}
@@ -0,0 +1,205 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:github/github.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'cargo.dart';
import 'crate_hash.dart';
import 'options.dart';
import 'rustup.dart';
import 'target.dart';
final _log = Logger('precompile_binaries');
class PrecompileBinaries {
PrecompileBinaries({
required this.privateKey,
required this.githubToken,
required this.repositorySlug,
required this.manifestDir,
required this.targets,
this.androidSdkLocation,
this.androidNdkVersion,
this.androidMinSdkVersion,
this.tempDir,
this.glibcVersion,
});
final PrivateKey privateKey;
final String githubToken;
final RepositorySlug repositorySlug;
final String manifestDir;
final List<Target> targets;
final String? androidSdkLocation;
final String? androidNdkVersion;
final int? androidMinSdkVersion;
final String? tempDir;
final String? glibcVersion;
static String fileName(Target target, String name) {
return '${target.rust}_$name';
}
static String signatureFileName(Target target, String name) {
return '${target.rust}_$name.sig';
}
Future<void> run() async {
final crateInfo = CrateInfo.load(manifestDir);
final targets = List.of(this.targets);
if (targets.isEmpty) {
targets.addAll([
...Target.buildableTargets(),
if (androidSdkLocation != null) ...Target.androidTargets(),
]);
}
_log.info('Precompiling binaries for $targets');
final hash = CrateHash.compute(manifestDir);
_log.info('Computed crate hash: $hash');
final String tagName = 'precompiled_$hash';
final github = GitHub(auth: Authentication.withToken(githubToken));
final repo = github.repositories;
final release = await _getOrCreateRelease(
repo: repo,
tagName: tagName,
packageName: crateInfo.packageName,
hash: hash,
);
final tempDir = this.tempDir != null
? Directory(this.tempDir!)
: Directory.systemTemp.createTempSync('precompiled_');
tempDir.createSync(recursive: true);
final crateOptions = CargokitCrateOptions.load(
manifestDir: manifestDir,
);
final buildEnvironment = BuildEnvironment(
configuration: BuildConfiguration.release,
crateOptions: crateOptions,
targetTempDir: tempDir.path,
manifestDir: manifestDir,
crateInfo: crateInfo,
isAndroid: androidSdkLocation != null,
androidSdkPath: androidSdkLocation,
androidNdkVersion: androidNdkVersion,
androidMinSdkVersion: androidMinSdkVersion,
glibcVersion: glibcVersion,
);
final rustup = Rustup();
for (final target in targets) {
final artifactNames = getArtifactNames(
target: target,
libraryName: crateInfo.packageName,
remote: true,
);
if (artifactNames.every((name) {
final fileName = PrecompileBinaries.fileName(target, name);
return (release.assets ?? []).any((e) => e.name == fileName);
})) {
_log.info("All artifacts for $target already exist - skipping");
continue;
}
_log.info('Building for $target');
final builder =
RustBuilder(target: target, environment: buildEnvironment);
builder.prepare(rustup);
final res = await builder.build();
final assets = <CreateReleaseAsset>[];
for (final name in artifactNames) {
final file = File(path.join(res, name));
if (!file.existsSync()) {
throw Exception('Missing artifact: ${file.path}');
}
final data = file.readAsBytesSync();
final create = CreateReleaseAsset(
name: PrecompileBinaries.fileName(target, name),
contentType: "application/octet-stream",
assetData: data,
);
final signature = sign(privateKey, data);
final signatureCreate = CreateReleaseAsset(
name: signatureFileName(target, name),
contentType: "application/octet-stream",
assetData: signature,
);
bool verified = verify(public(privateKey), data, signature);
if (!verified) {
throw Exception('Signature verification failed');
}
assets.add(create);
assets.add(signatureCreate);
}
_log.info('Uploading assets: ${assets.map((e) => e.name)}');
for (final asset in assets) {
// This seems to be failing on CI so do it one by one
int retryCount = 0;
while (true) {
try {
await repo.uploadReleaseAssets(release, [asset]);
break;
} on Exception catch (e) {
if (retryCount == 10) {
rethrow;
}
++retryCount;
_log.shout(
'Upload failed (attempt $retryCount, will retry): ${e.toString()}');
await Future.delayed(Duration(seconds: 2));
}
}
}
}
_log.info('Cleaning up');
tempDir.deleteSync(recursive: true);
}
Future<Release> _getOrCreateRelease({
required RepositoriesService repo,
required String tagName,
required String packageName,
required String hash,
}) async {
Release release;
try {
_log.info('Fetching release $tagName');
release = await repo.getReleaseByTagName(repositorySlug, tagName);
} on ReleaseNotFound {
_log.info('Release not found - creating release $tagName');
release = await repo.createRelease(
repositorySlug,
CreateRelease.from(
tagName: tagName,
name: 'Precompiled binaries ${hash.substring(0, 8)}',
targetCommitish: null,
isDraft: false,
isPrerelease: false,
body: 'Precompiled binaries for crate $packageName, '
'crate hash $hash.',
));
}
return release;
}
}
@@ -0,0 +1,149 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
import 'util.dart';
class _Toolchain {
_Toolchain(
this.name,
this.targets,
);
final String name;
final List<String> targets;
}
class Rustup {
List<String>? installedTargets(String toolchain) {
final targets = _installedTargets(toolchain);
return targets != null ? List.unmodifiable(targets) : null;
}
void installToolchain(String toolchain) {
log.info("Installing Rust toolchain: $toolchain");
runCommand("rustup", ['toolchain', 'install', toolchain]);
_installedToolchains
.add(_Toolchain(toolchain, _getInstalledTargets(toolchain)));
}
void installTarget(
String target, {
required String toolchain,
}) {
log.info("Installing Rust target: $target");
runCommand("rustup", ['target', 'add', '--toolchain', toolchain, target]);
_installedTargets(toolchain)?.add(target);
}
bool _didInstallZigBuild = false;
void installZigBuild(String toolchain) {
if (_didInstallZigBuild) {
return;
}
log.info("Installing Zig build");
runCommand("rustup", [
'run',
toolchain,
'cargo',
'install',
'--locked',
'cargo-zigbuild',
]);
_didInstallZigBuild = true;
}
final List<_Toolchain> _installedToolchains;
Rustup() : _installedToolchains = _getInstalledToolchains();
List<String>? _installedTargets(String toolchain) => _installedToolchains
.firstWhereOrNull(
(e) => e.name == toolchain || e.name.startsWith('$toolchain-'))
?.targets;
static List<_Toolchain> _getInstalledToolchains() {
String extractToolchainName(String line) {
// ignore (default) after toolchain name
final parts = line.split(' ');
return parts[0];
}
final res = runCommand("rustup", ['toolchain', 'list']);
// To list all non-custom toolchains, we need to filter out lines that
// don't start with "stable", "beta", or "nightly".
Pattern nonCustom = RegExp(r"^(stable|beta|nightly)");
final lines = res.stdout
.toString()
.split('\n')
.where((e) => e.isNotEmpty && e.startsWith(nonCustom))
.map(extractToolchainName)
.toList(growable: true);
return lines
.map(
(name) => _Toolchain(
name,
_getInstalledTargets(name),
),
)
.toList(growable: true);
}
static List<String> _getInstalledTargets(String toolchain) {
final res = runCommand("rustup", [
'target',
'list',
'--toolchain',
toolchain,
'--installed',
]);
final lines = res.stdout
.toString()
.split('\n')
.where((e) => e.isNotEmpty)
.toList(growable: true);
return lines;
}
bool _didInstallRustSrcForNightly = false;
void installRustSrcForNightly() {
if (_didInstallRustSrcForNightly) {
return;
}
// Useful for -Z build-std
runCommand(
"rustup",
['component', 'add', 'rust-src', '--toolchain', 'nightly'],
);
_didInstallRustSrcForNightly = true;
}
static String? executablePath() {
final envPath = Platform.environment['PATH'];
final envPathSeparator = Platform.isWindows ? ';' : ':';
final home = Platform.isWindows
? Platform.environment['USERPROFILE']
: Platform.environment['HOME'];
final paths = [
if (home != null) path.join(home, '.cargo', 'bin'),
if (envPath != null) ...envPath.split(envPathSeparator),
];
for (final p in paths) {
final rustup = Platform.isWindows ? 'rustup.exe' : 'rustup';
final rustupPath = path.join(p, rustup);
if (File(rustupPath).existsSync()) {
return rustupPath;
}
}
return null;
}
}
@@ -0,0 +1,147 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:collection/collection.dart';
import 'util.dart';
class Target {
Target({
required this.rust,
this.flutter,
this.android,
this.androidMinSdkVersion,
this.darwinPlatform,
this.darwinArch,
});
static final all = [
Target(
rust: 'armv7-linux-androideabi',
flutter: 'android-arm',
android: 'armeabi-v7a',
androidMinSdkVersion: 16,
),
Target(
rust: 'aarch64-linux-android',
flutter: 'android-arm64',
android: 'arm64-v8a',
androidMinSdkVersion: 21,
),
Target(
rust: 'i686-linux-android',
flutter: 'android-x86',
android: 'x86',
androidMinSdkVersion: 16,
),
Target(
rust: 'x86_64-linux-android',
flutter: 'android-x64',
android: 'x86_64',
androidMinSdkVersion: 21,
),
Target(
rust: 'x86_64-pc-windows-msvc',
flutter: 'windows-x64',
),
Target(
rust: 'aarch64-pc-windows-msvc',
flutter: 'windows-arm64',
),
Target(
rust: 'x86_64-unknown-linux-gnu',
flutter: 'linux-x64',
),
Target(
rust: 'aarch64-unknown-linux-gnu',
flutter: 'linux-arm64',
),
Target(rust: 'riscv64gc-unknown-linux-gnu', flutter: 'linux-riscv64'),
Target(
rust: 'x86_64-apple-darwin',
darwinPlatform: 'macosx',
darwinArch: 'x86_64',
),
Target(
rust: 'aarch64-apple-darwin',
darwinPlatform: 'macosx',
darwinArch: 'arm64',
),
Target(
rust: 'aarch64-apple-ios',
darwinPlatform: 'iphoneos',
darwinArch: 'arm64',
),
Target(
rust: 'aarch64-apple-ios-sim',
darwinPlatform: 'iphonesimulator',
darwinArch: 'arm64',
),
Target(
rust: 'x86_64-apple-ios',
darwinPlatform: 'iphonesimulator',
darwinArch: 'x86_64',
),
];
static Target? forFlutterName(String flutterName) {
return all.firstWhereOrNull((element) => element.flutter == flutterName);
}
static Target? forDarwin({
required String platformName,
required String darwinAarch,
}) {
return all.firstWhereOrNull((element) => //
element.darwinPlatform == platformName &&
element.darwinArch == darwinAarch);
}
static Target? forRustTriple(String triple) {
return all.firstWhereOrNull((element) => element.rust == triple);
}
static List<Target> androidTargets() {
return all
.where((element) => element.android != null)
.toList(growable: false);
}
/// Returns buildable targets on current host platform ignoring Android targets.
static List<Target> buildableTargets() {
if (Platform.isLinux) {
// Right now we don't support cross-compiling on Linux. So we just return
// the host target.
final arch = (runCommand('arch', []).stdout as String).trim();
if (arch == 'aarch64') {
return [Target.forRustTriple('aarch64-unknown-linux-gnu')!];
} else if (arch == 'riscv64') {
return [Target.forRustTriple('riscv64gc-unknown-linux-gnu')!];
} else {
return [Target.forRustTriple('x86_64-unknown-linux-gnu')!];
}
}
return all.where((target) {
if (Platform.isWindows) {
return target.rust.contains('-windows-');
} else if (Platform.isMacOS) {
return target.darwinPlatform != null;
}
return false;
}).toList(growable: false);
}
@override
String toString() {
return rust;
}
final String? flutter;
final String rust;
final String? android;
final int? androidMinSdkVersion;
final String? darwinPlatform;
final String? darwinArch;
}
@@ -0,0 +1,172 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'logging.dart';
import 'rustup.dart';
final log = Logger("process");
class CommandFailedException implements Exception {
final String executable;
final List<String> arguments;
final ProcessResult result;
CommandFailedException({
required this.executable,
required this.arguments,
required this.result,
});
@override
String toString() {
final stdout = result.stdout.toString().trim();
final stderr = result.stderr.toString().trim();
return [
"External Command: $executable ${arguments.map((e) => '"$e"').join(' ')}",
"Returned Exit Code: ${result.exitCode}",
kSeparator,
"STDOUT:",
if (stdout.isNotEmpty) stdout,
kSeparator,
"STDERR:",
if (stderr.isNotEmpty) stderr,
].join('\n');
}
}
class TestRunCommandArgs {
final String executable;
final List<String> arguments;
final String? workingDirectory;
final Map<String, String>? environment;
final bool includeParentEnvironment;
final bool runInShell;
final Encoding? stdoutEncoding;
final Encoding? stderrEncoding;
TestRunCommandArgs({
required this.executable,
required this.arguments,
this.workingDirectory,
this.environment,
this.includeParentEnvironment = true,
this.runInShell = false,
this.stdoutEncoding,
this.stderrEncoding,
});
}
class TestRunCommandResult {
TestRunCommandResult({
this.pid = 1,
this.exitCode = 0,
this.stdout = '',
this.stderr = '',
});
final int pid;
final int exitCode;
final String stdout;
final String stderr;
}
TestRunCommandResult Function(TestRunCommandArgs args)? testRunCommandOverride;
ProcessResult runCommand(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment = true,
bool runInShell = false,
Encoding? stdoutEncoding = systemEncoding,
Encoding? stderrEncoding = systemEncoding,
}) {
if (testRunCommandOverride != null) {
final result = testRunCommandOverride!(TestRunCommandArgs(
executable: executable,
arguments: arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: includeParentEnvironment,
runInShell: runInShell,
stdoutEncoding: stdoutEncoding,
stderrEncoding: stderrEncoding,
));
return ProcessResult(
result.pid,
result.exitCode,
result.stdout,
result.stderr,
);
}
log.finer('Running command $executable ${arguments.join(' ')}');
final res = Process.runSync(
_resolveExecutable(executable),
arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: includeParentEnvironment,
runInShell: runInShell,
stderrEncoding: stderrEncoding,
stdoutEncoding: stdoutEncoding,
);
if (res.exitCode != 0) {
throw CommandFailedException(
executable: executable,
arguments: arguments,
result: res,
);
} else {
return res;
}
}
class RustupNotFoundException implements Exception {
@override
String toString() {
return [
' ',
'rustup not found in PATH.',
' ',
'Maybe you need to install Rust? It only takes a minute:',
' ',
if (Platform.isWindows) 'https://www.rust-lang.org/tools/install',
if (hasHomebrewRustInPath()) ...[
'\$ brew unlink rust # Unlink homebrew Rust from PATH',
],
if (!Platform.isWindows)
"\$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh",
' ',
].join('\n');
}
static bool hasHomebrewRustInPath() {
if (!Platform.isMacOS) {
return false;
}
final envPath = Platform.environment['PATH'] ?? '';
final paths = envPath.split(':');
return paths.any((p) {
return p.contains('homebrew') && File(path.join(p, 'rustc')).existsSync();
});
}
}
String _resolveExecutable(String executable) {
if (executable == 'rustup') {
final resolved = Rustup.executablePath();
if (resolved != null) {
return resolved;
}
throw RustupNotFoundException();
} else {
return executable;
}
}
@@ -0,0 +1,84 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:http/http.dart';
import 'artifacts_provider.dart';
import 'cargo.dart';
import 'crate_hash.dart';
import 'options.dart';
import 'precompile_binaries.dart';
import 'target.dart';
class VerifyBinaries {
VerifyBinaries({
required this.manifestDir,
});
final String manifestDir;
Future<void> run() async {
final crateInfo = CrateInfo.load(manifestDir);
final config = CargokitCrateOptions.load(manifestDir: manifestDir);
final precompiledBinaries = config.precompiledBinaries;
if (precompiledBinaries == null) {
stdout.writeln('Crate does not support precompiled binaries.');
} else {
final crateHash = CrateHash.compute(manifestDir);
stdout.writeln('Crate hash: $crateHash');
for (final target in Target.all) {
final message = 'Checking ${target.rust}...';
stdout.write(message.padRight(40));
stdout.flush();
final artifacts = getArtifactNames(
target: target,
libraryName: crateInfo.packageName,
remote: true,
);
final prefix = precompiledBinaries.uriPrefix;
bool ok = true;
for (final artifact in artifacts) {
final fileName = PrecompileBinaries.fileName(target, artifact);
final signatureFileName =
PrecompileBinaries.signatureFileName(target, artifact);
final url = Uri.parse('$prefix$crateHash/$fileName');
final signatureUrl =
Uri.parse('$prefix$crateHash/$signatureFileName');
final signature = await get(signatureUrl);
if (signature.statusCode != 200) {
stdout.writeln('MISSING');
ok = false;
break;
}
final asset = await get(url);
if (asset.statusCode != 200) {
stdout.writeln('MISSING');
ok = false;
break;
}
if (!verify(precompiledBinaries.publicKey, asset.bodyBytes,
signature.bodyBytes)) {
stdout.writeln('INVALID SIGNATURE');
ok = false;
}
}
if (ok) {
stdout.writeln('OK');
}
}
}
}
}
+453
View File
@@ -0,0 +1,453 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051
url: "https://pub.dev"
source: hosted
version: "64.0.0"
adaptive_number:
dependency: transitive
description:
name: adaptive_number
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893"
url: "https://pub.dev"
source: hosted
version: "6.2.0"
args:
dependency: "direct main"
description:
name: args
sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596
url: "https://pub.dev"
source: hosted
version: "2.4.2"
async:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
collection:
dependency: "direct main"
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.18.0"
convert:
dependency: "direct main"
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
coverage:
dependency: transitive
description:
name: coverage
sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097"
url: "https://pub.dev"
source: hosted
version: "1.6.3"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
url: "https://pub.dev"
source: hosted
version: "3.0.3"
ed25519_edwards:
dependency: "direct main"
description:
name: ed25519_edwards
sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
file:
dependency: transitive
description:
name: file
sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
url: "https://pub.dev"
source: hosted
version: "6.1.4"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
github:
dependency: "direct main"
description:
name: github
sha256: "9966bc13bf612342e916b0a343e95e5f046c88f602a14476440e9b75d2295411"
url: "https://pub.dev"
source: hosted
version: "9.17.0"
glob:
dependency: transitive
description:
name: glob
sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
hex:
dependency: "direct main"
description:
name: hex
sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
http:
dependency: "direct main"
description:
name: http
sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
io:
dependency: transitive
description:
name: io
sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467
url: "https://pub.dev"
source: hosted
version: "4.8.1"
lints:
dependency: "direct dev"
description:
name: lints
sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
logging:
dependency: "direct main"
description:
name: logging
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
url: "https://pub.dev"
source: hosted
version: "0.12.16"
meta:
dependency: transitive
description:
name: meta
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
mime:
dependency: transitive
description:
name: mime
sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e
url: "https://pub.dev"
source: hosted
version: "1.0.4"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
path:
dependency: "direct main"
description:
name: path
sha256: "2ad4cddff7f5cc0e2d13069f2a3f7a73ca18f66abd6f5ecf215219cdb3638edb"
url: "https://pub.dev"
source: hosted
version: "1.8.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750
url: "https://pub.dev"
source: hosted
version: "5.4.0"
pool:
dependency: transitive
description:
name: pool
sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
shelf:
dependency: transitive
description:
name: shelf
sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4
url: "https://pub.dev"
source: hosted
version: "1.4.1"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e
url: "https://pub.dev"
source: hosted
version: "1.1.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703"
url: "https://pub.dev"
source: hosted
version: "0.10.12"
source_span:
dependency: "direct main"
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.2"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.1"
test:
dependency: "direct dev"
description:
name: test
sha256: "9b0dd8e36af4a5b1569029949d50a52cb2a2a2fdaa20cebb96e6603b9ae241f9"
url: "https://pub.dev"
source: hosted
version: "1.24.6"
test_api:
dependency: transitive
description:
name: test_api
sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
url: "https://pub.dev"
source: hosted
version: "0.6.1"
test_core:
dependency: transitive
description:
name: test_core
sha256: "4bef837e56375537055fdbbbf6dd458b1859881f4c7e6da936158f77d61ab265"
url: "https://pub.dev"
source: hosted
version: "0.5.6"
toml:
dependency: "direct main"
description:
name: toml
sha256: "157c5dca5160fced243f3ce984117f729c788bb5e475504f3dbcda881accee44"
url: "https://pub.dev"
source: hosted
version: "0.14.0"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
url: "https://pub.dev"
source: hosted
version: "1.3.2"
version:
dependency: "direct main"
description:
name: version
sha256: "2307e23a45b43f96469eeab946208ed63293e8afca9c28cd8b5241ff31c55f55"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0fae432c85c4ea880b33b497d32824b97795b04cdaa74d270219572a1f50268d"
url: "https://pub.dev"
source: hosted
version: "11.9.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b
url: "https://pub.dev"
source: hosted
version: "2.4.0"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
yaml:
dependency: "direct main"
description:
name: yaml
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.0.0 <4.0.0"
+33
View File
@@ -0,0 +1,33 @@
# This is copied from Cargokit (which is the official way to use it currently)
# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
name: build_tool
description: Cargokit build_tool. Facilitates the build of Rust crate during Flutter application build.
publish_to: none
version: 1.0.0
environment:
sdk: ">=3.0.0 <4.0.0"
# Add regular dependencies here.
dependencies:
# these are pinned on purpose because the bundle_tool_runner doesn't have
# pubspec.lock. See run_build_tool.sh
logging: 1.2.0
path: 1.8.0
version: 3.0.0
collection: 1.18.0
ed25519_edwards: 0.3.1
hex: 0.2.0
yaml: 3.1.2
source_span: 1.10.0
github: 9.17.0
args: 2.4.2
crypto: 3.0.3
convert: 3.1.1
http: 1.1.0
toml: 0.14.0
dev_dependencies:
lints: ^2.1.0
test: ^1.24.0
+99
View File
@@ -0,0 +1,99 @@
SET(cargokit_cmake_root "${CMAKE_CURRENT_LIST_DIR}/..")
# Workaround for https://github.com/dart-lang/pub/issues/4010
get_filename_component(cargokit_cmake_root "${cargokit_cmake_root}" REALPATH)
if(WIN32)
# REALPATH does not properly resolve symlinks on windows :-/
execute_process(COMMAND powershell -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_LIST_DIR}/resolve_symlinks.ps1" "${cargokit_cmake_root}" OUTPUT_VARIABLE cargokit_cmake_root OUTPUT_STRIP_TRAILING_WHITESPACE)
endif()
# Arguments
# - target: CMAKE target to which rust library is linked
# - manifest_dir: relative path from current folder to directory containing cargo manifest
# - lib_name: cargo package name
# - any_symbol_name: name of any exported symbol from the library.
# used on windows to force linking with library.
function(apply_cargokit target manifest_dir lib_name any_symbol_name)
set(CARGOKIT_LIB_NAME "${lib_name}")
set(CARGOKIT_LIB_FULL_NAME "${CMAKE_SHARED_MODULE_PREFIX}${CARGOKIT_LIB_NAME}${CMAKE_SHARED_MODULE_SUFFIX}")
if (CMAKE_CONFIGURATION_TYPES)
set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>")
set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/$<CONFIG>/${CARGOKIT_LIB_FULL_NAME}")
else()
set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}")
set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/${CARGOKIT_LIB_FULL_NAME}")
endif()
set(CARGOKIT_TEMP_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargokit_build")
if (FLUTTER_TARGET_PLATFORM)
set(CARGOKIT_TARGET_PLATFORM "${FLUTTER_TARGET_PLATFORM}")
else()
set(CARGOKIT_TARGET_PLATFORM "windows-x64")
endif()
set(CARGOKIT_ENV
"CARGOKIT_CMAKE=${CMAKE_COMMAND}"
"CARGOKIT_CONFIGURATION=$<CONFIG>"
"CARGOKIT_MANIFEST_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${manifest_dir}"
"CARGOKIT_TARGET_TEMP_DIR=${CARGOKIT_TEMP_DIR}"
"CARGOKIT_OUTPUT_DIR=${CARGOKIT_OUTPUT_DIR}"
"CARGOKIT_TARGET_PLATFORM=${CARGOKIT_TARGET_PLATFORM}"
"CARGOKIT_TOOL_TEMP_DIR=${CARGOKIT_TEMP_DIR}/tool"
"CARGOKIT_ROOT_PROJECT_DIR=${CMAKE_SOURCE_DIR}"
)
if (WIN32)
set(SCRIPT_EXTENSION ".cmd")
set(IMPORT_LIB_EXTENSION ".lib")
else()
set(SCRIPT_EXTENSION ".sh")
set(IMPORT_LIB_EXTENSION "")
execute_process(COMMAND chmod +x "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}")
endif()
# Using generators in custom command is only supported in CMake 3.20+
if (CMAKE_CONFIGURATION_TYPES AND ${CMAKE_VERSION} VERSION_LESS "3.20.0")
foreach(CONFIG IN LISTS CMAKE_CONFIGURATION_TYPES)
add_custom_command(
OUTPUT
"${CMAKE_CURRENT_BINARY_DIR}/${CONFIG}/${CARGOKIT_LIB_FULL_NAME}"
"${CMAKE_CURRENT_BINARY_DIR}/_phony_"
COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV}
"${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake
VERBATIM
)
endforeach()
else()
add_custom_command(
OUTPUT
${OUTPUT_LIB}
"${CMAKE_CURRENT_BINARY_DIR}/_phony_"
COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV}
"${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake
VERBATIM
)
endif()
set_source_files_properties("${CMAKE_CURRENT_BINARY_DIR}/_phony_" PROPERTIES SYMBOLIC TRUE)
if (TARGET ${target})
# If we have actual cmake target provided create target and make existing
# target depend on it
add_custom_target("${target}_cargokit" DEPENDS ${OUTPUT_LIB})
add_dependencies("${target}" "${target}_cargokit")
target_link_libraries("${target}" PRIVATE "${OUTPUT_LIB}${IMPORT_LIB_EXTENSION}")
if(WIN32)
target_link_options(${target} PRIVATE "/INCLUDE:${any_symbol_name}")
endif()
else()
# Otherwise (FFI) just use ALL to force building always
add_custom_target("${target}_cargokit" ALL DEPENDS ${OUTPUT_LIB})
endif()
# Allow adding the output library to plugin bundled libraries
set("${target}_cargokit_lib" ${OUTPUT_LIB} PARENT_SCOPE)
endfunction()
+34
View File
@@ -0,0 +1,34 @@
function Resolve-Symlinks {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[string] $Path
)
[string] $separator = '/'
[string[]] $parts = $Path.Split($separator)
[string] $realPath = ''
foreach ($part in $parts) {
if ($realPath -and !$realPath.EndsWith($separator)) {
$realPath += $separator
}
$realPath += $part.Replace('\', '/')
# The slash is important when using Get-Item on Drive letters in pwsh.
if (-not($realPath.Contains($separator)) -and $realPath.EndsWith(':')) {
$realPath += '/'
}
$item = Get-Item $realPath
if ($item.LinkTarget) {
$realPath = $item.LinkTarget.Replace('\', '/')
}
}
$realPath
}
$path = Resolve-Symlinks -Path $args[0]
Write-Host $path
+184
View File
@@ -0,0 +1,184 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import java.nio.file.Paths
import org.apache.tools.ant.taskdefs.condition.Os
CargoKitPlugin.file = buildscript.sourceFile
apply plugin: CargoKitPlugin
class CargoKitExtension {
String manifestDir; // Relative path to folder containing Cargo.toml
String libname; // Library name within Cargo.toml. Must be a cdylib
}
abstract class CargoKitBuildTask extends DefaultTask {
@Input
String buildMode
@Input
String buildDir
@Input
String outputDir
@Input
String ndkVersion
@Input
String sdkDirectory
@Input
int compileSdkVersion;
@Input
int minSdkVersion;
@Input
String pluginFile
@Input
List<String> targetPlatforms
@TaskAction
def build() {
if (project.cargokit.manifestDir == null) {
throw new GradleException("Property 'manifestDir' must be set on cargokit extension");
}
if (project.cargokit.libname == null) {
throw new GradleException("Property 'libname' must be set on cargokit extension");
}
def executableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "run_build_tool.cmd" : "run_build_tool.sh"
def path = Paths.get(new File(pluginFile).parent, "..", executableName);
def manifestDir = Paths.get(project.buildscript.sourceFile.parent, project.cargokit.manifestDir)
def rootProjectDir = project.rootProject.projectDir
if (!Os.isFamily(Os.FAMILY_WINDOWS)) {
project.exec {
commandLine 'chmod', '+x', path
}
}
project.exec {
executable path
args "build-gradle"
environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir
environment "CARGOKIT_TOOL_TEMP_DIR", "${buildDir}/build_tool"
environment "CARGOKIT_MANIFEST_DIR", manifestDir
environment "CARGOKIT_CONFIGURATION", buildMode
environment "CARGOKIT_TARGET_TEMP_DIR", buildDir
environment "CARGOKIT_OUTPUT_DIR", outputDir
environment "CARGOKIT_NDK_VERSION", ndkVersion
environment "CARGOKIT_SDK_DIR", sdkDirectory
environment "CARGOKIT_COMPILE_SDK_VERSION", compileSdkVersion
environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion
environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",")
environment "CARGOKIT_JAVA_HOME", System.properties['java.home']
}
}
}
class CargoKitPlugin implements Plugin<Project> {
static String file;
private Plugin findFlutterPlugin(Project rootProject) {
_findFlutterPlugin(rootProject.childProjects)
}
private Plugin _findFlutterPlugin(Map projects) {
for (project in projects) {
for (plugin in project.value.getPlugins()) {
if (plugin.class.name == "com.flutter.gradle.FlutterPlugin" || plugin.class.name == "FlutterPlugin") {
return plugin;
}
}
def plugin = _findFlutterPlugin(project.value.childProjects);
if (plugin != null) {
return plugin;
}
}
return null;
}
@Override
void apply(Project project) {
def plugin = findFlutterPlugin(project.rootProject);
project.extensions.create("cargokit", CargoKitExtension)
if (plugin == null) {
print("Flutter plugin not found, CargoKit plugin will not be applied.")
return;
}
def cargoBuildDir = "${project.buildDir}/build"
// Determine if the project is an application or library
def isApplication = plugin.project.plugins.hasPlugin('com.android.application')
def variants = isApplication ? plugin.project.android.applicationVariants : plugin.project.android.libraryVariants
variants.all { variant ->
final buildType = variant.buildType.name
def cargoOutputDir = "${project.buildDir}/jniLibs/${buildType}";
def jniLibs = project.android.sourceSets.maybeCreate(buildType).jniLibs;
jniLibs.srcDir(new File(cargoOutputDir))
def List<String> platforms
try {
platforms = com.flutter.gradle.FlutterPluginUtils.getTargetPlatforms(project).collect()
} catch (Exception ignored) {
platforms = plugin.getTargetPlatforms().collect()
}
// Same thing addFlutterDependencies does in flutter.gradle
if (buildType == "debug") {
platforms.add("android-x86")
platforms.add("android-x64")
}
// The task name depends on plugin properties, which are not available
// at this point
project.getGradle().afterProject {
def taskName = "cargokitCargoBuild${project.cargokit.libname.capitalize()}${buildType.capitalize()}";
if (project.tasks.findByName(taskName)) {
return
}
if (plugin.project.android.ndkVersion == null) {
throw new GradleException("Please set 'android.ndkVersion' in 'app/build.gradle'.")
}
def task = project.tasks.create(taskName, CargoKitBuildTask.class) {
buildMode = variant.buildType.name
buildDir = cargoBuildDir
outputDir = cargoOutputDir
ndkVersion = plugin.project.android.ndkVersion
sdkDirectory = plugin.project.android.sdkDirectory
minSdkVersion = plugin.project.android.defaultConfig.minSdkVersion.apiLevel as int
compileSdkVersion = plugin.project.android.compileSdkVersion.substring(8) as int
targetPlatforms = platforms
pluginFile = CargoKitPlugin.file
}
def onTask = { newTask ->
if (newTask.name == "merge${buildType.capitalize()}NativeLibs") {
newTask.dependsOn task
// Fix gradle 7.4.2 not picking up JNI library changes
newTask.outputs.upToDateWhen { false }
}
}
project.tasks.each onTask
project.tasks.whenTaskAdded onTask
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
@echo off
setlocal
setlocal ENABLEDELAYEDEXPANSION
SET BASEDIR=%~dp0
if not exist "%CARGOKIT_TOOL_TEMP_DIR%" (
mkdir "%CARGOKIT_TOOL_TEMP_DIR%"
)
cd /D "%CARGOKIT_TOOL_TEMP_DIR%"
SET BUILD_TOOL_PKG_DIR=%BASEDIR%build_tool
SET DART=%FLUTTER_ROOT%\bin\cache\dart-sdk\bin\dart
set BUILD_TOOL_PKG_DIR_POSIX=%BUILD_TOOL_PKG_DIR:\=/%
(
echo name: build_tool_runner
echo version: 1.0.0
echo publish_to: none
echo.
echo environment:
echo sdk: '^>=3.0.0 ^<4.0.0'
echo.
echo dependencies:
echo build_tool:
echo path: %BUILD_TOOL_PKG_DIR_POSIX%
) >pubspec.yaml
if not exist bin (
mkdir bin
)
(
echo import 'package:build_tool/build_tool.dart' as build_tool;
echo void main^(List^<String^> args^) ^{
echo build_tool.runMain^(args^);
echo ^}
) >bin\build_tool_runner.dart
SET PRECOMPILED=bin\build_tool_runner.dill
REM To detect changes in package we compare output of DIR /s (recursive)
set PREV_PACKAGE_INFO=.dart_tool\package_info.prev
set CUR_PACKAGE_INFO=.dart_tool\package_info.cur
DIR "%BUILD_TOOL_PKG_DIR%" /s > "%CUR_PACKAGE_INFO%_orig"
REM Last line in dir output is free space on harddrive. That is bound to
REM change between invocation so we need to remove it
(
Set "Line="
For /F "UseBackQ Delims=" %%A In ("%CUR_PACKAGE_INFO%_orig") Do (
SetLocal EnableDelayedExpansion
If Defined Line Echo !Line!
EndLocal
Set "Line=%%A")
) >"%CUR_PACKAGE_INFO%"
DEL "%CUR_PACKAGE_INFO%_orig"
REM Compare current directory listing with previous
FC /B "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" > nul 2>&1
If %ERRORLEVEL% neq 0 (
REM Changed - copy current to previous and remove precompiled kernel
if exist "%PREV_PACKAGE_INFO%" (
DEL "%PREV_PACKAGE_INFO%"
)
MOVE /Y "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%"
if exist "%PRECOMPILED%" (
DEL "%PRECOMPILED%"
)
)
REM There is no CUR_PACKAGE_INFO it was renamed in previous step to %PREV_PACKAGE_INFO%
REM which means we need to do pub get and precompile
if not exist "%PRECOMPILED%" (
echo Running pub get in "%cd%"
"%DART%" pub get --no-precompile
"%DART%" compile kernel bin/build_tool_runner.dart
)
"%DART%" "%PRECOMPILED%" %*
REM 253 means invalid snapshot version.
If %ERRORLEVEL% equ 253 (
"%DART%" pub get --no-precompile
"%DART%" compile kernel bin/build_tool_runner.dart
"%DART%" "%PRECOMPILED%" %*
)
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -e
BASEDIR=$(dirname "$0")
mkdir -p "$CARGOKIT_TOOL_TEMP_DIR"
cd "$CARGOKIT_TOOL_TEMP_DIR"
# Write a very simple bin package in temp folder that depends on build_tool package
# from Cargokit. This is done to ensure that we don't pollute Cargokit folder
# with .dart_tool contents.
BUILD_TOOL_PKG_DIR="$BASEDIR/build_tool"
if [[ -z $FLUTTER_ROOT ]]; then # not defined
DART=dart
else
DART="$FLUTTER_ROOT/bin/cache/dart-sdk/bin/dart"
fi
cat << EOF > "pubspec.yaml"
name: build_tool_runner
version: 1.0.0
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
build_tool:
path: "$BUILD_TOOL_PKG_DIR"
EOF
mkdir -p "bin"
cat << EOF > "bin/build_tool_runner.dart"
import 'package:build_tool/build_tool.dart' as build_tool;
void main(List<String> args) {
build_tool.runMain(args);
}
EOF
# Create alias for `shasum` if it does not exist and `sha1sum` exists
if ! [ -x "$(command -v shasum)" ] && [ -x "$(command -v sha1sum)" ]; then
shopt -s expand_aliases
alias shasum="sha1sum"
fi
# Dart run will not cache any package that has a path dependency, which
# is the case for our build_tool_runner. So instead we precompile the package
# ourselves.
# To invalidate the cached kernel we use the hash of ls -LR of the build_tool
# package directory. This should be good enough, as the build_tool package
# itself is not meant to have any path dependencies.
if [[ "$OSTYPE" == "darwin"* ]]; then
PACKAGE_HASH=$(ls -lTR "$BUILD_TOOL_PKG_DIR" | shasum)
else
PACKAGE_HASH=$(ls -lR --full-time "$BUILD_TOOL_PKG_DIR" | shasum)
fi
PACKAGE_HASH_FILE=".package_hash"
if [ -f "$PACKAGE_HASH_FILE" ]; then
EXISTING_HASH=$(cat "$PACKAGE_HASH_FILE")
if [ "$PACKAGE_HASH" != "$EXISTING_HASH" ]; then
rm "$PACKAGE_HASH_FILE"
fi
fi
# Run pub get if needed.
if [ ! -f "$PACKAGE_HASH_FILE" ]; then
"$DART" pub get --no-precompile
"$DART" compile kernel bin/build_tool_runner.dart
echo "$PACKAGE_HASH" > "$PACKAGE_HASH_FILE"
fi
# Rebuild the tool if it was deleted by Android Studio
if [ ! -f "bin/build_tool_runner.dill" ]; then
"$DART" compile kernel bin/build_tool_runner.dart
fi
set +e
"$DART" bin/build_tool_runner.dill "$@"
exit_code=$?
# 253 means invalid snapshot version.
if [ $exit_code == 253 ]; then
"$DART" pub get --no-precompile
"$DART" compile kernel bin/build_tool_runner.dart
"$DART" bin/build_tool_runner.dill "$@"
exit_code=$?
fi
exit $exit_code
+26
View File
@@ -0,0 +1,26 @@
# kolibri examples
Runnable, self-contained scripts. Build the native library once, then point
`KOLIBRI_LIB` at it:
```bash
cargo build --manifest-path rust/Cargo.toml
export KOLIBRI_LIB="$PWD/rust/target/debug/libkolibri_dart.dylib" # .so / .dll elsewhere
```
| File | What it shows |
| --- | --- |
| [`example.dart`](example.dart) | Open a session, run the handshake, listen for pushes |
| [`handshake.dart`](handshake.dart) | The bare `connect()` / `disconnect()` lifecycle |
| [`upload.dart`](upload.dart) | Media upload as a `Stream<UploadEvent>` (progress → done/error) |
| [`call.dart`](call.dart) | Call signaling over a local WebSocket (no network needed) |
| [`fingerprint.dart`](fingerprint.dart) | Build the 96-byte anti-spoof `authMode` fingerprint |
Run any of them with:
```bash
dart run example/example.dart
```
`call.dart` and `upload.dart` spin up a local server and need no network access;
`example.dart` and `handshake.dart` connect to a real host.
+48
View File
@@ -0,0 +1,48 @@
import 'dart:convert';
import 'dart:io';
import 'package:kolibri/kolibri.dart';
Future<void> main() async {
final lib = Platform.environment['KOLIBRI_LIB'] ??
'rust/target/debug/libkolibri_dart.dylib';
await initKolibri(libraryPath: lib);
var gotPong = false;
final server = await HttpServer.bind('127.0.0.1', 0);
server.listen((req) async {
final ws = await WebSocketTransformer.upgrade(req);
ws.add('ping');
ws.listen((msg) {
if (msg == 'pong') {
gotPong = true;
return;
}
final v = jsonDecode(msg as String);
ws.add(jsonEncode(
{'sequence': v['sequence'], 'response': v['command'], 'type': 'response'}));
if (v['command'] == 'accept-call') {
ws.add(jsonEncode(
{'type': 'notification', 'notification': 'connection', 'topology': 'P2P'}));
}
});
});
final sig = await connectCallSignaling(url: 'ws://127.0.0.1:${server.port}/ws2');
final notifs = <String>[];
final sub = sig.notifications().listen(notifs.add);
print('accept-call : ${await sig.acceptCall()}');
await sig.transmitSdp(participantId: 42, sdpType: 'offer', sdp: 'v=0...');
await Future<void>.delayed(const Duration(milliseconds: 300));
print('notification: ${notifs.isNotEmpty ? notifs.first : "(none)"}');
print('got pong : $gotPong');
print('connected : ${sig.isConnected()}');
await sub.cancel();
sig.close();
await server.close(force: true);
exit(0);
}
+35
View File
@@ -0,0 +1,35 @@
import 'dart:io';
import 'package:kolibri/kolibri.dart';
/// Minimal end-to-end example: load the native library, open a session,
/// perform the handshake, then disconnect.
///
/// On Flutter you would omit `libraryPath` — the plugin bundles the native
/// library and `flutter_rust_bridge` finds it automatically. For `dart run`
/// point `KOLIBRI_LIB` at a library you built with
/// `cargo build --manifest-path rust/Cargo.toml`.
Future<void> main() async {
final libPath = Platform.environment['KOLIBRI_LIB'] ??
'rust/target/debug/libkolibri_dart.dylib';
await initKolibri(libraryPath: libPath);
final session = openSession(host: 'api.oneme.ru');
print('state: ${session.state()}');
final info = await session.connect(); // sessionInit handshake
print('state : ${session.state()}');
print('calls_seed : ${info.callsSeed}');
print('device_name : ${info.deviceName}');
print('payload : ${info.payload.length} bytes of msgpack');
// Server pushes arrive on a Stream; decode payloads to maps with pushesMap().
final sub = session.pushesMap().listen((push) {
final (opcode, payload) = push;
print('push $opcode: $payload');
});
session.disconnect();
await sub.cancel();
print('state: ${session.state()}');
}
+10
View File
@@ -0,0 +1,10 @@
import 'dart:io';
import 'package:kolibri/kolibri.dart';
Future<void> main() async {
final lib = Platform.environment['KOLIBRI_LIB'] ??
'rust/target/debug/libkolibri_dart.dylib';
await initKolibri(libraryPath: lib);
final m = authMode(5091188991553007784, 'd1e9c0de-0000-4000-8000-kolibri0001');
print(m.map((b) => b.toRadixString(16).padLeft(2, '0')).join());
}
+21
View File
@@ -0,0 +1,21 @@
import 'dart:io';
import 'package:kolibri/kolibri.dart';
Future<void> main() async {
final libPath = Platform.environment['KOLIBRI_LIB'] ??
'rust/target/debug/libkolibri_dart.dylib';
await initKolibri(libraryPath: libPath);
final session = openSession(host: 'api.oneme.ru');
print('state: ${session.state()}');
final info = await session.connect();
print('state: ${session.state()}');
print('calls_seed : ${info.callsSeed}');
print('device_name : ${info.deviceName}');
print('payload : ${info.payload.length} bytes of msgpack');
session.disconnect();
print('state: ${session.state()}');
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:io';
import 'package:kolibri/kolibri.dart';
Future<void> main() async {
final libPath = Platform.environment['KOLIBRI_LIB'] ??
'rust/target/debug/libkolibri_dart.dylib';
await initKolibri(libraryPath: libPath);
final server = await HttpServer.bind('127.0.0.1', 0);
server.listen((req) async {
await req.drain<void>();
req.response.statusCode = 200;
req.response.write('ok');
await req.response.close();
});
final session = openSession(host: 'api.oneme.ru');
final data = List<int>.filled(200000, 0x58);
var progressCount = 0;
await for (final event in session.uploadFile(
url: 'http://127.0.0.1:${server.port}/up',
data: data,
filename: 'clip.bin',
)) {
switch (event) {
case UploadEvent_Progress(:final sent, :final total):
progressCount++;
print('progress: $sent / $total');
case UploadEvent_Done(:final status, :final body):
print('done: status=$status body=${String.fromCharCodes(body)}');
case UploadEvent_Error(:final message):
print('error: $message');
}
}
print('progress events: $progressCount');
await server.close();
session.disconnect();
}
+3
View File
@@ -0,0 +1,3 @@
rust_input: crate::api
rust_root: rust/
dart_output: lib/src/rust
+1
View File
@@ -0,0 +1 @@
// This is an empty file to force CocoaPods to create a framework.
+46
View File
@@ -0,0 +1,46 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint kolibri_dart.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'kolibri'
s.version = '0.0.1'
s.summary = 'A new Flutter FFI plugin project.'
s.description = <<-DESC
A new Flutter FFI plugin project.
DESC
s.homepage = 'http://example.com'
s.license = { :file => '../LICENSE' }
s.author = { 'Your Company' => 'email@example.com' }
s.module_name = 'kolibri_dart'
# This will ensure the source files in Classes/ are included in the native
# builds of apps using this FFI plugin. Podspec does not support relative
# paths, so Classes contains a forwarder C file that relatively imports
# `../src/*` so that the C sources can be shared among all target platforms.
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'Flutter'
s.platform = :ios, '11.0'
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
s.swift_version = '5.0'
s.script_phase = {
:name => 'Build Rust library',
# First argument is relative path to the `rust` folder, second is name of rust library
:script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust kolibri_dart',
:execution_position => :before_compile,
:input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'],
# Let XCode know that the static library referenced in -force_load below is
# created by this build step.
:output_files => ["${PODS_CONFIGURATION_BUILD_DIR}/kolibri_dart/libkolibri_dart.a"],
}
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
# Flutter.framework does not contain a i386 slice.
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
'OTHER_LDFLAGS' => '-force_load ${PODS_CONFIGURATION_BUILD_DIR}/kolibri_dart/libkolibri_dart.a',
}
end
@@ -0,0 +1,2 @@
/target
Cargo.lock
+50
View File
@@ -0,0 +1,50 @@
[package]
name = "kolibri-net"
version = "0.1.2"
edition = "2021"
description = "Qlyra binary protocol core: packet codec, framing, compression, transport"
license = "MIT OR Apache-2.0"
readme = "README.md"
[lib]
name = "kolibri_net"
[features]
default = ["transport", "calls"]
transport = ["dep:tokio", "dep:tokio-rustls", "dep:rustls", "dep:webpki-roots", "dep:rustls-pemfile"]
calls = [
"transport",
"json",
"dep:tokio-tungstenite",
"dep:futures-util",
]
json = ["dep:serde_json", "dep:base64"]
[dependencies]
rmpv = "1"
lz4_flex = { version = "0.11", features = ["frame"] }
zstd = "0.13"
thiserror = "2"
sha2 = "0.10"
tokio = { version = "1", features = ["net", "io-util", "fs", "rt", "sync", "time", "macros"], optional = true }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"], optional = true }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"], optional = true }
webpki-roots = { version = "0.26", optional = true }
rustls-pemfile = { version = "2", optional = true }
tokio-tungstenite = { version = "0.26", default-features = false, features = ["connect", "rustls-tls-webpki-roots"], optional = true }
futures-util = { version = "0.3", default-features = false, features = ["sink"], optional = true }
serde_json = { version = "1", optional = true }
base64 = { version = "0.22", optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
rcgen = "0.13"
rmpv = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
sha2 = "0.10"
tokio-tungstenite = "0.26"
futures-util = "0.3"
serde_json = "1"
lz4_flex = { version = "0.11", features = ["frame"] }
base64 = "0.22"
+97
View File
@@ -0,0 +1,97 @@
# kolibri-net
Reusable Rust core of the Qlyra messaging protocol — a hand-rolled binary
framing over a persistent TLS TCP socket, with MessagePack payloads and LZ4/Zstd
compression. Extracted from the Flutter client (`lib/core/transport/` +
`lib/core/protocol/`) so the same protocol implementation can be shared across
Flutter (via `flutter_rust_bridge`), Python (via PyO3/maturin), and any other
host over a C ABI.
## Status
| Phase | Scope | State |
|-------|-------|-------|
| **1** | Protocol core: packet codec, framing, compression, opcodes | ✅ done |
| **2** | Async transport: tokio TCP + TLS (rustls), seq/opcode dispatcher | ✅ done |
| **3** | Session state machine: handshake, ping keepalive, reconnect+backoff | ✅ done |
| **4** | FFI: Python (`pyo3`/`maturin`) ✅ · Dart (`flutter_rust_bridge`) planned | 🚧 |
| 5 | Swap Dart transport behind a flag, live-compare, remove | planned |
Proxy: `ClientConfig::proxy` takes a `ProxyConfig` (HTTP CONNECT or SOCKS5, with
optional user/pass), applied to the main socket, media uploads, and ws2 calls.
Parse one from a url with `ProxyConfig::parse("http://user:pass@host:port")`
(`http` / `socks5` / `socks5h`).
Not yet ported: VPN-bypass (Android). Production TLS uses the bundled Mozilla
root store — swap for `rustls-platform-verifier` to match Dart's OS trust store.
## Wire format (10-byte big-endian header)
```text
[0] ver protocol version (u8, = 10)
[1] cmd 0 request/push · 1 ok · 2 not_found · 3 error
[2..4] seq sequence number (u16 BE)
[4..6] opcode operation code (u16 BE)
[6..10] packedLen high byte = compression flag, low 24 bits = payload length
[10..] payload MessagePack, LZ4-block / LZ4-frame / Zstd (sniffed by magic)
```
Payloads under 32 bytes are sent uncompressed. Outgoing compression is LZ4
frame; incoming is sniffed by magic number (Zstd `28 B5 2F FD`, LZ4 frame
`04 22 4D 18`, otherwise LZ4 block).
## Design
The core is I/O-free and representation-agnostic: `encode`/`decode` work on raw
MessagePack bytes so a Dart `Map`, a Python `dict`, and a Rust struct all come
from the same `Packet.payload`. `PacketReceiver` de-frames the raw TLS byte
stream into complete packets.
```rust
use kolibri_net::{encode, decode, protocol::opcodes};
let wire = encode(opcodes::MSG_SEND, &msgpack_bytes, seq);
let packet = decode(&wire)?;
let value = packet.value()?; // rmpv::Value
```
## Build & test
```bash
cargo test # 14 protocol vectors + 5 transport + 6 session + 1 backoff
cargo clippy --all-targets
```
The `transport` feature (async client, on by default) can be disabled to build
the pure protocol codec with no tokio/rustls dependency:
```bash
cargo build --no-default-features
```
### Transport usage
```rust
use kolibri_net::{Client, ClientConfig, protocol::opcodes};
let client = Client::connect(ClientConfig::new("host.example", 443)).await?;
let mut pushes = client.subscribe(); // broadcast of server pushes
let resp = client.request(opcodes::CHATS_LIST, &msgpack_bytes).await?;
```
### Session usage (handshake + keepalive + reconnect)
```rust
use kolibri_net::{Session, SessionConfig, ClientConfig, HandshakeConfig, UserAgent};
let config = SessionConfig::new(
ClientConfig::new("host.example", 443),
HandshakeConfig { /* device values from the host */ },
);
let session = Session::new(config);
let info = session.connect().await?; // connect + sessionInit → Online
let resp = session.request(opcodes::AUTH_REQUEST, &msgpack_bytes).await?;
```
The host (Flutter, Python, …) supplies device values for the handshake; the wire
shape and the connect → handshake → ping → reconnect sequence live in Rust.
+176
View File
@@ -0,0 +1,176 @@
//! WARNING: sends a REAL SMS to the given number via api.oneme.ru.
//!
//! cargo run --example auth_request -- +7XXXXXXXXXX
//!
//! Override device identity with KOLIBRI_DEVICE_ID / KOLIBRI_INSTANCE_ID for a
//! fresh install fingerprint.
use std::time::Duration;
use kolibri_net::protocol::opcodes;
use kolibri_net::{ClientConfig, HandshakeConfig, Session, SessionConfig, UserAgent};
use rmpv::Value;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufReadExt, BufReader};
const HOST: &str = "api.oneme.ru";
const PORT: u16 = 443;
const APP_VERSION: &str = "26.20.2";
const BUILD_NUMBER: i64 = 6758;
// APK signature / dex / so digests for the anti-spoof `mode` fingerprint
// (from ChatCacheFingerprint)
const SIGNATURE_DIGEST: &str = "1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93";
const SO_DIGEST: &str = "90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d";
const DEX_DIGEST: &str = "0a6265f6e5d8231b9cba641f8c40475e6f3baeb06ed41b804b9bf7307aa4214e";
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let phone = match std::env::args().nth(1) {
Some(p) => normalize_phone(&p),
None => {
eprintln!("usage: cargo run --example auth_request -- +7XXXXXXXXXX");
std::process::exit(2);
}
};
let device_id = std::env::var("KOLIBRI_DEVICE_ID")
.unwrap_or_else(|_| "d1e9c0de-0000-4000-8000-kolibri0001".to_string());
let instance_id = std::env::var("KOLIBRI_INSTANCE_ID")
.unwrap_or_else(|_| "i1e9c0de-0000-4000-8000-kolibri0001".to_string());
let handshake = HandshakeConfig {
instance_id,
device_id: device_id.clone(),
client_session_id: 1_700_000_000,
user_agent: UserAgent {
device_type: "ANDROID".to_string(),
app_version: APP_VERSION.to_string(),
os_version: "Android 14".to_string(),
timezone: "Europe/Moscow".to_string(),
screen: "420dpi 420dpi 1080x2340".to_string(),
push_device_type: "GCM".to_string(),
arch: "arm64-v8a".to_string(),
locale: "ru".to_string(),
build_number: BUILD_NUMBER,
device_name: "Xiaomi 23127PN0CG".to_string(),
device_locale: "ru".to_string(),
is_pwa: None,
header_user_agent: None,
},
};
let mut config = SessionConfig::new(ClientConfig::new(HOST, PORT), handshake);
config.ping_interval = Duration::from_secs(10);
config.auto_reconnect = false;
let session = Session::new(config);
println!("→ connecting to {HOST}:{PORT}");
let info = session.connect().await?;
println!(
"✓ online. callsSeed={:?} device_name={:?}",
info.calls_seed, info.device_name
);
let calls_seed = info
.calls_seed
.ok_or("server did not return callsSeed in handshake")?;
let mode = compute_mode(calls_seed, &device_id);
let request = Value::Map(vec![
(Value::from("phone"), Value::from(phone.clone())),
(Value::from("type"), Value::from("START_AUTH")),
(Value::from("language"), Value::from("ru")),
(Value::from("mode"), Value::Binary(mode)),
]);
println!("→ requesting OTP code for {}", mask_phone(&phone));
let response = session
.request(opcodes::AUTH_REQUEST, &encode(&request))
.await?;
if !response.is_ok() {
return Err(format!("authRequest not ok (cmd={})", response.cmd).into());
}
let payload = response.value()?;
let token = map_str(&payload, "token").ok_or("no token in authRequest response")?;
println!("✓ code sent. temp token = {token}");
print!("Enter the SMS code (blank to skip): ");
use std::io::Write;
std::io::stdout().flush().ok();
let mut line = String::new();
BufReader::new(tokio::io::stdin())
.read_line(&mut line)
.await?;
let code = line.trim();
if code.is_empty() {
println!("skipped verification.");
return Ok(());
}
let verify = Value::Map(vec![
(Value::from("token"), Value::from(token)),
(Value::from("verifyCode"), Value::from(code)),
(Value::from("authTokenType"), Value::from("CHECK_CODE")),
]);
println!("→ verifying code …");
let vresp = session.request(opcodes::AUTH, &encode(&verify)).await?;
let vpayload = vresp.value()?;
println!("✓ verify response (cmd={}): {vpayload}", vresp.cmd);
Ok(())
}
fn normalize_phone(phone: &str) -> String {
let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect();
format!("+{digits}")
}
fn mask_phone(phone: &str) -> String {
if phone.len() <= 5 {
return "***".to_string();
}
format!("{}***{}", &phone[..3], &phone[phone.len() - 2..])
}
// three SHA-256 hashes of (digest || int64_be(callsSeed) || utf8(deviceId)),
// concatenated to 96 bytes
fn compute_mode(calls_seed: i64, device_id: &str) -> Vec<u8> {
let seed = calls_seed.to_be_bytes();
let dev = device_id.as_bytes();
let mut out = Vec::with_capacity(96);
out.extend(sha256_of(&[&hex(SIGNATURE_DIGEST), &seed, dev]));
out.extend(sha256_of(&[&hex(DEX_DIGEST), &seed, dev]));
out.extend(sha256_of(&[&hex(SO_DIGEST), &seed, dev]));
out
}
fn sha256_of(parts: &[&[u8]]) -> Vec<u8> {
let mut hasher = Sha256::new();
for p in parts {
hasher.update(p);
}
hasher.finalize().to_vec()
}
fn hex(s: &str) -> Vec<u8> {
(0..s.len() / 2)
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap())
.collect()
}
fn encode(value: &Value) -> Vec<u8> {
let mut out = Vec::new();
rmpv::encode::write_value(&mut out, value).unwrap();
out
}
fn map_str(value: &Value, key: &str) -> Option<String> {
value
.as_map()?
.iter()
.find(|(k, _)| k.as_str() == Some(key))
.and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
}
+59
View File
@@ -0,0 +1,59 @@
//! Verify an OTP code using the temp token from the `auth_request` example.
//!
//! cargo run --example auth_verify -- <TOKEN> <CODE>
use std::time::Duration;
use kolibri_net::protocol::opcodes;
use kolibri_net::{ClientConfig, HandshakeConfig, Session, SessionConfig, UserAgent};
use rmpv::Value;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let token = args.next().ok_or("usage: auth_verify -- <TOKEN> <CODE>")?;
let code = args.next().ok_or("usage: auth_verify -- <TOKEN> <CODE>")?;
let handshake = HandshakeConfig {
instance_id: "i1e9c0de-0000-4000-8000-kolibri0001".to_string(),
device_id: "d1e9c0de-0000-4000-8000-kolibri0001".to_string(),
client_session_id: 1_700_000_000,
user_agent: UserAgent {
device_type: "ANDROID".to_string(),
app_version: "26.20.2".to_string(),
os_version: "Android 14".to_string(),
timezone: "Europe/Moscow".to_string(),
screen: "420dpi 420dpi 1080x2340".to_string(),
push_device_type: "GCM".to_string(),
arch: "arm64-v8a".to_string(),
locale: "ru".to_string(),
build_number: 6758,
device_name: "Xiaomi 23127PN0CG".to_string(),
device_locale: "ru".to_string(),
is_pwa: None,
header_user_agent: None,
},
};
let mut config = SessionConfig::new(ClientConfig::new("api.oneme.ru", 443), handshake);
config.auto_reconnect = false;
println!("→ connecting …");
let session = Session::new(config);
tokio::time::timeout(Duration::from_secs(20), session.connect()).await??;
println!("✓ online, verifying code …");
let verify = Value::Map(vec![
(Value::from("token"), Value::from(token)),
(Value::from("verifyCode"), Value::from(code)),
(Value::from("authTokenType"), Value::from("CHECK_CODE")),
]);
let mut buf = Vec::new();
rmpv::encode::write_value(&mut buf, &verify).unwrap();
let resp = session.request(opcodes::AUTH, &buf).await?;
println!("✓ response (cmd={}):\n{}", resp.cmd, resp.value()?);
session.disconnect();
Ok(())
}
+48
View File
@@ -0,0 +1,48 @@
//! Runs the sessionInit handshake against production and disconnects. No SMS,
//! no account action.
//!
//! cargo run --example handshake
use std::time::Duration;
use kolibri_net::{ClientConfig, HandshakeConfig, Session, SessionConfig, UserAgent};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handshake = HandshakeConfig {
instance_id: "i1e9c0de-0000-4000-8000-kolibri0001".to_string(),
device_id: "d1e9c0de-0000-4000-8000-kolibri0001".to_string(),
client_session_id: 1_700_000_000,
user_agent: UserAgent {
device_type: "ANDROID".to_string(),
app_version: "26.20.2".to_string(),
os_version: "Android 14".to_string(),
timezone: "Europe/Moscow".to_string(),
screen: "420dpi 420dpi 1080x2340".to_string(),
push_device_type: "GCM".to_string(),
arch: "arm64-v8a".to_string(),
locale: "ru".to_string(),
build_number: 6758,
device_name: "Xiaomi 23127PN0CG".to_string(),
device_locale: "ru".to_string(),
is_pwa: None,
header_user_agent: None,
},
};
let mut config = SessionConfig::new(ClientConfig::new("api.oneme.ru", 443), handshake);
config.auto_reconnect = false;
println!("→ connecting to api.oneme.ru:443 …");
let session = Session::new(config);
let info = tokio::time::timeout(Duration::from_secs(20), session.connect()).await??;
println!("✓ handshake OK");
println!(" callsSeed = {:?}", info.calls_seed);
println!(" device_name = {:?}", info.device_name);
println!(" full payload = {}", info.payload);
session.disconnect();
Ok(())
}
@@ -0,0 +1,36 @@
//! TLS handshake probe against api2.oneme.ru (Минцифры-signed chain): off should
//! fail on the cert, on should verify.
//!
//! cargo run --example mincifry_probe
use kolibri_net::{set_trust_mincifry_ca, Client, ClientConfig};
async fn probe(label: &str) -> Result<(), String> {
let cfg = ClientConfig::new("api2.oneme.ru", 443);
match Client::connect(cfg).await {
Ok(_) => {
println!("[{label}] TLS handshake OK — cert verified");
Ok(())
}
Err(e) => {
println!("[{label}] failed: {e}");
Err(e.to_string())
}
}
}
#[tokio::main]
async fn main() {
set_trust_mincifry_ca(false);
println!("== flag OFF (expect cert failure) ==");
let off = probe("off").await;
set_trust_mincifry_ca(true);
println!("== flag ON (expect success) ==");
let on = probe("on").await;
println!("\nresult: off={:?}, on={:?}", off.is_err(), on.is_ok());
assert!(off.is_err(), "expected verification to FAIL without the CA");
assert!(on.is_ok(), "expected verification to SUCCEED with the CA");
println!("PASS: Минцифры CA is exactly what lets the handshake verify");
}
+59
View File
@@ -0,0 +1,59 @@
//! Anti-spoof fingerprint for the auth flow (`mode` in authRequest,
//! `chatCacheFingerprint` in login).
//!
//! the three digests (APK signature/dex/native-lib hashes) come from the caller,
//! not baked in, so they can change per app version or flavor.
use sha2::{Digest, Sha256};
/// 96-byte fingerprint: three SHA-256 of `digest || int64_be(calls_seed) ||
/// utf8(device_id)`, concatenated in signature/dex/so order.
pub fn chat_cache_fingerprint(
signature_digest: &[u8],
dex_digest: &[u8],
so_digest: &[u8],
calls_seed: i64,
device_id: &str,
) -> Vec<u8> {
let seed = calls_seed.to_be_bytes();
let device = device_id.as_bytes();
let mut out = Vec::with_capacity(96);
out.extend_from_slice(&hash(signature_digest, &seed, device));
out.extend_from_slice(&hash(dex_digest, &seed, device));
out.extend_from_slice(&hash(so_digest, &seed, device));
out
}
fn hash(prefix: &[u8], seed: &[u8], device: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(prefix);
hasher.update(seed);
hasher.update(device);
hasher.finalize().into()
}
#[cfg(test)]
mod tests {
use super::chat_cache_fingerprint;
#[test]
fn fingerprint_is_96_bytes_and_deterministic() {
let (sig, dex, so) = ([1u8; 32], [2u8; 32], [3u8; 32]);
let a = chat_cache_fingerprint(&sig, &dex, &so, 12345, "dev-abc");
let b = chat_cache_fingerprint(&sig, &dex, &so, 12345, "dev-abc");
assert_eq!(a.len(), 96);
assert_eq!(a, b);
}
#[test]
fn fingerprint_varies_with_inputs() {
let (sig, dex, so) = ([1u8; 32], [2u8; 32], [3u8; 32]);
let base = chat_cache_fingerprint(&sig, &dex, &so, 1, "dev");
assert_ne!(base, chat_cache_fingerprint(&sig, &dex, &so, 2, "dev"));
assert_ne!(base, chat_cache_fingerprint(&sig, &dex, &so, 1, "dev2"));
assert_ne!(
base,
chat_cache_fingerprint(&[9u8; 32], &dex, &so, 1, "dev")
);
}
}
+233
View File
@@ -0,0 +1,233 @@
use serde_json::Value;
use super::vcp::IceServer;
/// ws2 `connection` notification: topology, participants, and ICE servers
/// (from `conversationParams`, authoritative over the vcp).
#[derive(Debug, Clone)]
pub struct ConnectionInfo {
pub topology: Option<String>,
pub participants: Vec<i64>,
pub ice_servers: Vec<IceServer>,
}
impl ConnectionInfo {
pub fn is_sfu(&self) -> bool {
self.topology.as_deref() == Some("SERVER")
}
/// the participant that isn't us, the peer to answer.
pub fn peer_of(&self, my_user_id: i64) -> Option<i64> {
self.participants
.iter()
.copied()
.find(|&id| id != my_user_id)
}
}
/// payload of a `transmitted-data` notification: SDP offer/answer or ICE candidate.
#[derive(Debug, Clone)]
pub enum TransmittedData {
Sdp {
sdp_type: String,
sdp: String,
},
Candidate {
candidate: String,
sdp_mid: Option<String>,
sdp_mline_index: Option<i64>,
},
}
/// categorised ws2 notification.
#[derive(Debug, Clone)]
pub enum CallEvent {
Connection(ConnectionInfo),
Transmitted(TransmittedData),
Hungup,
Closed,
TopologyChanged(Option<String>),
Error(String),
Other(String),
}
impl CallEvent {
pub fn parse(value: &Value) -> CallEvent {
if value.get("type").and_then(|t| t.as_str()) == Some("error") {
let msg = value
.get("error")
.map(|e| e.to_string())
.unwrap_or_default();
return CallEvent::Error(msg);
}
let name = value
.get("notification")
.and_then(|n| n.as_str())
.unwrap_or("");
match name {
"connection" => CallEvent::Connection(parse_connection(value)),
"transmitted-data" => match parse_transmitted_data(value) {
Some(td) => CallEvent::Transmitted(td),
None => CallEvent::Other(name.to_string()),
},
"hungup" => CallEvent::Hungup,
"closed-conversation" => CallEvent::Closed,
"topology-changed" => CallEvent::TopologyChanged(topology_of(value)),
other => CallEvent::Other(other.to_string()),
}
}
}
pub fn parse_connection(value: &Value) -> ConnectionInfo {
let conversation = value.get("conversation");
ConnectionInfo {
topology: conversation
.and_then(|c| c.get("topology"))
.and_then(|t| t.as_str())
.map(|s| s.to_string()),
participants: conversation
.and_then(|c| c.get("participants"))
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter_map(|p| p.get("id").and_then(|i| i.as_i64()))
.collect()
})
.unwrap_or_default(),
ice_servers: ice_from_conversation_params(value.get("conversationParams")),
}
}
/// `transmitted-data` notification, either an SDP or a candidate.
pub fn parse_transmitted_data(value: &Value) -> Option<TransmittedData> {
let data = value.get("data")?;
if let Some(sdp) = data.get("sdp") {
return Some(TransmittedData::Sdp {
sdp_type: sdp.get("type")?.as_str()?.to_string(),
sdp: sdp.get("sdp")?.as_str()?.to_string(),
});
}
if let Some(c) = data.get("candidate") {
return Some(TransmittedData::Candidate {
candidate: c.get("candidate")?.as_str()?.to_string(),
sdp_mid: c
.get("sdpMid")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
sdp_mline_index: c.get("sdpMLineIndex").and_then(|v| v.as_i64()),
});
}
None
}
fn topology_of(value: &Value) -> Option<String> {
value
.get("conversation")
.and_then(|c| c.get("topology"))
.and_then(|t| t.as_str())
.map(|s| s.to_string())
}
fn ice_from_conversation_params(cp: Option<&Value>) -> Vec<IceServer> {
let mut servers = Vec::new();
let Some(cp) = cp else {
return servers;
};
for kind in ["stun", "turn"] {
let Some(entry) = cp.get(kind).and_then(|v| v.as_object()) else {
continue;
};
let urls: Vec<String> = match entry.get("urls") {
Some(Value::Array(a)) => a
.iter()
.filter_map(|u| u.as_str().map(String::from))
.collect(),
Some(Value::String(u)) => vec![u.clone()],
_ => continue,
};
if urls.is_empty() {
continue;
}
servers.push(IceServer {
urls,
username: entry
.get("username")
.and_then(|v| v.as_str())
.map(String::from),
credential: entry
.get("credential")
.and_then(|v| v.as_str())
.map(String::from),
});
}
servers
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_connection() {
let n = json!({
"type": "notification",
"notification": "connection",
"conversation": {
"topology": "DIRECT",
"participants": [{"id": 42}, {"id": 99}],
},
"conversationParams": {
"stun": {"urls": ["stun:s:3478"]},
"turn": {"urls": ["turn:t:3478"], "username": "u", "credential": "p"},
},
});
let info = parse_connection(&n);
assert_eq!(info.topology.as_deref(), Some("DIRECT"));
assert_eq!(info.participants, vec![42, 99]);
assert!(!info.is_sfu());
assert_eq!(info.peer_of(42), Some(99));
assert_eq!(info.ice_servers.len(), 2);
assert_eq!(info.ice_servers[1].username.as_deref(), Some("u"));
}
#[test]
fn parses_transmitted_sdp_and_candidate() {
let offer = json!({"notification": "transmitted-data",
"data": {"sdp": {"type": "offer", "sdp": "v=0..."}}});
match parse_transmitted_data(&offer) {
Some(TransmittedData::Sdp { sdp_type, sdp }) => {
assert_eq!(sdp_type, "offer");
assert_eq!(sdp, "v=0...");
}
other => panic!("expected sdp, got {other:?}"),
}
let cand = json!({"notification": "transmitted-data",
"data": {"candidate": {"candidate": "candidate:1 ...", "sdpMid": "0", "sdpMLineIndex": 0}}});
match parse_transmitted_data(&cand) {
Some(TransmittedData::Candidate {
candidate,
sdp_mid,
sdp_mline_index,
}) => {
assert!(candidate.starts_with("candidate:"));
assert_eq!(sdp_mid.as_deref(), Some("0"));
assert_eq!(sdp_mline_index, Some(0));
}
other => panic!("expected candidate, got {other:?}"),
}
}
#[test]
fn categorises_events() {
assert!(matches!(
CallEvent::parse(&json!({"notification": "hungup"})),
CallEvent::Hungup
));
assert!(matches!(
CallEvent::parse(&json!({"type": "error", "error": "boom"})),
CallEvent::Error(_)
));
}
}
+28
View File
@@ -0,0 +1,28 @@
//! Call setup and signaling. The main protocol socket only bootstraps a call
//! (opcode 78/166 hand back a `vcp`/endpoint, push 137 announces an incoming
//! one); everything live runs on a separate ws2 WebSocket. WebRTC media stays in
//! the host.
mod events;
mod signaling;
mod vcp;
use thiserror::Error;
pub use events::{
parse_connection, parse_transmitted_data, CallEvent, ConnectionInfo, TransmittedData,
};
pub use signaling::{Ws2Signaling, DEFAULT_USER_AGENT as DEFAULT_WS2_USER_AGENT};
pub use vcp::{ws2_url_from_endpoint, ConversationParams, IceServer, Ws2ClientInfo};
#[derive(Debug, Error)]
pub enum CallError {
#[error("websocket error: {0}")]
Ws(String),
#[error("command '{command}' failed: {error}")]
Command { command: String, error: String },
#[error("request timed out")]
Timeout,
#[error("connection closed")]
Closed,
}
+305
View File
@@ -0,0 +1,305 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::sync::{broadcast, mpsc, oneshot, watch};
use tokio::task::JoinHandle;
use tokio_tungstenite::client_async_tls_with_config;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::Connector;
use super::CallError;
use crate::transport::proxy::{connect_tcp, ProxyConfig};
use crate::transport::tls::build_client_config;
const NOTIF_CAPACITY: usize = 256;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
/// host + port out of a `ws://`/`wss://` url, for the proxy connect.
fn ws_host_port(url: &str) -> Result<(String, u16), CallError> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| CallError::Ws(format!("bad ws url: {url}")))?;
let default_port = if scheme == "wss" { 443 } else { 80 };
let authority = rest.split(['/', '?']).next().unwrap_or(rest);
let authority = authority.rsplit('@').next().unwrap_or(authority);
match authority.rsplit_once(':') {
Some((h, p)) if p.parse::<u16>().is_ok() => Ok((h.to_string(), p.parse().unwrap())),
_ => Ok((authority.to_string(), default_port)),
}
}
/// default ws2 User-Agent (the app's WebSocket lib). override via
/// [`Ws2Signaling::connect`].
pub const DEFAULT_USER_AGENT: &str = "okhttp/4.12.0";
/// Call signaling over the ws2 WebSocket: SDP offer/answer, ICE candidates,
/// accept/hangup, SFU negotiation, all as JSON.
///
/// envelope:
/// - request: `{"command": ..., ..., "sequence": N}`
/// - response: `{"sequence": N, "response": "<command>", "type": "response"}`
/// - notification: `{..., "notification": "<name>", "type": "notification"}`
/// - keepalive: text frame `ping`, answered with `pong`
pub struct Ws2Signaling {
seq: AtomicI64,
write_tx: mpsc::UnboundedSender<String>,
pending: Arc<Mutex<HashMap<i64, oneshot::Sender<Value>>>>,
notif_tx: broadcast::Sender<Value>,
connected_tx: watch::Sender<bool>,
tasks: Vec<JoinHandle<()>>,
}
impl Ws2Signaling {
pub async fn connect(url: &str, user_agent: Option<&str>) -> Result<Self, CallError> {
Self::connect_via(url, user_agent, None).await
}
/// like [`Ws2Signaling::connect`], but through `proxy` (HTTP CONNECT or
/// SOCKS5).
pub async fn connect_via(
url: &str,
user_agent: Option<&str>,
proxy: Option<&ProxyConfig>,
) -> Result<Self, CallError> {
let mut request = url
.into_client_request()
.map_err(|e| CallError::Ws(e.to_string()))?;
let ua = user_agent.unwrap_or(DEFAULT_USER_AGENT);
request.headers_mut().insert(
"User-Agent",
ua.parse()
.map_err(|_| CallError::Ws("invalid user agent".into()))?,
);
// own the TLS (both branches) so the Минцифры-CA flag reaches ws2;
// tungstenite's built-in connector only knows the Mozilla roots.
let (host, port) = ws_host_port(url)?;
let tcp = connect_tcp(&host, port, DEFAULT_TIMEOUT, proxy)
.await
.map_err(|e| CallError::Ws(e.to_string()))?;
let connector = Connector::Rustls(
build_client_config(false).map_err(|e| CallError::Ws(e.to_string()))?,
);
let ws = client_async_tls_with_config(request, tcp, None, Some(connector))
.await
.map_err(|e| CallError::Ws(e.to_string()))?
.0;
let (mut write, mut read) = ws.split();
let (write_tx, mut write_rx) = mpsc::unbounded_channel::<String>();
let (notif_tx, _) = broadcast::channel(NOTIF_CAPACITY);
let (connected_tx, _) = watch::channel(true);
let pending: Arc<Mutex<HashMap<i64, oneshot::Sender<Value>>>> =
Arc::new(Mutex::new(HashMap::new()));
let writer = tokio::spawn(async move {
while let Some(text) = write_rx.recv().await {
if write.send(Message::text(text)).await.is_err() {
break;
}
}
});
let reader_pending = pending.clone();
let reader_notif = notif_tx.clone();
let reader_write = write_tx.clone();
let reader_connected = connected_tx.clone();
let reader = tokio::spawn(async move {
while let Some(msg) = read.next().await {
let text = match msg {
Ok(Message::Text(t)) => t.as_str().to_string(),
Ok(Message::Binary(b)) => String::from_utf8_lossy(&b).into_owned(),
Ok(Message::Close(_)) | Err(_) => break,
_ => continue,
};
route(&text, &reader_pending, &reader_notif, &reader_write);
}
reader_connected.send_replace(false);
for (_, tx) in reader_pending.lock().unwrap().drain() {
drop(tx);
}
});
Ok(Self {
seq: AtomicI64::new(0),
write_tx,
pending,
notif_tx,
connected_tx,
tasks: vec![writer, reader],
})
}
/// server notifications (`type == "notification"`); filter on `notification`.
pub fn notifications(&self) -> broadcast::Receiver<Value> {
self.notif_tx.subscribe()
}
pub fn is_connected(&self) -> bool {
*self.connected_tx.borrow()
}
/// send a command, await the response; errors if the response carries one.
pub async fn send_command(&self, command: &str, extra: Value) -> Result<Value, CallError> {
if !self.is_connected() {
return Err(CallError::Closed);
}
let seq = self.seq.fetch_add(1, Ordering::Relaxed) + 1;
let mut obj = match extra {
Value::Object(m) => m,
_ => serde_json::Map::new(),
};
obj.insert("command".into(), json!(command));
obj.insert("sequence".into(), json!(seq));
let text = Value::Object(obj).to_string();
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(seq, tx);
self.write_tx.send(text).map_err(|_| CallError::Closed)?;
let resp = match tokio::time::timeout(DEFAULT_TIMEOUT, rx).await {
Ok(Ok(v)) => v,
Ok(Err(_)) => return Err(CallError::Closed),
Err(_) => return Err(CallError::Timeout),
};
let is_error = resp.get("type").and_then(|t| t.as_str()) == Some("error")
|| resp.get("error").is_some();
if is_error {
let error = resp
.get("error")
.map(|e| e.to_string())
.unwrap_or_else(|| "error".to_string());
return Err(CallError::Command {
command: command.to_string(),
error,
});
}
Ok(resp)
}
/// SDP offer/answer to another participant.
pub async fn transmit_sdp(
&self,
participant_id: i64,
sdp_type: &str,
sdp: &str,
) -> Result<Value, CallError> {
self.send_command(
"transmit-data",
json!({
"participantId": participant_id,
"participantType": "USER",
"deviceIdx": 0,
"data": { "sdp": { "type": sdp_type, "sdp": sdp } },
"capabilities": "1",
}),
)
.await
}
/// trickle ICE candidate to another participant.
pub async fn transmit_candidate(
&self,
participant_id: i64,
candidate: &str,
sdp_mid: &str,
sdp_mline_index: i64,
) -> Result<Value, CallError> {
self.send_command(
"transmit-data",
json!({
"participantId": participant_id,
"participantType": "USER",
"deviceIdx": 0,
"data": { "candidate": {
"candidate": candidate,
"sdpMid": sdp_mid,
"sdpMLineIndex": sdp_mline_index,
}},
}),
)
.await
}
pub async fn accept_call(&self) -> Result<Value, CallError> {
self.send_command("accept-call", json!({})).await
}
pub async fn hangup(&self, reason: &str) -> Result<Value, CallError> {
self.send_command("hangup", json!({ "reason": reason }))
.await
}
pub async fn change_media_settings(
&self,
audio: bool,
video: bool,
screen: bool,
) -> Result<Value, CallError> {
self.send_command(
"change-media-settings",
json!({ "mediaSettings": {
"isAudioEnabled": audio,
"isVideoEnabled": video,
"isScreenSharingEnabled": screen,
"isAnimojiEnabled": false,
}}),
)
.await
}
pub fn close(&self) {
self.connected_tx.send_replace(false);
for (_, tx) in self.pending.lock().unwrap().drain() {
drop(tx);
}
for task in &self.tasks {
task.abort();
}
}
}
impl Drop for Ws2Signaling {
fn drop(&mut self) {
self.close();
}
}
fn route(
text: &str,
pending: &Arc<Mutex<HashMap<i64, oneshot::Sender<Value>>>>,
notif_tx: &broadcast::Sender<Value>,
write_tx: &mpsc::UnboundedSender<String>,
) {
if text == "ping" {
let _ = write_tx.send("pong".to_string());
return;
}
let value: Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => return,
};
let ty = value.get("type").and_then(|t| t.as_str());
if ty == Some("response") || ty == Some("error") {
if let Some(seq) = value.get("sequence").and_then(|s| s.as_i64()) {
if let Some(tx) = pending.lock().unwrap().remove(&seq) {
let _ = tx.send(value.clone());
}
}
if ty == Some("error") {
let _ = notif_tx.send(value);
}
return;
}
if ty == Some("notification") || value.get("notification").is_some() {
let _ = notif_tx.send(value);
}
}
+239
View File
@@ -0,0 +1,239 @@
use base64::Engine;
use crate::protocol::compress::decompress_lz4_block;
/// ICE server in the shape a WebRTC stack expects.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IceServer {
pub urls: Vec<String>,
pub username: Option<String>,
pub credential: Option<String>,
}
/// Call connection params (`vcp`), sent in the incoming-call push (opcode 137)
/// and the outgoing-call response.
///
/// wire format: `<rawLen>:<base64(LZ4-block(JSON))>`, JSON keys are short.
#[derive(Debug, Clone)]
pub struct ConversationParams {
pub token: String,
pub ws_endpoint: String,
pub ws_ips: Vec<String>,
pub wt_endpoint: Option<String>,
pub calls_api_endpoint: Option<String>,
pub client_type: Option<String>,
pub expires_at: Option<i64>,
pub stun: Option<String>,
pub turn: Vec<String>,
pub turn_user: Option<String>,
pub turn_password: Option<String>,
pub is_video: bool,
}
impl ConversationParams {
pub fn decode(vcp: &str) -> Option<Self> {
let sep = vcp.find(':')?;
if sep == 0 {
return None;
}
let raw_len: usize = vcp[..sep].parse().ok()?;
if raw_len == 0 {
return None;
}
let compressed = base64::engine::general_purpose::STANDARD
.decode(&vcp[sep + 1..])
.ok()?;
let decompressed = decompress_lz4_block(&compressed, raw_len).ok()?;
let bytes = if decompressed.len() > raw_len {
&decompressed[..raw_len]
} else {
&decompressed[..]
};
let json: serde_json::Value = serde_json::from_slice(bytes).ok()?;
let obj = json.as_object()?;
Some(ConversationParams {
token: obj.get("tkn")?.as_str()?.to_string(),
ws_endpoint: obj.get("wse")?.as_str()?.to_string(),
ws_ips: string_list(obj.get("wsip")),
wt_endpoint: str_field(obj.get("wte")),
calls_api_endpoint: str_field(obj.get("vcae")),
client_type: str_field(obj.get("srcp")),
expires_at: obj.get("et").and_then(|v| v.as_i64()),
stun: str_field(obj.get("stne")),
turn: split_csv(obj.get("trne")),
turn_user: str_field(obj.get("trnu")),
turn_password: str_field(obj.get("trnp")),
is_video: obj.get("iv").and_then(|v| v.as_bool()).unwrap_or(false),
})
}
pub fn ice_servers(&self) -> Vec<IceServer> {
let mut servers = Vec::new();
if let Some(stun) = self.stun.as_ref().filter(|s| !s.is_empty()) {
servers.push(IceServer {
urls: vec![stun.clone()],
username: None,
credential: None,
});
}
if !self.turn.is_empty() {
servers.push(IceServer {
urls: self.turn.clone(),
username: self.turn_user.clone(),
credential: self.turn_password.clone(),
});
}
servers
}
/// treats "expires within 5s" as expired. `now_secs` is unix time.
pub fn is_expired(&self, now_secs: i64) -> bool {
match self.expires_at {
Some(exp) => now_secs >= exp - 5,
None => false,
}
}
/// calls user id, the part after the last `:` in the TURN username.
pub fn user_id(&self) -> i64 {
self.turn_user
.as_deref()
.and_then(|u| u.rsplit(':').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
/// ws2 connect URL for an incoming call.
pub fn ws2_url(&self, conversation_id: &str, client: &Ws2ClientInfo) -> String {
let params = [
("userId", self.user_id().to_string()),
("entityType", "USER".to_string()),
("conversationId", conversation_id.to_string()),
("token", self.token.clone()),
("version", "5".to_string()),
("capabilities", client.capabilities.clone()),
("device", client.device.clone()),
("platform", client.platform.clone()),
("clientType", client.client_type.clone()),
("appVersion", client.app_version.clone()),
("osVersion", client.os_version.clone()),
];
set_query(&self.ws_endpoint, &params)
}
}
/// ws2 params that don't come from the server.
#[derive(Debug, Clone)]
pub struct Ws2ClientInfo {
pub capabilities: String,
pub device: String,
pub platform: String,
pub client_type: String,
pub app_version: String,
pub os_version: String,
}
impl Default for Ws2ClientInfo {
fn default() -> Self {
Self {
capabilities: "3c03f".to_string(),
device: "Kolibri".to_string(),
platform: "ANDROID".to_string(),
client_type: "ONE_ME".to_string(),
app_version: "sdk-0.1.16.4".to_string(),
os_version: "36".to_string(),
}
}
}
/// append client params to an outgoing-call `endpoint` (already carries token
/// and conversation/user ids in its query), overriding on key clash.
pub fn ws2_url_from_endpoint(endpoint: &str, client: &Ws2ClientInfo) -> String {
let extra = [
("platform", client.platform.clone()),
("version", "5".to_string()),
("capabilities", client.capabilities.clone()),
("clientType", client.client_type.clone()),
("appVersion", client.app_version.clone()),
("device", client.device.clone()),
("tgt", "start".to_string()),
];
merge_query(endpoint, &extra)
}
fn str_field(v: Option<&serde_json::Value>) -> Option<String> {
v.and_then(|v| v.as_str()).map(|s| s.to_string())
}
fn string_list(v: Option<&serde_json::Value>) -> Vec<String> {
v.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| e.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn split_csv(v: Option<&serde_json::Value>) -> Vec<String> {
v.and_then(|v| v.as_str())
.map(|s| {
s.split(',')
.map(|e| e.trim())
.filter(|e| !e.is_empty())
.map(|e| e.to_string())
.collect()
})
.unwrap_or_default()
}
/// replace the whole query of `base` with `params`.
fn set_query(base: &str, params: &[(&str, String)]) -> String {
let path = base.split('?').next().unwrap_or(base);
let query = params
.iter()
.map(|(k, v)| format!("{}={}", k, encode_query(v)))
.collect::<Vec<_>>()
.join("&");
format!("{path}?{query}")
}
/// merge `extra` into the existing query of `base`, extra wins on clash.
fn merge_query(base: &str, extra: &[(&str, String)]) -> String {
let (path, existing) = base.split_once('?').unwrap_or((base, ""));
let mut pairs: Vec<(String, String)> = Vec::new();
for pair in existing.split('&').filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
pairs.push((k.to_string(), v.to_string()));
}
for (k, v) in extra {
let encoded = encode_query(v);
if let Some(slot) = pairs.iter_mut().find(|(ek, _)| ek == k) {
slot.1 = encoded;
} else {
pairs.push((k.to_string(), encoded));
}
}
let query = pairs
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
format!("{path}?{query}")
}
fn encode_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
+35
View File
@@ -0,0 +1,35 @@
//! Reusable core of the Qlyra protocol: binary framing over a persistent TLS
//! socket, MessagePack payloads, LZ4/Zstd compression.
//!
//! This module set is the pure, I/O-free protocol layer (encode/decode, stream
//! de-framer, compression). Transport, session, and FFI bindings build on it.
pub mod auth;
pub mod protocol;
#[cfg(feature = "transport")]
pub mod transport;
#[cfg(feature = "transport")]
pub mod session;
#[cfg(feature = "transport")]
pub mod media;
#[cfg(feature = "calls")]
pub mod calls;
pub use protocol::{
cmd, decode, encode, opcodes, Packet, PacketReceiver, HEADER_SIZE, PROTOCOL_VERSION,
};
#[cfg(feature = "transport")]
pub use transport::{
set_trust_mincifry_ca, trust_mincifry_ca, Client, ClientConfig, Direction, ProxyConfig,
ProxyKind, TransportError, WireTap,
};
#[cfg(feature = "transport")]
pub use session::{
HandshakeConfig, HandshakeInfo, Session, SessionConfig, SessionState, UserAgent,
};
+347
View File
@@ -0,0 +1,347 @@
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::{MediaError, ProgressFn};
use crate::transport::proxy::{connect_tcp, ProxyConfig};
use crate::transport::tls::build_connector;
pub(crate) struct ParsedUrl {
pub https: bool,
pub host: String,
pub port: u16,
pub path: String,
}
impl ParsedUrl {
pub(crate) fn parse(url: &str) -> Result<Self, MediaError> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| MediaError::Url(format!("no scheme: {url}")))?;
let https = match scheme {
"https" => true,
"http" => false,
other => return Err(MediaError::Url(format!("unsupported scheme: {other}"))),
};
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, "/"),
};
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) if p.parse::<u16>().is_ok() => (h.to_string(), p.parse().unwrap()),
_ => (authority.to_string(), if https { 443 } else { 80 }),
};
Ok(ParsedUrl {
https,
host,
port,
path: if path.is_empty() {
"/".to_string()
} else {
path.to_string()
},
})
}
}
/// status code + dechunked body bytes
pub struct HttpResponse {
pub status: u16,
pub body: Vec<u8>,
}
// fresh connection per request, one request/response. no general HTTP client:
// the CDN wants exact header shapes.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn request(
url: &ParsedUrl,
method: &str,
headers: &[(&str, String)],
body: &[u8],
insecure: bool,
proxy: Option<&ProxyConfig>,
timeout: Duration,
progress: Option<&ProgressFn>,
progress_total: u64,
) -> Result<HttpResponse, MediaError> {
let head = build_head(method, &url.path, headers);
let tcp = connect_tcp(&url.host, url.port, timeout, proxy).await?;
if url.https {
let connector = build_connector(insecure).map_err(|e| MediaError::Tls(e.to_string()))?;
let name = rustls::pki_types::ServerName::try_from(url.host.clone())
.map_err(|e| MediaError::Tls(e.to_string()))?;
let tls = connector
.connect(name, tcp)
.await
.map_err(|e| MediaError::Tls(e.to_string()))?;
exchange(tls, &head, body, timeout, progress, progress_total).await
} else {
exchange(tcp, &head, body, timeout, progress, progress_total).await
}
}
// like [`request`], but the body is streamed: `prefix` bytes, then `body_len`
// bytes pulled from `reader` (e.g. a file, off disk, never fully in RAM), then
// `suffix` bytes. Caller sets Content-Length to prefix+body_len+suffix.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn request_streaming<R: AsyncReadExt + Unpin>(
url: &ParsedUrl,
method: &str,
headers: &[(&str, String)],
prefix: &[u8],
reader: R,
body_len: u64,
suffix: &[u8],
insecure: bool,
proxy: Option<&ProxyConfig>,
timeout: Duration,
progress: Option<&ProgressFn>,
progress_total: u64,
) -> Result<HttpResponse, MediaError> {
let head = build_head(method, &url.path, headers);
let tcp = connect_tcp(&url.host, url.port, timeout, proxy).await?;
if url.https {
let connector = build_connector(insecure).map_err(|e| MediaError::Tls(e.to_string()))?;
let name = rustls::pki_types::ServerName::try_from(url.host.clone())
.map_err(|e| MediaError::Tls(e.to_string()))?;
let tls = connector
.connect(name, tcp)
.await
.map_err(|e| MediaError::Tls(e.to_string()))?;
exchange_streaming(
tls,
&head,
prefix,
reader,
body_len,
suffix,
timeout,
progress,
progress_total,
)
.await
} else {
exchange_streaming(
tcp,
&head,
prefix,
reader,
body_len,
suffix,
timeout,
progress,
progress_total,
)
.await
}
}
#[allow(clippy::too_many_arguments)]
async fn exchange_streaming<S, R>(
mut stream: S,
head: &[u8],
prefix: &[u8],
mut reader: R,
body_len: u64,
suffix: &[u8],
timeout: Duration,
progress: Option<&ProgressFn>,
progress_total: u64,
) -> Result<HttpResponse, MediaError>
where
S: AsyncReadExt + AsyncWriteExt + Unpin,
R: AsyncReadExt + Unpin,
{
stream.write_all(head).await?;
let mut sent = 0u64;
let report = |sent: u64| {
if let Some(cb) = progress {
cb(sent, progress_total.max(sent));
}
};
if !prefix.is_empty() {
stream.write_all(prefix).await?;
sent += prefix.len() as u64;
report(sent);
}
let mut remaining = body_len;
let mut buf = vec![0u8; 64 * 1024];
while remaining > 0 {
let want = (buf.len() as u64).min(remaining) as usize;
let n = reader.read(&mut buf[..want]).await?;
if n == 0 {
break;
}
stream.write_all(&buf[..n]).await?;
sent += n as u64;
remaining -= n as u64;
report(sent);
}
if !suffix.is_empty() {
stream.write_all(suffix).await?;
sent += suffix.len() as u64;
report(sent);
}
stream.flush().await?;
read_response(&mut stream, timeout).await
}
fn build_head(method: &str, path: &str, headers: &[(&str, String)]) -> Vec<u8> {
let mut s = format!("{method} {path} HTTP/1.1\r\n");
for (k, v) in headers {
s.push_str(k);
s.push_str(": ");
s.push_str(v);
s.push_str("\r\n");
}
s.push_str("\r\n");
s.into_bytes()
}
async fn exchange<S: AsyncReadExt + AsyncWriteExt + Unpin>(
mut stream: S,
head: &[u8],
body: &[u8],
timeout: Duration,
progress: Option<&ProgressFn>,
progress_total: u64,
) -> Result<HttpResponse, MediaError> {
stream.write_all(head).await?;
let mut sent = 0u64;
for chunk in body.chunks(64 * 1024) {
stream.write_all(chunk).await?;
sent += chunk.len() as u64;
if let Some(cb) = progress {
cb(sent, progress_total.max(sent));
}
}
stream.flush().await?;
read_response(&mut stream, timeout).await
}
async fn read_response<S: AsyncReadExt + Unpin>(
stream: &mut S,
timeout: Duration,
) -> Result<HttpResponse, MediaError> {
let mut buf = Vec::new();
let mut tmp = [0u8; 16 * 1024];
loop {
let n = tokio::time::timeout(timeout, stream.read(&mut tmp))
.await
.map_err(|_| MediaError::Timeout)??;
if n == 0 {
break;
}
buf.extend_from_slice(&tmp[..n]);
if let Some(resp) = try_parse(&buf, false)? {
return Ok(resp);
}
}
try_parse(&buf, true)?.ok_or(MediaError::Incomplete)
}
fn try_parse(buf: &[u8], at_close: bool) -> Result<Option<HttpResponse>, MediaError> {
let Some(header_end) = find_subslice(buf, b"\r\n\r\n").map(|p| p + 4) else {
return Ok(None);
};
let header_str = String::from_utf8_lossy(&buf[..header_end]);
let mut lines = header_str.split("\r\n");
let status = lines
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(0);
let mut content_length: Option<usize> = None;
let mut chunked = false;
for line in lines {
if let Some((k, v)) = line.split_once(':') {
let key = k.trim().to_ascii_lowercase();
let val = v.trim();
if key == "content-length" {
content_length = val.parse().ok();
} else if key == "transfer-encoding" && val.to_ascii_lowercase().contains("chunked") {
chunked = true;
}
}
}
let body_bytes = &buf[header_end..];
if chunked {
if !at_close && find_subslice(body_bytes, b"0\r\n\r\n").is_none() {
return Ok(None);
}
return Ok(Some(HttpResponse {
status,
body: decode_chunked(body_bytes),
}));
}
if let Some(cl) = content_length {
if !at_close && body_bytes.len() < cl {
return Ok(None);
}
let end = cl.min(body_bytes.len());
return Ok(Some(HttpResponse {
status,
body: body_bytes[..end].to_vec(),
}));
}
if at_close {
Ok(Some(HttpResponse {
status,
body: body_bytes.to_vec(),
}))
} else {
Ok(None)
}
}
fn decode_chunked(body: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut i = 0;
while i < body.len() {
let Some(line_end) = find_subslice(&body[i..], b"\r\n").map(|p| i + p) else {
break;
};
let size_str = String::from_utf8_lossy(&body[i..line_end]);
let size_hex = size_str.split(';').next().unwrap_or("").trim();
if size_hex.is_empty() {
i = line_end + 2;
continue;
}
let Ok(size) = usize::from_str_radix(size_hex, 16) else {
break;
};
if size == 0 {
break;
}
let data_start = line_end + 2;
if data_start + size > body.len() {
break;
}
out.extend_from_slice(&body[data_start..data_start + size]);
i = data_start + size;
if i + 2 <= body.len() && &body[i..i + 2] == b"\r\n" {
i += 2;
}
}
out
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack
.windows(needle.len())
.position(|window| window == needle)
}
+35
View File
@@ -0,0 +1,35 @@
//! Hand-rolled HTTP(S) client for CDN uploads (reuses the transport's tokio +
//! rustls). CDN wants exact request shapes: single-POST files, multipart photos,
//! resumable parallel-chunk video. Control plane (upload URL, send message) stays
//! on the main protocol socket via [`crate::transport`].
mod http;
mod upload;
use std::sync::Arc;
use thiserror::Error;
pub use http::HttpResponse;
pub use upload::{
content_type_for_filename, upload_file, upload_file_path, upload_photo, upload_photo_path,
upload_video, upload_video_path,
};
/// `(bytes_sent, total_bytes)`
pub type ProgressFn = Arc<dyn Fn(u64, u64) + Send + Sync>;
#[derive(Debug, Error)]
pub enum MediaError {
#[error("invalid url: {0}")]
Url(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("tls error: {0}")]
Tls(String),
#[error("http status {0}")]
Http(u16),
#[error("request timed out")]
Timeout,
#[error("incomplete http response")]
Incomplete,
}
+519
View File
@@ -0,0 +1,519 @@
use std::io::SeekFrom;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use super::http::{self, HttpResponse, ParsedUrl};
use super::{MediaError, ProgressFn};
use crate::transport::proxy::ProxyConfig;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
/// single POST with a `Content-Range` covering the whole body. status 200 = ok.
/// `user_agent` from the handshake device (see `UserAgent::http_user_agent`).
#[allow(clippy::too_many_arguments)]
pub async fn upload_file(
url: &str,
data: &[u8],
filename: &str,
insecure: bool,
proxy: Option<&ProxyConfig>,
progress: Option<ProgressFn>,
user_agent: &str,
) -> Result<HttpResponse, MediaError> {
let parsed = ParsedUrl::parse(url)?;
let total = data.len() as u64;
let headers = vec![
("Host", parsed.host.clone()),
(
"Content-Type",
"application/x-binary; charset=x-user-defined".to_string(),
),
(
"Content-Disposition",
format!("attachment; filename={filename}"),
),
("Connection", "keep-alive".to_string()),
("User-Agent", percent_encode(user_agent)),
(
"Content-Range",
format!("bytes 0-{}/{}", total.saturating_sub(1), total),
),
("Content-Length", total.to_string()),
];
http::request(
&parsed,
"POST",
&headers,
data,
insecure,
proxy,
DEFAULT_TIMEOUT,
progress.as_ref(),
total,
)
.await
}
/// `multipart/form-data`; caller extracts `photoToken` from the JSON body.
/// `user_agent` from the handshake device.
#[allow(clippy::too_many_arguments)]
pub async fn upload_photo(
url: &str,
data: &[u8],
filename: &str,
insecure: bool,
proxy: Option<&ProxyConfig>,
progress: Option<ProgressFn>,
user_agent: &str,
) -> Result<HttpResponse, MediaError> {
let parsed = ParsedUrl::parse(url)?;
let boundary = format!("----KolibriBoundary{}", now_micros());
let preamble = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {}\r\n\r\n",
content_type_for_filename(filename)
);
let epilogue = format!("\r\n--{boundary}--\r\n");
let mut body = Vec::with_capacity(preamble.len() + data.len() + epilogue.len());
body.extend_from_slice(preamble.as_bytes());
body.extend_from_slice(data);
body.extend_from_slice(epilogue.as_bytes());
let total = body.len() as u64;
let headers = vec![
("Host", parsed.host.clone()),
(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
),
("Content-Length", total.to_string()),
("Connection", "keep-alive".to_string()),
("User-Agent", percent_encode(user_agent)),
];
http::request(
&parsed,
"POST",
&headers,
&body,
insecure,
proxy,
Duration::from_secs(120),
progress.as_ref(),
total,
)
.await
}
/// parallel-chunk video upload with resume. GET handshake returns the resume
/// offset, then each `chunk_size` range is POSTed by up to `concurrency` workers.
#[allow(clippy::too_many_arguments)]
pub async fn upload_video(
url: &str,
data: Vec<u8>,
chunk_size: usize,
concurrency: usize,
insecure: bool,
proxy: Option<ProxyConfig>,
progress: Option<ProgressFn>,
) -> Result<bool, MediaError> {
let parsed = Arc::new(ParsedUrl::parse(url)?);
let total = data.len();
if total == 0 {
return Ok(false);
}
let filename = Arc::new(now_micros().to_string());
let data = Arc::new(data);
let handshake = ok_cdn_request(
&parsed,
"GET",
&filename,
&[],
None,
insecure,
proxy.as_ref(),
)
.await?;
if handshake.status != 200 {
return Ok(false);
}
let mut start_offset = 0usize;
if let Ok(resumed) = String::from_utf8_lossy(&handshake.body)
.trim()
.parse::<usize>()
{
if resumed <= total {
start_offset = resumed;
}
}
let mut ranges = Vec::new();
let mut o = start_offset;
while o < total {
let end = (o + chunk_size).min(total);
ranges.push((o, end));
o = end;
}
if ranges.is_empty() {
return Ok(true);
}
let ranges = Arc::new(ranges);
let next = Arc::new(AtomicUsize::new(0));
let sent = Arc::new(AtomicUsize::new(start_offset));
let worker_count = concurrency.max(1).min(ranges.len());
let mut handles = Vec::with_capacity(worker_count);
for _ in 0..worker_count {
let parsed = parsed.clone();
let filename = filename.clone();
let data = data.clone();
let ranges = ranges.clone();
let next = next.clone();
let sent = sent.clone();
let progress = progress.clone();
let proxy = proxy.clone();
handles.push(tokio::spawn(async move {
loop {
let i = next.fetch_add(1, Ordering::SeqCst);
if i >= ranges.len() {
return Ok::<(), MediaError>(());
}
let (start, end) = ranges[i];
let range = format!("bytes {start}-{}/{total}", end - 1);
let resp = ok_cdn_request(
&parsed,
"POST",
&filename,
&data[start..end],
Some(&range),
insecure,
proxy.as_ref(),
)
.await?;
if resp.status != 200 && resp.status != 201 {
return Err(MediaError::Http(resp.status));
}
let done = sent.fetch_add(end - start, Ordering::SeqCst) + (end - start);
if let Some(cb) = &progress {
cb(done as u64, total as u64);
}
}
}));
}
for handle in handles {
match handle.await {
Ok(Ok(())) => {}
Ok(Err(_)) => return Ok(false),
Err(_) => return Ok(false),
}
}
Ok(true)
}
/// Like [`upload_file`], but streams the body off disk from `path` (never fully
/// in RAM). Content-Length comes from the file's metadata length.
#[allow(clippy::too_many_arguments)]
pub async fn upload_file_path(
url: &str,
path: &str,
filename: &str,
content_type: Option<&str>,
connection: Option<&str>,
insecure: bool,
proxy: Option<&ProxyConfig>,
progress: Option<ProgressFn>,
user_agent: &str,
) -> Result<HttpResponse, MediaError> {
let parsed = ParsedUrl::parse(url)?;
let file = File::open(path).await?;
let total = file.metadata().await?.len();
let headers = vec![
("Host", parsed.host.clone()),
(
"Content-Type",
content_type
.unwrap_or("application/x-binary; charset=x-user-defined")
.to_string(),
),
(
"Content-Disposition",
format!("attachment; filename={filename}"),
),
("Connection", connection.unwrap_or("keep-alive").to_string()),
("User-Agent", percent_encode(user_agent)),
(
"Content-Range",
format!("bytes 0-{}/{}", total.saturating_sub(1), total),
),
("Content-Length", total.to_string()),
];
http::request_streaming(
&parsed,
"POST",
&headers,
&[],
file,
total,
&[],
insecure,
proxy,
DEFAULT_TIMEOUT,
progress.as_ref(),
total,
)
.await
}
/// Like [`upload_photo`], but streams the file part off disk from `path`.
#[allow(clippy::too_many_arguments)]
pub async fn upload_photo_path(
url: &str,
path: &str,
filename: &str,
insecure: bool,
proxy: Option<&ProxyConfig>,
progress: Option<ProgressFn>,
user_agent: &str,
) -> Result<HttpResponse, MediaError> {
let parsed = ParsedUrl::parse(url)?;
let file = File::open(path).await?;
let file_len = file.metadata().await?.len();
let boundary = format!("----KolibriBoundary{}", now_micros());
let preamble = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {}\r\n\r\n",
content_type_for_filename(filename)
);
let epilogue = format!("\r\n--{boundary}--\r\n");
let total = preamble.len() as u64 + file_len + epilogue.len() as u64;
let headers = vec![
("Host", parsed.host.clone()),
(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
),
("Content-Length", total.to_string()),
("Connection", "keep-alive".to_string()),
("User-Agent", percent_encode(user_agent)),
];
http::request_streaming(
&parsed,
"POST",
&headers,
preamble.as_bytes(),
file,
file_len,
epilogue.as_bytes(),
insecure,
proxy,
Duration::from_secs(120),
progress.as_ref(),
total,
)
.await
}
/// Like [`upload_video`], but each chunk is read off disk from `path` on demand
/// (only `chunk_size` bytes per worker in RAM at a time), never the whole file.
#[allow(clippy::too_many_arguments)]
pub async fn upload_video_path(
url: &str,
path: &str,
chunk_size: usize,
concurrency: usize,
insecure: bool,
proxy: Option<ProxyConfig>,
progress: Option<ProgressFn>,
) -> Result<bool, MediaError> {
let parsed = Arc::new(ParsedUrl::parse(url)?);
let total = tokio::fs::metadata(path).await?.len() as usize;
if total == 0 {
return Ok(false);
}
let filename = Arc::new(now_micros().to_string());
let path = Arc::new(path.to_string());
let handshake = ok_cdn_request(
&parsed,
"GET",
&filename,
&[],
None,
insecure,
proxy.as_ref(),
)
.await?;
if handshake.status != 200 {
return Ok(false);
}
let mut start_offset = 0usize;
if let Ok(resumed) = String::from_utf8_lossy(&handshake.body)
.trim()
.parse::<usize>()
{
if resumed <= total {
start_offset = resumed;
}
}
let mut ranges = Vec::new();
let mut o = start_offset;
while o < total {
let end = (o + chunk_size).min(total);
ranges.push((o, end));
o = end;
}
if ranges.is_empty() {
return Ok(true);
}
let ranges = Arc::new(ranges);
let next = Arc::new(AtomicUsize::new(0));
let sent = Arc::new(AtomicUsize::new(start_offset));
let worker_count = concurrency.max(1).min(ranges.len());
let mut handles = Vec::with_capacity(worker_count);
for _ in 0..worker_count {
let parsed = parsed.clone();
let filename = filename.clone();
let path = path.clone();
let ranges = ranges.clone();
let next = next.clone();
let sent = sent.clone();
let progress = progress.clone();
let proxy = proxy.clone();
handles.push(tokio::spawn(async move {
loop {
let i = next.fetch_add(1, Ordering::SeqCst);
if i >= ranges.len() {
return Ok::<(), MediaError>(());
}
let (start, end) = ranges[i];
let mut f = File::open(&*path).await?;
f.seek(SeekFrom::Start(start as u64)).await?;
let mut buf = vec![0u8; end - start];
f.read_exact(&mut buf).await?;
let range = format!("bytes {start}-{}/{total}", end - 1);
let resp = ok_cdn_request(
&parsed,
"POST",
&filename,
&buf,
Some(&range),
insecure,
proxy.as_ref(),
)
.await?;
if resp.status != 200 && resp.status != 201 {
return Err(MediaError::Http(resp.status));
}
let done = sent.fetch_add(end - start, Ordering::SeqCst) + (end - start);
if let Some(cb) = &progress {
cb(done as u64, total as u64);
}
}
}));
}
for handle in handles {
match handle.await {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(_) => return Ok(false),
}
}
Ok(true)
}
#[allow(clippy::too_many_arguments)]
async fn ok_cdn_request(
url: &ParsedUrl,
method: &str,
filename: &str,
body: &[u8],
content_range: Option<&str>,
insecure: bool,
proxy: Option<&ProxyConfig>,
) -> Result<HttpResponse, MediaError> {
let mut headers = vec![
("Host", url.host.clone()),
(
"Content-Type",
"application/x-binary; charset=x-user-defined".to_string(),
),
(
"Content-Disposition",
format!("attachment; fileName=\"{filename}\""),
),
("Content-Length", body.len().to_string()),
("X-Uploading-Mode", "parallel".to_string()),
("Connection", "close".to_string()),
];
if let Some(range) = content_range {
headers.push(("Content-Range", range.to_string()));
}
http::request(
url,
method,
&headers,
body,
insecure,
proxy,
Duration::from_secs(120),
None,
body.len() as u64,
)
.await
}
pub fn content_type_for_filename(filename: &str) -> &'static str {
let ext = filename
.rsplit('.')
.next()
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
match ext.as_str() {
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"heic" | "heif" => "image/heic",
"bmp" => "image/bmp",
_ => "image/jpeg",
}
}
fn now_micros() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as u64 & 0x7FFF_FFFF)
.unwrap_or(0)
}
// matches Dart's Uri.encodeComponent; unreserved chars pass through
fn percent_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for &b in input.as_bytes() {
match b {
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'!'
| b'~'
| b'*'
| b'\''
| b'('
| b')' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
+104
View File
@@ -0,0 +1,104 @@
use super::compress::{self, CompressError};
use super::packet::{cmd, Packet, HEADER_SIZE, PROTOCOL_VERSION};
use thiserror::Error;
/// Payloads below this go out uncompressed.
pub const COMPRESSION_THRESHOLD: usize = 32;
#[derive(Debug, Error)]
pub enum CodecError {
#[error("buffer smaller than header ({0} B)")]
ShortHeader(usize),
#[error("payload length {declared} exceeds buffer ({actual} B)")]
PayloadOutOfRange { declared: usize, actual: usize },
#[error(transparent)]
Compress(#[from] CompressError),
}
/// `payload` is already-serialized MessagePack. Payloads >= threshold get LZ4
/// compressed and the header flag byte carries `(raw_len / comp_len) + 1` as a
/// size hint, else 0.
pub fn encode(opcode: u16, payload: &[u8], seq: u16) -> Vec<u8> {
encode_with_cmd(cmd::REQUEST, opcode, payload, seq)
}
/// Explicit command byte. The client only sends [`cmd::REQUEST`]; this is for
/// building ok/error/not-found responses and pushes in tests / server code.
pub fn encode_with_cmd(cmd: u8, opcode: u16, payload: &[u8], seq: u16) -> Vec<u8> {
let (body, flag): (Vec<u8>, u8) = if payload.len() < COMPRESSION_THRESHOLD {
(payload.to_vec(), 0)
} else {
let compressed = compress::compress_lz4_block(payload);
let flag = ((payload.len() / compressed.len().max(1)) + 1) as u8;
(compressed, flag)
};
let mut out = Vec::with_capacity(HEADER_SIZE + body.len());
out.push(PROTOCOL_VERSION);
out.push(cmd);
out.extend_from_slice(&seq.to_be_bytes());
out.extend_from_slice(&opcode.to_be_bytes());
let packed_len = (((flag as u32) & 0xFF) << 24) | ((body.len() as u32) & 0x00FF_FFFF);
out.extend_from_slice(&packed_len.to_be_bytes());
out.extend_from_slice(&body);
out
}
/// Declared total length (header + payload) for the packet starting at `buf[0]`,
/// or `None` if fewer than [`HEADER_SIZE`] bytes are available.
pub fn packet_total_len(buf: &[u8]) -> Option<usize> {
if buf.len() < HEADER_SIZE {
return None;
}
let packed_len = u32::from_be_bytes([buf[6], buf[7], buf[8], buf[9]]);
let payload_len = (packed_len & 0x00FF_FFFF) as usize;
Some(HEADER_SIZE + payload_len)
}
/// Decode one complete packet buffer, decompressing when the header flag is set.
pub fn decode(buf: &[u8]) -> Result<Packet, CodecError> {
if buf.len() < HEADER_SIZE {
return Err(CodecError::ShortHeader(buf.len()));
}
let ver = buf[0];
let cmd_byte = buf[1];
let seq = u16::from_be_bytes([buf[2], buf[3]]);
let opcode = u16::from_be_bytes([buf[4], buf[5]]);
let packed_len = u32::from_be_bytes([buf[6], buf[7], buf[8], buf[9]]);
let comp_flag = (packed_len >> 24) as u8;
let payload_len = (packed_len & 0x00FF_FFFF) as usize;
if payload_len == 0 {
return Ok(Packet {
ver,
cmd: cmd_byte,
seq,
opcode,
payload: Vec::new(),
});
}
let end = HEADER_SIZE + payload_len;
if end > buf.len() {
return Err(CodecError::PayloadOutOfRange {
declared: payload_len,
actual: buf.len(),
});
}
let slice = &buf[HEADER_SIZE..end];
let payload = if comp_flag != 0 {
compress::decompress(slice)?
} else {
slice.to_vec()
};
Ok(Packet {
ver,
cmd: cmd_byte,
seq,
opcode,
payload,
})
}
+138
View File
@@ -0,0 +1,138 @@
use thiserror::Error;
/// Decompression-bomb ceiling for one payload.
pub const MAX_DECOMPRESSED_SIZE: usize = 32 * 1024 * 1024;
#[derive(Debug, Error)]
pub enum CompressError {
#[error("decompressed size exceeds limit ({0} B)")]
LimitExceeded(usize),
#[error("truncated LZ4 block input")]
TruncatedInput,
#[error("LZ4 block: zero match offset")]
ZeroOffset,
#[error("LZ4 block: match offset before start of output")]
OffsetOutOfRange,
#[error("zstd decompression error: {0}")]
Zstd(String),
#[error("LZ4 frame decompression error: {0}")]
Lz4Frame(String),
}
/// Sniff format by magic number. The header only flags that a payload is
/// compressed, not which of LZ4 block / LZ4 frame / Zstd the server picked.
pub fn decompress(src: &[u8]) -> Result<Vec<u8>, CompressError> {
// Zstandard magic: 28 B5 2F FD
if src.len() >= 4 && src[0] == 0x28 && src[1] == 0xB5 && src[2] == 0x2F && src[3] == 0xFD {
return decompress_zstd(src);
}
// LZ4 frame magic: 04 22 4D 18
if src.len() >= 4 && src[0] == 0x04 && src[1] == 0x22 && src[2] == 0x4D && src[3] == 0x18 {
return decompress_lz4_frame(src);
}
// no magic: LZ4 block
decompress_lz4_block(src, MAX_DECOMPRESSED_SIZE)
}
fn decompress_zstd(src: &[u8]) -> Result<Vec<u8>, CompressError> {
zstd::stream::decode_all(src).map_err(|e| CompressError::Zstd(e.to_string()))
}
fn decompress_lz4_frame(src: &[u8]) -> Result<Vec<u8>, CompressError> {
use std::io::Read;
let mut reader = lz4_flex::frame::FrameDecoder::new(src);
let mut out = Vec::new();
reader
.read_to_end(&mut out)
.map_err(|e| CompressError::Lz4Frame(e.to_string()))?;
Ok(out)
}
/// Raw LZ4 block (no frame header, no size prefix), what the server expects
/// outgoing. Decompressed size travels out-of-band in the header flag byte.
pub fn compress_lz4_block(src: &[u8]) -> Vec<u8> {
lz4_flex::block::compress(src)
}
/// LZ4 frame format. Kept for interop tests; outgoing traffic uses the block form.
pub fn compress_lz4_frame(src: &[u8]) -> Vec<u8> {
use std::io::Write;
let mut enc = lz4_flex::frame::FrameEncoder::new(Vec::new());
enc.write_all(src).expect("in-memory write cannot fail");
enc.finish().expect("in-memory finish cannot fail")
}
/// LZ4 block decompression. Block format has no size prefix, so the output grows
/// dynamically.
pub fn decompress_lz4_block(src: &[u8], max_size: usize) -> Result<Vec<u8>, CompressError> {
let mut out: Vec<u8> = Vec::with_capacity(1024);
let mut pos = 0usize;
while pos < src.len() {
let token = src[pos];
pos += 1;
let mut lit_len = (token >> 4) as usize;
if lit_len == 15 {
while pos < src.len() {
let b = src[pos];
pos += 1;
lit_len += b as usize;
if b != 255 {
break;
}
}
}
if lit_len > 0 {
if out.len() + lit_len > max_size {
return Err(CompressError::LimitExceeded(max_size));
}
if pos + lit_len > src.len() {
return Err(CompressError::TruncatedInput);
}
out.extend_from_slice(&src[pos..pos + lit_len]);
pos += lit_len;
}
if pos >= src.len() {
break;
}
if pos + 1 >= src.len() {
return Err(CompressError::TruncatedInput);
}
let offset = (src[pos] as usize) | ((src[pos + 1] as usize) << 8);
pos += 2;
if offset == 0 {
return Err(CompressError::ZeroOffset);
}
let mut match_len = (token & 0x0F) as usize + 4;
if (token & 0x0F) == 0x0F {
while pos < src.len() {
let b = src[pos];
pos += 1;
match_len += b as usize;
if b != 255 {
break;
}
}
}
if out.len() + match_len > max_size {
return Err(CompressError::LimitExceeded(max_size));
}
if offset > out.len() {
return Err(CompressError::OffsetOutOfRange);
}
let start = out.len() - offset;
// overlapping copy: offset may be < match_len, so go byte-by-byte
for i in 0..match_len {
let b = out[start + i];
out.push(b);
}
}
Ok(out)
}
+62
View File
@@ -0,0 +1,62 @@
use super::codec;
use thiserror::Error;
/// Buffer overflow guard.
pub const MAX_BUFFER_SIZE: usize = 16 * 1024 * 1024;
#[derive(Debug, Error)]
#[error("PacketReceiver buffer overflow ({0} B)")]
pub struct OverflowError(pub usize);
/// Reassembles the TLS byte stream into whole packets. Feed arbitrary chunks;
/// get back whichever packets are complete, partial remainder stays buffered.
#[derive(Default)]
pub struct PacketReceiver {
buf: Vec<u8>,
}
impl PacketReceiver {
pub fn new() -> Self {
Self { buf: Vec::new() }
}
/// Append `data`, drain complete packets. Each is a full header+payload
/// buffer ready for [`codec::decode`].
pub fn feed(&mut self, data: &[u8]) -> Result<Vec<Vec<u8>>, OverflowError> {
self.buf.extend_from_slice(data);
if self.buf.len() > MAX_BUFFER_SIZE {
let overflow = self.buf.len();
self.reset();
return Err(OverflowError(overflow));
}
let mut packets = Vec::new();
let mut consumed = 0usize;
loop {
let remaining = &self.buf[consumed..];
let Some(total) = codec::packet_total_len(remaining) else {
break;
};
if remaining.len() < total {
break;
}
packets.push(remaining[..total].to_vec());
consumed += total;
}
if consumed > 0 {
self.buf.drain(..consumed);
}
Ok(packets)
}
pub fn reset(&mut self) {
self.buf.clear();
}
pub fn buffered_len(&self) -> usize {
self.buf.len()
}
}
+252
View File
@@ -0,0 +1,252 @@
//! MessagePack -> JSON, for logs. lossy: Binary/Ext turn into base64 strings,
//! non-string map keys get stringified (JSON holds neither), so it reads but
//! won't round-trip back to the same msgpack. The request direction
//! ([`json_to_value`]) recovers what hosts tag explicitly: `{"$bin":…}`,
//! `{"$ext":…}` and `"$int:<n>"` map keys.
use base64::Engine;
use rmpv::Value;
use serde_json::{Map, Number, Value as Json};
/// a decoded MessagePack value as JSON. `Binary` -> plain base64 string
/// (readable, but a request can't recover it, see [`value_to_json_tagged`]).
pub fn value_to_json(value: &Value) -> Json {
to_json(value, false)
}
/// like [`value_to_json`], but `Binary` -> `{"$bin":"<base64>"}`, which
/// [`json_to_value`] turns back; round-trips on the data plane.
pub fn value_to_json_tagged(value: &Value) -> Json {
to_json(value, true)
}
fn to_json(value: &Value, tag_binary: bool) -> Json {
match value {
Value::Nil => Json::Null,
Value::Boolean(b) => Json::Bool(*b),
Value::Integer(i) => integer_to_json(i),
Value::F32(f) => Number::from_f64(*f as f64).map_or(Json::Null, Json::Number),
Value::F64(f) => Number::from_f64(*f).map_or(Json::Null, Json::Number),
Value::String(s) => Json::String(utf8_lossy(s)),
Value::Binary(bytes) => {
if tag_binary {
let mut map = Map::with_capacity(1);
map.insert("$bin".to_string(), Json::String(base64_encode(bytes)));
Json::Object(map)
} else {
Json::String(base64_encode(bytes))
}
}
Value::Array(items) => Json::Array(items.iter().map(|v| to_json(v, tag_binary)).collect()),
Value::Map(entries) => {
let mut map = Map::with_capacity(entries.len());
for (k, v) in entries {
map.insert(map_key(k), to_json(v, tag_binary));
}
Json::Object(map)
}
Value::Ext(tag, data) => {
let mut map = Map::with_capacity(2);
map.insert("$ext".to_string(), Json::Number((*tag).into()));
map.insert("data".to_string(), Json::String(base64_encode(data)));
Json::Object(map)
}
}
}
/// JSON to MessagePack, to build a request from a host map. Inverse of
/// [`value_to_json`] where it can be: `{"$bin":"<b64>"}` -> `Binary`,
/// `{"$ext": tag, "data": "<b64>"}` -> `Ext`, a `"$int:<n>"` key -> integer
/// map key (JSON has no non-string keys); other keys stay text.
pub fn json_to_value(value: &Json) -> Value {
match value {
Json::Null => Value::Nil,
Json::Bool(b) => Value::from(*b),
Json::Number(n) => number_to_value(n),
Json::String(s) => Value::from(s.clone()),
Json::Array(items) => Value::Array(items.iter().map(json_to_value).collect()),
Json::Object(obj) => {
if let Some(v) = tagged_binary(obj) {
return v;
}
Value::Map(
obj.iter()
.map(|(k, v)| (json_key_to_value(k), json_to_value(v)))
.collect(),
)
}
}
}
fn json_key_to_value(key: &str) -> Value {
if let Some(num) = key.strip_prefix("$int:") {
if let Ok(i) = num.parse::<i64>() {
return Value::from(i);
}
if let Ok(u) = num.parse::<u64>() {
return Value::from(u);
}
}
Value::from(key.to_string())
}
fn number_to_value(n: &Number) -> Value {
if let Some(i) = n.as_i64() {
Value::from(i)
} else if let Some(u) = n.as_u64() {
Value::from(u)
} else if let Some(f) = n.as_f64() {
Value::from(f)
} else {
Value::Nil
}
}
fn tagged_binary(obj: &Map<String, Json>) -> Option<Value> {
if obj.len() == 1 {
if let Some(Json::String(b64)) = obj.get("$bin") {
return base64_decode(b64).map(Value::Binary);
}
}
if obj.len() == 2 {
if let (Some(Json::Number(tag)), Some(Json::String(b64))) =
(obj.get("$ext"), obj.get("data"))
{
if let (Some(t), Some(bytes)) = (tag.as_i64(), base64_decode(b64)) {
return Some(Value::Ext(t as i8, bytes));
}
}
}
None
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
base64::engine::general_purpose::STANDARD.decode(s).ok()
}
fn integer_to_json(i: &rmpv::Integer) -> Json {
if let Some(u) = i.as_u64() {
Json::Number(u.into())
} else if let Some(s) = i.as_i64() {
Json::Number(s.into())
} else {
Json::Null
}
}
fn map_key(value: &Value) -> String {
match value {
Value::String(s) => utf8_lossy(s),
Value::Integer(i) => integer_to_json(i).to_string(),
Value::Boolean(b) => b.to_string(),
Value::Nil => "null".to_string(),
other => value_to_json(other).to_string(),
}
}
fn utf8_lossy(s: &rmpv::Utf8String) -> String {
match s.as_str() {
Some(text) => text.to_string(),
None => String::from_utf8_lossy(s.as_bytes()).into_owned(),
}
}
fn base64_encode(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
#[cfg(test)]
mod tests {
use super::value_to_json;
use rmpv::Value;
#[test]
fn renders_scalars_and_nested_maps() {
let value = Value::Map(vec![
(Value::from("id"), Value::from(42u64)),
(Value::from("ok"), Value::from(true)),
(
Value::from("tags"),
Value::Array(vec![Value::from("a"), Value::from("b")]),
),
]);
let json = value_to_json(&value);
assert_eq!(json["id"], 42);
assert_eq!(json["ok"], true);
assert_eq!(json["tags"][1], "b");
}
#[test]
fn binary_becomes_base64_and_int_keys_stringify() {
let value = Value::Map(vec![(Value::from(7), Value::Binary(vec![0xDE, 0xAD]))]);
let json = value_to_json(&value);
assert_eq!(json["7"], "3q0=");
}
#[test]
fn tagged_binary_round_trips_flat_stays_base64() {
use super::{json_to_value, value_to_json_tagged};
let value = Value::Map(vec![
(
Value::from("fp"),
Value::Binary(vec![0xDE, 0xAD, 0xBE, 0xEF]),
),
(Value::from("name"), Value::from("x")),
]);
// tagged output round-trips back to the same msgpack Binary
let back = json_to_value(&value_to_json_tagged(&value));
assert_eq!(back, value);
// flat output keeps binary as a plain base64 string (for logs)
assert_eq!(value_to_json(&value)["fp"], "3q2+7w==");
assert!(value_to_json_tagged(&value)["fp"].is_object());
}
#[test]
fn json_to_value_builds_map_and_recovers_binary() {
use super::json_to_value;
let json = serde_json::json!({
"id": 42,
"ok": true,
"name": "x",
"fp": { "$bin": "3q0=" },
"list": [1, 2, 3],
});
let value = json_to_value(&json);
let map = value.as_map().unwrap();
let get = |k: &str| {
map.iter()
.find(|(mk, _)| mk.as_str() == Some(k))
.map(|(_, v)| v)
};
assert_eq!(get("id").unwrap().as_i64(), Some(42));
assert_eq!(get("ok").unwrap().as_bool(), Some(true));
assert_eq!(get("fp").unwrap().as_slice(), Some(&[0xDE, 0xAD][..]));
assert_eq!(get("list").unwrap().as_array().unwrap().len(), 3);
}
#[test]
fn tagged_int_keys_become_integer_map_keys() {
use super::json_to_value;
let json = serde_json::json!({
"settings": {
"chats": {
"$int:12345": { "dontDisturbUntil": -1 },
"$int:-7": { "dontDisturbUntil": 0 },
"$int:18446744073709551615": true,
"$int:oops": true,
"plain": true,
},
},
});
let value = json_to_value(&json);
let settings = value.as_map().unwrap()[0].1.as_map().unwrap();
let chats = settings[0].1.as_map().unwrap();
let has = |want: &Value| chats.iter().any(|(k, _)| k == want);
assert!(has(&Value::from(12345i64)));
assert!(has(&Value::from(-7i64)));
assert!(has(&Value::from(u64::MAX)));
assert!(has(&Value::from("$int:oops")));
assert!(has(&Value::from("plain")));
assert_eq!(chats.len(), 5);
}
}
+18
View File
@@ -0,0 +1,18 @@
//! Qlyra binary protocol: packet layout, wire codec, stream framing,
//! compression, opcodes. Transport-agnostic, no I/O; bytes <-> [`Packet`].
pub mod codec;
pub mod compress;
pub mod framing;
#[cfg(feature = "json")]
pub mod json;
pub mod opcodes;
pub mod packet;
pub use codec::{
decode, encode, encode_with_cmd, packet_total_len, CodecError, COMPRESSION_THRESHOLD,
};
pub use framing::{OverflowError, PacketReceiver};
#[cfg(feature = "json")]
pub use json::{json_to_value, value_to_json, value_to_json_tagged};
pub use packet::{cmd, Packet, HEADER_SIZE, PROTOCOL_VERSION};
+388
View File
@@ -0,0 +1,388 @@
//! Protocol operation codes, from `lib/core/protocol/opcode_map.dart`.
// ── Session ──────────────────────────────────────────────────────────────
pub const PING: u16 = 1;
pub const DEBUG: u16 = 2;
pub const RECONNECT: u16 = 3;
pub const LOG: u16 = 5;
pub const SESSION_INIT: u16 = 6;
pub const CONTACTS_GET: u16 = 8;
// ── Profile ──────────────────────────────────────────────────────────────
pub const PROFILE: u16 = 16;
// ── Auth ─────────────────────────────────────────────────────────────────
pub const AUTH_REQUEST: u16 = 17;
pub const AUTH: u16 = 18;
pub const LOGIN: u16 = 19;
pub const LOGOUT: u16 = 20;
pub const SYNC: u16 = 21;
pub const CONFIG: u16 = 22;
pub const AUTH_CONFIRM: u16 = 23;
// ── Auth 2FA ─────────────────────────────────────────────────────────────
pub const AUTH_LOGIN_RESTORE_PASSWORD: u16 = 101;
pub const AUTH_2FA_DETAILS: u16 = 104;
pub const EXTERNAL_CALLBACK: u16 = 105;
pub const AUTH_VALIDATE_PASSWORD: u16 = 107;
pub const AUTH_VALIDATE_HINT: u16 = 108;
pub const AUTH_VERIFY_EMAIL: u16 = 109;
pub const AUTH_CHECK_EMAIL: u16 = 110;
pub const AUTH_SET_2FA: u16 = 111;
pub const AUTH_CREATE_TRACK: u16 = 112;
pub const AUTH_CHECK_PASSWORD: u16 = 113;
pub const AUTH_LOGIN_CHECK_PASSWORD: u16 = 115;
pub const AUTH_LOGIN_PROFILE_DELETE: u16 = 116;
// ── Assets ───────────────────────────────────────────────────────────────
pub const PRESET_AVATARS: u16 = 25;
pub const ASSETS_GET: u16 = 26;
pub const ASSETS_UPDATE: u16 = 27;
pub const ASSETS_GET_BY_IDS: u16 = 28;
pub const ASSETS_ADD: u16 = 29;
pub const ASSETS_REMOVE: u16 = 259;
pub const ASSETS_MOVE: u16 = 260;
pub const ASSETS_LIST_MODIFY: u16 = 261;
// ── Contacts ─────────────────────────────────────────────────────────────
pub const CONTACT_INFO: u16 = 32;
pub const CONTACT_ADD: u16 = 33;
pub const CONTACT_UPDATE: u16 = 34;
pub const CONTACT_PRESENCE: u16 = 35;
pub const CONTACT_LIST: u16 = 36;
pub const CONTACT_SEARCH: u16 = 37;
pub const CONTACT_MUTUAL: u16 = 38;
pub const CONTACT_PHOTOS: u16 = 39;
pub const CONTACT_SORT: u16 = 40;
pub const CONTACT_VERIFY: u16 = 42;
pub const REMOVE_CONTACT_PHOTO: u16 = 43;
pub const CONTACT_INFO_BY_PHONE: u16 = 46;
// ── Chats ────────────────────────────────────────────────────────────────
pub const CHAT_INFO: u16 = 48;
pub const CHAT_HISTORY: u16 = 49;
pub const CHAT_MARK: u16 = 50;
pub const CHAT_MEDIA: u16 = 51;
pub const CHAT_DELETE: u16 = 52;
pub const CHATS_LIST: u16 = 53;
pub const CHAT_CLEAR: u16 = 54;
pub const CHAT_UPDATE: u16 = 55;
pub const CHAT_CHECK_LINK: u16 = 56;
pub const CHAT_JOIN: u16 = 57;
pub const CHAT_LEAVE: u16 = 58;
pub const CHAT_MEMBERS: u16 = 59;
pub const PUBLIC_SEARCH: u16 = 60;
pub const CHAT_PERSONAL_CONFIG: u16 = 61;
pub const CHAT_CREATE: u16 = 63;
// ── Messages ─────────────────────────────────────────────────────────────
pub const MSG_SEND: u16 = 64;
pub const MSG_TYPING: u16 = 65;
pub const MSG_DELETE: u16 = 66;
pub const MSG_EDIT: u16 = 67;
pub const CHAT_SEARCH: u16 = 68;
pub const MSG_SHARE_PREVIEW: u16 = 70;
pub const MSG_GET: u16 = 71;
pub const MSG_SEARCH_TOUCH: u16 = 72;
pub const MSG_SEARCH: u16 = 73;
pub const MSG_GET_STAT: u16 = 74;
pub const CHAT_SUBSCRIBE: u16 = 75;
pub const MSG_DELETE_RANGE: u16 = 92;
// ── Reactions ────────────────────────────────────────────────────────────
pub const MSG_REACTION: u16 = 178;
pub const MSG_CANCEL_REACTION: u16 = 179;
pub const MSG_GET_REACTIONS: u16 = 180;
pub const MSG_GET_DETAILED_REACTIONS: u16 = 181;
pub const CHAT_REACTIONS_SETTINGS_SET: u16 = 257;
pub const REACTIONS_SETTINGS_GET_BY_CHAT_ID: u16 = 258;
// ── Calls & Video ────────────────────────────────────────────────────────
pub const VIDEO_CHAT_START: u16 = 76;
pub const CHAT_MEMBERS_UPDATE: u16 = 77;
pub const VIDEO_CHAT_START_ACTIVE: u16 = 78;
pub const VIDEO_CHAT_HISTORY: u16 = 79;
pub const VIDEO_CHAT_DELETE_HISTORY: u16 = 164;
pub const VIDEO_CHAT_CREATE_JOIN_LINK: u16 = 84;
pub const VIDEO_CHAT_JOIN_BY_LINK: u16 = 166;
pub const VIDEO_CHAT_MEMBERS: u16 = 195;
pub const GET_INBOUND_CALLS: u16 = 103;
// ── Media & Files ────────────────────────────────────────────────────────
pub const PHOTO_UPLOAD: u16 = 80;
pub const STICKER_UPLOAD: u16 = 81;
pub const VIDEO_UPLOAD: u16 = 82;
pub const VIDEO_PLAY: u16 = 83;
pub const CHAT_PIN_SET_VISIBILITY: u16 = 86;
pub const FILE_UPLOAD: u16 = 87;
pub const FILE_DOWNLOAD: u16 = 88;
pub const LINK_INFO: u16 = 89;
pub const AUDIO_PLAY: u16 = 301;
// ── Sessions ─────────────────────────────────────────────────────────────
pub const SESSIONS_INFO: u16 = 96;
pub const SESSIONS_CLOSE: u16 = 97;
pub const PHONE_BIND_REQUEST: u16 = 98;
pub const PHONE_BIND_CONFIRM: u16 = 99;
// ── Bots ─────────────────────────────────────────────────────────────────
pub const CHAT_COMPLAIN: u16 = 117;
pub const MSG_SEND_CALLBACK: u16 = 118;
pub const SUSPEND_BOT: u16 = 119;
pub const CHAT_BOT_COMMANDS: u16 = 144;
pub const BOT_INFO: u16 = 145;
// ── Location ─────────────────────────────────────────────────────────────
pub const LOCATION_STOP: u16 = 124;
// ── Mentions ─────────────────────────────────────────────────────────────
pub const GET_LAST_MENTIONS: u16 = 127;
// ── Stickers (creation) ──────────────────────────────────────────────────
pub const STICKER_CREATE: u16 = 193;
pub const STICKER_SUGGEST: u16 = 194;
// ── Notifications (server push) ──────────────────────────────────────────
pub const NOTIF_MESSAGE: u16 = 128;
pub const NOTIF_TYPING: u16 = 129;
pub const NOTIF_MARK: u16 = 130;
pub const NOTIF_CONTACT: u16 = 131;
pub const NOTIF_PRESENCE: u16 = 132;
pub const NOTIF_CONFIG: u16 = 134;
pub const NOTIF_CHAT: u16 = 135;
pub const NOTIF_ATTACH: u16 = 136;
pub const NOTIF_CALL_START: u16 = 137;
pub const NOTIF_CONTACT_SORT: u16 = 139;
pub const NOTIF_MSG_DELETE_RANGE: u16 = 140;
pub const NOTIF_MSG_DELETE: u16 = 142;
pub const NOTIF_CALLBACK_ANSWER: u16 = 143;
pub const NOTIF_LOCATION: u16 = 147;
pub const NOTIF_LOCATION_REQUEST: u16 = 148;
pub const NOTIF_ASSETS_UPDATE: u16 = 150;
pub const NOTIF_DRAFT: u16 = 152;
pub const NOTIF_DRAFT_DISCARD: u16 = 153;
pub const NOTIF_MSG_DELAYED: u16 = 154;
pub const NOTIF_MSG_REACTIONS_CHANGED: u16 = 155;
pub const NOTIF_MSG_YOU_REACTED: u16 = 156;
pub const NOTIF_PROFILE: u16 = 159;
pub const NOTIF_BANNERS: u16 = 292;
pub const NOTIF_FOLDERS: u16 = 277;
// ── Transcription ────────────────────────────────────────────────────────
pub const AUDIO_TRANSCRIPTION: u16 = 202;
pub const TRANSCRIPTION_RESULT: u16 = 293;
// ── Misc ─────────────────────────────────────────────────────────────────
pub const OK_TOKEN: u16 = 158;
pub const WEB_APP_INIT_DATA: u16 = 160;
pub const COMPLAIN: u16 = 161;
pub const COMPLAIN_REASONS_GET: u16 = 162;
pub const DRAFT_SAVE: u16 = 176;
pub const DRAFT_DISCARD: u16 = 177;
pub const CHAT_HIDE: u16 = 196;
pub const CHAT_SEARCH_COMMON_PARTICIPANTS: u16 = 198;
pub const PROFILE_DELETE: u16 = 199;
pub const PROFILE_DELETE_TIME: u16 = 200;
pub const AUTH_QR_APPROVE: u16 = 290;
pub const CHAT_SUGGEST: u16 = 300;
// ── Polls ────────────────────────────────────────────────────────────────
pub const SEND_VOTE: u16 = 304;
pub const VOTERS_LIST_BY_ANSWER: u16 = 305;
pub const GET_POLL_UPDATES: u16 = 306;
// ── Folders ──────────────────────────────────────────────────────────────
pub const FOLDERS_GET: u16 = 272;
pub const FOLDERS_GET_BY_ID: u16 = 273;
pub const FOLDERS_UPDATE: u16 = 274;
pub const FOLDERS_REORDER: u16 = 275;
pub const FOLDERS_DELETE: u16 = 276;
// ── Stories ──────────────────────────────────────────────────────────────
pub const STORIES_LIST: u16 = 208;
pub const STORIES_LIST_BY_OWNER: u16 = 209;
pub const STORIES_GET_BY_OWNER: u16 = 210;
pub const STORIES_GET_STATS: u16 = 211;
pub const STORIES_GET_DETAILED_STATS: u16 = 212;
pub const STORIES_REACT: u16 = 213;
pub const STORIES_MARK: u16 = 214;
pub const STORIES_SEND: u16 = 215;
pub const NOTIF_STORIES_UPDATE: u16 = 216;
pub const STORIES_EDIT: u16 = 217;
pub const STORIES_DELETE: u16 = 218;
pub const STORIES_GET_BY_STORY_ID: u16 = 220;
/// Label for an opcode, or `UNKNOWN(n)` if unmapped.
pub fn name(opcode: u16) -> String {
match opcode {
PING => "PING".into(),
DEBUG => "DEBUG".into(),
RECONNECT => "RECONNECT".into(),
LOG => "LOG".into(),
SESSION_INIT => "SESSION_INIT".into(),
CONTACTS_GET => "CONTACTS_GET".into(),
PROFILE => "PROFILE".into(),
AUTH_REQUEST => "AUTH_REQUEST".into(),
AUTH => "AUTH".into(),
LOGIN => "LOGIN".into(),
LOGOUT => "LOGOUT".into(),
SYNC => "SYNC".into(),
CONFIG => "CONFIG".into(),
AUTH_CONFIRM => "AUTH_CONFIRM".into(),
AUTH_LOGIN_RESTORE_PASSWORD => "AUTH_LOGIN_RESTORE_PASSWORD".into(),
AUTH_2FA_DETAILS => "AUTH_2FA_DETAILS".into(),
EXTERNAL_CALLBACK => "EXTERNAL_CALLBACK".into(),
AUTH_VALIDATE_PASSWORD => "AUTH_VALIDATE_PASSWORD".into(),
AUTH_VALIDATE_HINT => "AUTH_VALIDATE_HINT".into(),
AUTH_VERIFY_EMAIL => "AUTH_VERIFY_EMAIL".into(),
AUTH_CHECK_EMAIL => "AUTH_CHECK_EMAIL".into(),
AUTH_SET_2FA => "AUTH_SET_2FA".into(),
AUTH_CREATE_TRACK => "AUTH_CREATE_TRACK".into(),
AUTH_CHECK_PASSWORD => "AUTH_CHECK_PASSWORD".into(),
AUTH_LOGIN_CHECK_PASSWORD => "AUTH_LOGIN_CHECK_PASSWORD".into(),
AUTH_LOGIN_PROFILE_DELETE => "AUTH_LOGIN_PROFILE_DELETE".into(),
PRESET_AVATARS => "PRESET_AVATARS".into(),
ASSETS_GET => "ASSETS_GET".into(),
ASSETS_UPDATE => "ASSETS_UPDATE".into(),
ASSETS_GET_BY_IDS => "ASSETS_GET_BY_IDS".into(),
ASSETS_ADD => "ASSETS_ADD".into(),
ASSETS_REMOVE => "ASSETS_REMOVE".into(),
ASSETS_MOVE => "ASSETS_MOVE".into(),
ASSETS_LIST_MODIFY => "ASSETS_LIST_MODIFY".into(),
CONTACT_INFO => "CONTACT_INFO".into(),
CONTACT_ADD => "CONTACT_ADD".into(),
CONTACT_UPDATE => "CONTACT_UPDATE".into(),
CONTACT_PRESENCE => "CONTACT_PRESENCE".into(),
CONTACT_LIST => "CONTACT_LIST".into(),
CONTACT_SEARCH => "CONTACT_SEARCH".into(),
CONTACT_MUTUAL => "CONTACT_MUTUAL".into(),
CONTACT_PHOTOS => "CONTACT_PHOTOS".into(),
CONTACT_SORT => "CONTACT_SORT".into(),
CONTACT_VERIFY => "CONTACT_VERIFY".into(),
REMOVE_CONTACT_PHOTO => "REMOVE_CONTACT_PHOTO".into(),
CONTACT_INFO_BY_PHONE => "CONTACT_INFO_BY_PHONE".into(),
CHAT_INFO => "CHAT_INFO".into(),
CHAT_HISTORY => "CHAT_HISTORY".into(),
CHAT_MARK => "CHAT_MARK".into(),
CHAT_MEDIA => "CHAT_MEDIA".into(),
CHAT_DELETE => "CHAT_DELETE".into(),
CHATS_LIST => "CHATS_LIST".into(),
CHAT_CLEAR => "CHAT_CLEAR".into(),
CHAT_UPDATE => "CHAT_UPDATE".into(),
CHAT_CHECK_LINK => "CHAT_CHECK_LINK".into(),
CHAT_JOIN => "CHAT_JOIN".into(),
CHAT_LEAVE => "CHAT_LEAVE".into(),
CHAT_MEMBERS => "CHAT_MEMBERS".into(),
PUBLIC_SEARCH => "PUBLIC_SEARCH".into(),
CHAT_PERSONAL_CONFIG => "CHAT_PERSONAL_CONFIG".into(),
CHAT_CREATE => "CHAT_CREATE".into(),
MSG_SEND => "MSG_SEND".into(),
MSG_TYPING => "MSG_TYPING".into(),
MSG_DELETE => "MSG_DELETE".into(),
MSG_EDIT => "MSG_EDIT".into(),
CHAT_SEARCH => "CHAT_SEARCH".into(),
MSG_SHARE_PREVIEW => "MSG_SHARE_PREVIEW".into(),
MSG_GET => "MSG_GET".into(),
MSG_SEARCH_TOUCH => "MSG_SEARCH_TOUCH".into(),
MSG_SEARCH => "MSG_SEARCH".into(),
MSG_GET_STAT => "MSG_GET_STAT".into(),
CHAT_SUBSCRIBE => "CHAT_SUBSCRIBE".into(),
MSG_DELETE_RANGE => "MSG_DELETE_RANGE".into(),
MSG_REACTION => "MSG_REACTION".into(),
MSG_CANCEL_REACTION => "MSG_CANCEL_REACTION".into(),
MSG_GET_REACTIONS => "MSG_GET_REACTIONS".into(),
MSG_GET_DETAILED_REACTIONS => "MSG_GET_DETAILED_REACTIONS".into(),
CHAT_REACTIONS_SETTINGS_SET => "CHAT_REACTIONS_SETTINGS_SET".into(),
REACTIONS_SETTINGS_GET_BY_CHAT_ID => "REACTIONS_SETTINGS_GET_BY_CHAT_ID".into(),
VIDEO_CHAT_START => "VIDEO_CHAT_START".into(),
CHAT_MEMBERS_UPDATE => "CHAT_MEMBERS_UPDATE".into(),
VIDEO_CHAT_START_ACTIVE => "VIDEO_CHAT_START_ACTIVE".into(),
VIDEO_CHAT_HISTORY => "VIDEO_CHAT_HISTORY".into(),
VIDEO_CHAT_DELETE_HISTORY => "VIDEO_CHAT_DELETE_HISTORY".into(),
VIDEO_CHAT_CREATE_JOIN_LINK => "VIDEO_CHAT_CREATE_JOIN_LINK".into(),
VIDEO_CHAT_JOIN_BY_LINK => "VIDEO_CHAT_JOIN_BY_LINK".into(),
VIDEO_CHAT_MEMBERS => "VIDEO_CHAT_MEMBERS".into(),
GET_INBOUND_CALLS => "GET_INBOUND_CALLS".into(),
PHOTO_UPLOAD => "PHOTO_UPLOAD".into(),
STICKER_UPLOAD => "STICKER_UPLOAD".into(),
VIDEO_UPLOAD => "VIDEO_UPLOAD".into(),
VIDEO_PLAY => "VIDEO_PLAY".into(),
CHAT_PIN_SET_VISIBILITY => "CHAT_PIN_SET_VISIBILITY".into(),
FILE_UPLOAD => "FILE_UPLOAD".into(),
FILE_DOWNLOAD => "FILE_DOWNLOAD".into(),
LINK_INFO => "LINK_INFO".into(),
AUDIO_PLAY => "AUDIO_PLAY".into(),
SESSIONS_INFO => "SESSIONS_INFO".into(),
SESSIONS_CLOSE => "SESSIONS_CLOSE".into(),
PHONE_BIND_REQUEST => "PHONE_BIND_REQUEST".into(),
PHONE_BIND_CONFIRM => "PHONE_BIND_CONFIRM".into(),
CHAT_COMPLAIN => "CHAT_COMPLAIN".into(),
MSG_SEND_CALLBACK => "MSG_SEND_CALLBACK".into(),
SUSPEND_BOT => "SUSPEND_BOT".into(),
CHAT_BOT_COMMANDS => "CHAT_BOT_COMMANDS".into(),
BOT_INFO => "BOT_INFO".into(),
LOCATION_STOP => "LOCATION_STOP".into(),
GET_LAST_MENTIONS => "GET_LAST_MENTIONS".into(),
STICKER_CREATE => "STICKER_CREATE".into(),
STICKER_SUGGEST => "STICKER_SUGGEST".into(),
NOTIF_MESSAGE => "NOTIF_MESSAGE".into(),
NOTIF_TYPING => "NOTIF_TYPING".into(),
NOTIF_MARK => "NOTIF_MARK".into(),
NOTIF_CONTACT => "NOTIF_CONTACT".into(),
NOTIF_PRESENCE => "NOTIF_PRESENCE".into(),
NOTIF_CONFIG => "NOTIF_CONFIG".into(),
NOTIF_CHAT => "NOTIF_CHAT".into(),
NOTIF_ATTACH => "NOTIF_ATTACH".into(),
NOTIF_CALL_START => "NOTIF_CALL_START".into(),
NOTIF_CONTACT_SORT => "NOTIF_CONTACT_SORT".into(),
NOTIF_MSG_DELETE_RANGE => "NOTIF_MSG_DELETE_RANGE".into(),
NOTIF_MSG_DELETE => "NOTIF_MSG_DELETE".into(),
NOTIF_CALLBACK_ANSWER => "NOTIF_CALLBACK_ANSWER".into(),
NOTIF_LOCATION => "NOTIF_LOCATION".into(),
NOTIF_LOCATION_REQUEST => "NOTIF_LOCATION_REQUEST".into(),
NOTIF_ASSETS_UPDATE => "NOTIF_ASSETS_UPDATE".into(),
NOTIF_DRAFT => "NOTIF_DRAFT".into(),
NOTIF_DRAFT_DISCARD => "NOTIF_DRAFT_DISCARD".into(),
NOTIF_MSG_DELAYED => "NOTIF_MSG_DELAYED".into(),
NOTIF_MSG_REACTIONS_CHANGED => "NOTIF_MSG_REACTIONS_CHANGED".into(),
NOTIF_MSG_YOU_REACTED => "NOTIF_MSG_YOU_REACTED".into(),
NOTIF_PROFILE => "NOTIF_PROFILE".into(),
NOTIF_BANNERS => "NOTIF_BANNERS".into(),
NOTIF_FOLDERS => "NOTIF_FOLDERS".into(),
AUDIO_TRANSCRIPTION => "AUDIO_TRANSCRIPTION".into(),
TRANSCRIPTION_RESULT => "TRANSCRIPTION_RESULT".into(),
OK_TOKEN => "OK_TOKEN".into(),
WEB_APP_INIT_DATA => "WEB_APP_INIT_DATA".into(),
COMPLAIN => "COMPLAIN".into(),
COMPLAIN_REASONS_GET => "COMPLAIN_REASONS_GET".into(),
DRAFT_SAVE => "DRAFT_SAVE".into(),
DRAFT_DISCARD => "DRAFT_DISCARD".into(),
CHAT_HIDE => "CHAT_HIDE".into(),
CHAT_SEARCH_COMMON_PARTICIPANTS => "CHAT_SEARCH_COMMON_PARTICIPANTS".into(),
PROFILE_DELETE => "PROFILE_DELETE".into(),
PROFILE_DELETE_TIME => "PROFILE_DELETE_TIME".into(),
AUTH_QR_APPROVE => "AUTH_QR_APPROVE".into(),
CHAT_SUGGEST => "CHAT_SUGGEST".into(),
SEND_VOTE => "SEND_VOTE".into(),
VOTERS_LIST_BY_ANSWER => "VOTERS_LIST_BY_ANSWER".into(),
GET_POLL_UPDATES => "GET_POLL_UPDATES".into(),
FOLDERS_GET => "FOLDERS_GET".into(),
FOLDERS_GET_BY_ID => "FOLDERS_GET_BY_ID".into(),
FOLDERS_UPDATE => "FOLDERS_UPDATE".into(),
FOLDERS_REORDER => "FOLDERS_REORDER".into(),
FOLDERS_DELETE => "FOLDERS_DELETE".into(),
STORIES_LIST => "STORIES_LIST".into(),
STORIES_LIST_BY_OWNER => "STORIES_LIST_BY_OWNER_ID".into(),
STORIES_GET_BY_OWNER => "STORIES_GET_BY_OWNER_ID".into(),
STORIES_GET_STATS => "STORIES_GET_STATS".into(),
STORIES_GET_DETAILED_STATS => "STORIES_GET_DETAILED_STATS".into(),
STORIES_REACT => "STORIES_REACT".into(),
STORIES_MARK => "STORIES_MARK".into(),
STORIES_SEND => "STORIES_SEND".into(),
NOTIF_STORIES_UPDATE => "NOTIF_STORIES_UPDATE".into(),
STORIES_EDIT => "STORIES_EDIT".into(),
STORIES_DELETE => "STORIES_DELETE".into(),
STORIES_GET_BY_STORY_ID => "STORIES_GET_BY_STORY_ID".into(),
other => format!("UNKNOWN({other})"),
}
}
+74
View File
@@ -0,0 +1,74 @@
/// Wire header, 10 bytes big-endian:
///
/// ```text
/// [0] ver protocol version (default 10)
/// [1] cmd command type
/// [2..4] seq sequence number
/// [4..6] opcode operation code
/// [6..10] packedLen high byte = compression flag, low 24 bits = payload length
/// [10..] payload MessagePack, optionally compressed
/// ```
pub const HEADER_SIZE: usize = 10;
pub const PROTOCOL_VERSION: u8 = 10;
/// Request and push both carry cmd == 0; direction disambiguates.
pub mod cmd {
pub const REQUEST: u8 = 0;
pub const PUSH: u8 = 0;
pub const OK: u8 = 1;
pub const NOT_FOUND: u8 = 2;
pub const ERROR: u8 = 3;
}
/// Decoded packet. `payload` is decompressed MessagePack bytes (empty for no
/// body); the caller turns it into a concrete value via [`Packet::value`], so
/// the core stays representation-agnostic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Packet {
pub ver: u8,
pub cmd: u8,
pub seq: u16,
pub opcode: u16,
pub payload: Vec<u8>,
}
impl Packet {
pub fn is_ok(&self) -> bool {
self.cmd == cmd::OK
}
pub fn is_error(&self) -> bool {
self.cmd == cmd::ERROR
}
pub fn is_not_found(&self) -> bool {
self.cmd == cmd::NOT_FOUND
}
/// Push has cmd == 0 like an outgoing request; only meaningful on inbound.
pub fn is_push(&self) -> bool {
self.cmd == cmd::PUSH
}
/// Empty payload decodes to `Value::Nil`.
pub fn value(&self) -> Result<rmpv::Value, rmpv::decode::Error> {
if self.payload.is_empty() {
return Ok(rmpv::Value::Nil);
}
rmpv::decode::read_value(&mut &self.payload[..])
}
/// payload as JSON, for logs (lossy, see [`crate::protocol::json`]).
#[cfg(feature = "json")]
pub fn json(&self) -> Result<serde_json::Value, rmpv::decode::Error> {
self.value().map(|v| super::json::value_to_json(&v))
}
/// payload as JSON with binary tagged `{"$bin":...}`. round-trips, unlike
/// [`Packet::json`] (see [`crate::protocol::json`]).
#[cfg(feature = "json")]
pub fn json_tagged(&self) -> Result<serde_json::Value, rmpv::decode::Error> {
self.value().map(|v| super::json::value_to_json_tagged(&v))
}
}
+69
View File
@@ -0,0 +1,69 @@
use std::time::Duration;
use crate::transport::ClientConfig;
/// `userAgent` sub-map of the sessionInit handshake. Host supplies the device
/// values; field names/nesting live here so every client sends the same shape.
#[derive(Debug, Clone)]
pub struct UserAgent {
pub device_type: String,
pub app_version: String,
pub os_version: String,
pub timezone: String,
pub screen: String,
pub push_device_type: String,
pub arch: String,
pub locale: String,
pub build_number: i64,
pub device_name: String,
pub device_locale: String,
/// `isPwa` — отправляется только если задано. Веб-клиент MAX шлёт его с
/// августа 2026; сервер по нему решает, слать ли web push.
pub is_pwa: Option<bool>,
/// `headerUserAgent` — строка UA браузера, тоже только если задано.
pub header_user_agent: Option<String>,
}
impl UserAgent {
/// CDN/HTTP User-Agent for media uploads, from the same device fields sent in
/// the handshake (opcode 6) so both agree.
/// e.g. `OKMessages/26.20.2 (Android 14; Google Pixel 8; xxhdpi 420dpi 1080x2400)`
pub fn http_user_agent(&self) -> String {
format!(
"OKMessages/{} ({}; {}; {})",
self.app_version, self.os_version, self.device_name, self.screen
)
}
}
/// inputs for the `sessionInit` (opcode 6) payload.
#[derive(Debug, Clone)]
pub struct HandshakeConfig {
pub instance_id: String,
pub device_id: String,
pub client_session_id: i64,
pub user_agent: UserAgent,
}
#[derive(Debug, Clone)]
pub struct SessionConfig {
pub client: ClientConfig,
pub handshake: HandshakeConfig,
/// keepalive ping interval once online
pub ping_interval: Duration,
/// `interactive` flag in the ping payload (false = ghost/offline mode)
pub ping_interactive: bool,
pub auto_reconnect: bool,
}
impl SessionConfig {
pub fn new(client: ClientConfig, handshake: HandshakeConfig) -> Self {
Self {
client,
handshake,
ping_interval: Duration::from_secs(30),
ping_interactive: true,
auto_reconnect: true,
}
}
}
+414
View File
@@ -0,0 +1,414 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use rmpv::Value;
use tokio::sync::{broadcast, oneshot, watch};
use tokio::task::JoinHandle;
use tokio::time::sleep;
use super::config::{HandshakeConfig, SessionConfig};
use crate::protocol::opcodes;
use crate::protocol::packet::Packet;
use crate::transport::{Client, TransportError, WireTap};
const PUSH_CHANNEL_CAPACITY: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionState {
Disconnected,
Connecting,
Connected,
Online,
}
/// fields pulled from the sessionInit response; `payload` keeps the raw map so
/// the host can grab anything else (`reg-country-code`, `location`, ...).
#[derive(Debug, Clone)]
pub struct HandshakeInfo {
pub calls_seed: Option<i64>,
pub device_name: Option<String>,
pub payload: Value,
}
impl HandshakeInfo {
fn from_packet(packet: &Packet) -> Self {
let payload = packet.value().unwrap_or(Value::Nil);
Self {
calls_seed: map_i64(&payload, "callsSeed"),
device_name: map_string(&payload, "device_name"),
payload,
}
}
}
struct Shared {
config: SessionConfig,
wire_tap: Option<WireTap>,
ping_interactive: AtomicBool,
client: Mutex<Option<Arc<Client>>>,
state_tx: watch::Sender<SessionState>,
push_tx: broadcast::Sender<Packet>,
stop: AtomicBool,
}
impl Shared {
fn set_state(&self, state: SessionState) {
self.state_tx.send_if_modified(|current| {
if *current == state {
false
} else {
*current = state;
true
}
});
}
}
/// Managed session: connects, handshakes, pings to stay alive, and optionally
/// reconnects with backoff. Requests and pushes route through whichever
/// underlying [`Client`] is currently connected.
pub struct Session {
shared: Arc<Shared>,
supervisor: Mutex<Option<JoinHandle<()>>>,
}
impl Session {
pub fn new(config: SessionConfig) -> Self {
Self::with_wire_tap(config, None)
}
/// like [`Session::new`], but `wire_tap` sees every packet both ways, across
/// reconnects.
pub fn with_wire_tap(config: SessionConfig, wire_tap: Option<WireTap>) -> Self {
let (state_tx, _) = watch::channel(SessionState::Disconnected);
let (push_tx, _) = broadcast::channel(PUSH_CHANNEL_CAPACITY);
let ping_interactive = AtomicBool::new(config.ping_interactive);
Self {
shared: Arc::new(Shared {
config,
wire_tap,
ping_interactive,
client: Mutex::new(None),
state_tx,
push_tx,
stop: AtomicBool::new(false),
}),
supervisor: Mutex::new(None),
}
}
/// resolves once online. if the first attempt fails with `auto_reconnect`
/// set, the supervisor keeps retrying in the background but this call still
/// returns that first error.
pub async fn connect(&self) -> Result<HandshakeInfo, TransportError> {
self.shared.stop.store(false, Ordering::SeqCst);
let (first_tx, first_rx) = oneshot::channel();
let shared = self.shared.clone();
let handle = tokio::spawn(supervise(shared, first_tx));
*self.supervisor.lock().unwrap() = Some(handle);
first_rx
.await
.map_err(|_| TransportError::ConnectionClosed)?
}
pub async fn request(&self, opcode: u16, payload: &[u8]) -> Result<Packet, TransportError> {
let client = self.shared.client.lock().unwrap().clone();
match client {
Some(c) => c.request(opcode, payload).await,
None => Err(TransportError::ConnectionClosed),
}
}
/// like [`Session::request`], but returns the raw response packet. an error
/// packet comes back as `Ok` with its payload, not mapped to `Err`.
pub async fn request_raw(&self, opcode: u16, payload: &[u8]) -> Result<Packet, TransportError> {
let client = self.shared.client.lock().unwrap().clone();
match client {
Some(c) => c.request_raw(opcode, payload).await,
None => Err(TransportError::ConnectionClosed),
}
}
pub fn send(&self, opcode: u16, payload: &[u8]) -> Result<u16, TransportError> {
let client = self.shared.client.lock().unwrap().clone();
match client {
Some(c) => c.send(opcode, payload),
None => Err(TransportError::ConnectionClosed),
}
}
/// keepalive `interactive` flag (foreground/background hint).
pub fn ping_interactive(&self) -> bool {
self.shared.ping_interactive.load(Ordering::Relaxed)
}
/// flip the keepalive `interactive` flag on a live session. later pings pick
/// it up; one goes out now (best-effort) so the server hears it right away.
pub fn set_ping_interactive(&self, interactive: bool) {
self.shared
.ping_interactive
.store(interactive, Ordering::Relaxed);
let _ = self.send(opcodes::PING, &build_ping_payload(interactive));
}
/// stream survives reconnects; pushes from every underlying connection land here.
pub fn subscribe(&self) -> broadcast::Receiver<Packet> {
self.shared.push_tx.subscribe()
}
pub fn state(&self) -> SessionState {
*self.shared.state_tx.borrow()
}
/// HTTP User-Agent for media uploads, from this session's handshake device
/// (opcode 6) so uploads look like the same device.
pub fn http_user_agent(&self) -> String {
self.shared.config.handshake.user_agent.http_user_agent()
}
pub fn subscribe_state(&self) -> watch::Receiver<SessionState> {
self.shared.state_tx.subscribe()
}
/// stop and disable auto-reconnect.
pub fn disconnect(&self) {
self.shared.stop.store(true, Ordering::SeqCst);
if let Some(client) = self.shared.client.lock().unwrap().take() {
client.close();
}
if let Some(handle) = self.supervisor.lock().unwrap().take() {
handle.abort();
}
self.shared.set_state(SessionState::Disconnected);
}
}
impl Drop for Session {
fn drop(&mut self) {
self.disconnect();
}
}
/// supervisor loop: connect, handshake, maintain, backoff, reconnect.
async fn supervise(
shared: Arc<Shared>,
first_tx: oneshot::Sender<Result<HandshakeInfo, TransportError>>,
) {
let mut first_tx = Some(first_tx);
let mut attempt: u32 = 0;
loop {
if shared.stop.load(Ordering::SeqCst) {
break;
}
shared.set_state(SessionState::Connecting);
match connect_and_handshake(&shared).await {
Ok((client, info)) => {
attempt = 0;
*shared.client.lock().unwrap() = Some(client.clone());
shared.set_state(SessionState::Online);
if let Some(tx) = first_tx.take() {
let _ = tx.send(Ok(info));
}
maintain(&shared, client).await;
*shared.client.lock().unwrap() = None;
shared.set_state(SessionState::Disconnected);
}
Err(e) => {
shared.set_state(SessionState::Disconnected);
if let Some(tx) = first_tx.take() {
let _ = tx.send(Err(e));
}
}
}
if shared.stop.load(Ordering::SeqCst) || !shared.config.auto_reconnect {
break;
}
let delay = reconnect_delay(attempt);
attempt = attempt.saturating_add(1);
sleep(delay).await;
}
}
async fn connect_and_handshake(
shared: &Shared,
) -> Result<(Arc<Client>, HandshakeInfo), TransportError> {
let client = Arc::new(
Client::connect_with_tap(shared.config.client.clone(), shared.wire_tap.clone()).await?,
);
let payload = build_handshake_payload(&shared.config.handshake);
let response = client.request(opcodes::SESSION_INIT, &payload).await?;
if !response.is_ok() {
client.close();
return Err(TransportError::Server {
message: "handshake rejected by server".to_string(),
error_key: None,
});
}
let info = HandshakeInfo::from_packet(&response);
Ok((client, info))
}
/// pings on the interval, forwards pushes into the session-wide channel,
/// returns once the connection drops.
async fn maintain(shared: &Arc<Shared>, client: Arc<Client>) {
let ping_client = client.clone();
let interval = shared.config.ping_interval;
let ping_shared = shared.clone();
let ping_task = tokio::spawn(async move {
// first keepalive fires one interval after connect, not immediately
let mut tick = tokio::time::interval_at(tokio::time::Instant::now() + interval, interval);
loop {
tick.tick().await;
let interactive = ping_shared.ping_interactive.load(Ordering::Relaxed);
if ping_client
.send(opcodes::PING, &build_ping_payload(interactive))
.is_err()
{
break;
}
}
});
let mut client_pushes = client.subscribe();
let push_tx = shared.push_tx.clone();
let forward_task = tokio::spawn(async move {
while let Ok(packet) = client_pushes.recv().await {
let _ = push_tx.send(packet);
}
});
let mut connected = client.subscribe_connected();
loop {
let is_connected = *connected.borrow_and_update();
if !is_connected {
break;
}
if connected.changed().await.is_err() {
break;
}
}
ping_task.abort();
forward_task.abort();
client.close();
}
/// `(2 * 2^min(attempt,3)).clamp(2, 15)` => 2, 4, 8, 15, 15, ...
fn reconnect_delay(attempt: u32) -> Duration {
let shift = attempt.min(3);
let secs = (2u64 * (1u64 << shift)).clamp(2, 15);
Duration::from_secs(secs)
}
fn build_handshake_payload(cfg: &HandshakeConfig) -> Vec<u8> {
let ua = &cfg.user_agent;
let mut user_agent_fields = vec![
(
Value::from("deviceType"),
Value::from(ua.device_type.clone()),
),
(
Value::from("appVersion"),
Value::from(ua.app_version.clone()),
),
(Value::from("osVersion"), Value::from(ua.os_version.clone())),
(Value::from("timezone"), Value::from(ua.timezone.clone())),
(Value::from("screen"), Value::from(ua.screen.clone())),
(
Value::from("pushDeviceType"),
Value::from(ua.push_device_type.clone()),
),
(Value::from("locale"), Value::from(ua.locale.clone())),
(
Value::from("deviceName"),
Value::from(ua.device_name.clone()),
),
(
Value::from("deviceLocale"),
Value::from(ua.device_locale.clone()),
),
];
if let Some(header_user_agent) = &ua.header_user_agent {
user_agent_fields.push((
Value::from("headerUserAgent"),
Value::from(header_user_agent.clone()),
));
}
if let Some(is_pwa) = ua.is_pwa {
user_agent_fields.push((Value::from("isPwa"), Value::from(is_pwa)));
}
if !ua.arch.is_empty() {
user_agent_fields.push((Value::from("arch"), Value::from(ua.arch.clone())));
}
if ua.build_number != 0 {
user_agent_fields.push((Value::from("buildNumber"), Value::from(ua.build_number)));
}
let user_agent = Value::Map(user_agent_fields);
let mut fields = Vec::with_capacity(4);
if !cfg.instance_id.is_empty() {
fields.push((
Value::from("mt_instanceid"),
Value::from(cfg.instance_id.clone()),
));
}
fields.push((Value::from("userAgent"), user_agent));
if cfg.client_session_id != 0 {
fields.push((
Value::from("clientSessionId"),
Value::from(cfg.client_session_id),
));
}
fields.push((Value::from("deviceId"), Value::from(cfg.device_id.clone())));
encode_value(&Value::Map(fields))
}
fn build_ping_payload(interactive: bool) -> Vec<u8> {
let payload = Value::Map(vec![(Value::from("interactive"), Value::from(interactive))]);
encode_value(&payload)
}
fn encode_value(value: &Value) -> Vec<u8> {
let mut out = Vec::new();
rmpv::encode::write_value(&mut out, value).expect("in-memory msgpack write cannot fail");
out
}
fn map_i64(value: &Value, key: &str) -> Option<i64> {
value
.as_map()?
.iter()
.find(|(k, _)| k.as_str() == Some(key))
.and_then(|(_, v)| v.as_i64())
}
fn map_string(value: &Value, key: &str) -> Option<String> {
value
.as_map()?
.iter()
.find(|(k, _)| k.as_str() == Some(key))
.and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
}
#[cfg(test)]
mod tests {
use super::reconnect_delay;
use std::time::Duration;
#[test]
fn backoff_matches_dart_schedule() {
assert_eq!(reconnect_delay(0), Duration::from_secs(2));
assert_eq!(reconnect_delay(1), Duration::from_secs(4));
assert_eq!(reconnect_delay(2), Duration::from_secs(8));
assert_eq!(reconnect_delay(3), Duration::from_secs(15));
assert_eq!(reconnect_delay(4), Duration::from_secs(15));
assert_eq!(reconnect_delay(99), Duration::from_secs(15));
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Session state machine over the transport client: connect, sessionInit
//! handshake, online, with keepalive pings and exponential-backoff reconnect.
//! Host supplies device values; the wire shape and connect/ping/reconnect
//! sequence live here.
mod config;
mod manager;
pub use config::{HandshakeConfig, SessionConfig, UserAgent};
pub use manager::{HandshakeInfo, Session, SessionState};
+255
View File
@@ -0,0 +1,255 @@
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{broadcast, mpsc, watch};
use tokio::task::JoinHandle;
use tokio::time::timeout;
use super::dispatcher::Dispatcher;
use super::error::TransportError;
use super::proxy::{connect_tcp, ProxyConfig};
use super::tls::build_connector;
use super::wiretap::{Direction, WireTap};
use crate::protocol::codec;
use crate::protocol::framing::PacketReceiver;
use crate::protocol::packet::{cmd, Packet};
const READ_CHUNK: usize = 64 * 1024;
const PUSH_CHANNEL_CAPACITY: usize = 256;
/// `host` doubles as the TLS server name (SNI).
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub host: String,
pub port: u16,
/// accept any TLS cert, debug only
pub insecure_tls: bool,
pub connect_timeout: Duration,
pub request_timeout: Duration,
/// route the connection through a proxy (HTTP CONNECT or SOCKS5)
pub proxy: Option<ProxyConfig>,
}
impl ClientConfig {
pub fn new(host: impl Into<String>, port: u16) -> Self {
Self {
host: host.into(),
port,
insecure_tls: false,
connect_timeout: Duration::from_secs(15),
request_timeout: Duration::from_secs(30),
proxy: None,
}
}
pub fn insecure(mut self, insecure: bool) -> Self {
self.insecure_tls = insecure;
self
}
pub fn proxy(mut self, proxy: Option<ProxyConfig>) -> Self {
self.proxy = proxy;
self
}
}
/// Async client over the persistent TLS connection: [`Client::request`] awaits
/// the matching response, [`Client::subscribe`] observes server pushes.
pub struct Client {
seq: AtomicU16,
write_tx: mpsc::UnboundedSender<Vec<u8>>,
dispatcher: Arc<Dispatcher>,
request_timeout: Duration,
connected_tx: watch::Sender<bool>,
tap: Option<WireTap>,
tasks: Vec<JoinHandle<()>>,
}
impl Client {
pub async fn connect(config: ClientConfig) -> Result<Self, TransportError> {
Self::connect_with_tap(config, None).await
}
/// like [`Client::connect`], but hands every packet, both ways, to `tap`.
pub async fn connect_with_tap(
config: ClientConfig,
tap: Option<WireTap>,
) -> Result<Self, TransportError> {
let connector = build_connector(config.insecure_tls)?;
let tcp = connect_tcp(
&config.host,
config.port,
config.connect_timeout,
config.proxy.as_ref(),
)
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::TimedOut => TransportError::ConnectTimeout,
_ => TransportError::Io(e),
})?;
let server_name = rustls::pki_types::ServerName::try_from(config.host.clone())
.map_err(|e| TransportError::Config(format!("invalid server name: {e}")))?;
let tls = timeout(config.connect_timeout, connector.connect(server_name, tcp))
.await
.map_err(|_| TransportError::ConnectTimeout)?
.map_err(|e| TransportError::Tls(e.to_string()))?;
let (mut read_half, mut write_half) = tokio::io::split(tls);
let dispatcher = Arc::new(Dispatcher::new(PUSH_CHANNEL_CAPACITY));
let (connected_tx, _) = watch::channel(true);
let (write_tx, mut write_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let writer = tokio::spawn(async move {
while let Some(bytes) = write_rx.recv().await {
if write_half.write_all(&bytes).await.is_err() {
break;
}
if write_half.flush().await.is_err() {
break;
}
}
});
let reader_dispatcher = dispatcher.clone();
let reader_connected = connected_tx.clone();
let reader_tap = tap.clone();
let reader = tokio::spawn(async move {
let mut receiver = PacketReceiver::new();
let mut buf = vec![0u8; READ_CHUNK];
loop {
match read_half.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let packets = match receiver.feed(&buf[..n]) {
Ok(p) => p,
Err(_) => break,
};
for raw in packets {
match codec::decode(&raw) {
Ok(packet) => {
if let Some(t) = &reader_tap {
t(
Direction::In,
packet.cmd,
packet.opcode,
packet.seq,
&packet.payload,
);
}
reader_dispatcher.dispatch(packet);
}
Err(_) => continue,
}
}
}
}
}
reader_connected.send_replace(false);
reader_dispatcher.fail_all();
});
Ok(Self {
seq: AtomicU16::new(0),
write_tx,
dispatcher,
request_timeout: config.request_timeout,
connected_tx,
tap,
tasks: vec![writer, reader],
})
}
/// pre-increment wrapping at 2^16, so the first request is seq 1
fn next_seq(&self) -> u16 {
self.seq.fetch_add(1, Ordering::Relaxed).wrapping_add(1)
}
pub fn is_connected(&self) -> bool {
*self.connected_tx.borrow()
}
/// flips to `false` when the connection drops; drives supervisor reconnect
pub fn subscribe_connected(&self) -> watch::Receiver<bool> {
self.connected_tx.subscribe()
}
/// `payload` is already-serialized msgpack. not-found comes back as `Ok`
/// (see [`Packet::is_not_found`]); only an error packet is `Err`.
pub async fn request(&self, opcode: u16, payload: &[u8]) -> Result<Packet, TransportError> {
let packet = self.request_raw(opcode, payload).await?;
if packet.is_error() {
Err(super::dispatcher::error_from_payload(&packet))
} else {
Ok(packet)
}
}
/// Like [`Client::request`], but returns the raw response packet for any
/// command. an error packet comes back as `Ok` with its payload, not mapped
/// to `Err`. Only a lost connection or timeout is `Err`.
pub async fn request_raw(&self, opcode: u16, payload: &[u8]) -> Result<Packet, TransportError> {
if !self.is_connected() {
return Err(TransportError::ConnectionClosed);
}
let seq = self.next_seq();
if let Some(t) = &self.tap {
t(Direction::Out, cmd::REQUEST, opcode, seq, payload);
}
let bytes = codec::encode(opcode, payload, seq);
let rx = self.dispatcher.register(seq);
self.write_tx
.send(bytes)
.map_err(|_| TransportError::ConnectionClosed)?;
match timeout(self.request_timeout, rx).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(TransportError::ConnectionClosed),
Err(_) => Err(TransportError::Timeout),
}
}
/// Fire-and-forget, no response tracking (typing indicators, pings).
/// Returns the assigned seq.
pub fn send(&self, opcode: u16, payload: &[u8]) -> Result<u16, TransportError> {
if !self.is_connected() {
return Err(TransportError::ConnectionClosed);
}
let seq = self.next_seq();
if let Some(t) = &self.tap {
t(Direction::Out, cmd::REQUEST, opcode, seq, payload);
}
let bytes = codec::encode(opcode, payload, seq);
self.write_tx
.send(bytes)
.map_err(|_| TransportError::ConnectionClosed)?;
Ok(seq)
}
/// Each subscriber gets every push sent after it subscribes.
pub fn subscribe(&self) -> broadcast::Receiver<Packet> {
self.dispatcher.subscribe()
}
pub fn close(&self) {
self.connected_tx.send_replace(false);
self.dispatcher.fail_all();
for task in &self.tasks {
task.abort();
}
}
}
impl Drop for Client {
fn drop(&mut self) {
self.close();
}
}
@@ -0,0 +1,105 @@
use std::collections::HashMap;
use std::sync::Mutex;
use tokio::sync::{broadcast, oneshot};
use super::error::TransportError;
use crate::protocol::packet::{cmd, Packet};
type PendingResult = Result<Packet, TransportError>;
/// Routes incoming packets: responses match a waiting request by `seq`, pushes
/// (cmd == 0) fan out to all subscribers.
pub struct Dispatcher {
pending: Mutex<HashMap<u16, oneshot::Sender<PendingResult>>>,
push_tx: broadcast::Sender<Packet>,
}
impl Dispatcher {
pub fn new(push_capacity: usize) -> Self {
let (push_tx, _) = broadcast::channel(push_capacity);
Self {
pending: Mutex::new(HashMap::new()),
push_tx,
}
}
/// If `seq` was reused before its response arrived, the previous waiter fails.
pub fn register(&self, seq: u16) -> oneshot::Receiver<PendingResult> {
let (tx, rx) = oneshot::channel();
let mut pending = self.pending.lock().unwrap();
if let Some(old) = pending.insert(seq, tx) {
let _ = old.send(Err(TransportError::ConnectionClosed));
}
rx
}
pub fn subscribe(&self) -> broadcast::Receiver<Packet> {
self.push_tx.subscribe()
}
pub fn dispatch(&self, packet: Packet) {
let is_response = matches!(packet.cmd, cmd::OK | cmd::ERROR | cmd::NOT_FOUND);
if is_response {
let waiter = self.pending.lock().unwrap().remove(&packet.seq);
let Some(tx) = waiter else {
return;
};
// deliver the raw packet (error packets included); the caller decides
// whether to map an error packet to `Err` or read it raw.
let _ = tx.send(Ok(packet));
} else {
// send errors only when there are no subscribers, ignore that
let _ = self.push_tx.send(packet);
}
}
/// Called on disconnect so awaiting callers get `ConnectionClosed` instead
/// of hanging.
pub fn fail_all(&self) {
let mut pending = self.pending.lock().unwrap();
for (_, tx) in pending.drain() {
let _ = tx.send(Err(TransportError::ConnectionClosed));
}
}
}
pub(crate) fn error_from_payload(packet: &Packet) -> TransportError {
let value = match packet.value() {
Ok(v) => v,
Err(_) => {
return TransportError::Server {
message: "unknown error".into(),
error_key: None,
}
}
};
let message = extract_message(&value);
if map_str(&value, "message").as_deref() == Some("FAIL_LOGIN_TOKEN") {
return TransportError::SessionExpired(message);
}
TransportError::Server {
message,
error_key: map_str(&value, "error"),
}
}
fn extract_message(value: &rmpv::Value) -> String {
for key in ["localizedMessage", "message", "title"] {
if let Some(s) = map_str(value, key) {
let trimmed = s.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
"unknown error".to_string()
}
fn map_str(value: &rmpv::Value, key: &str) -> Option<String> {
let map = value.as_map()?;
map.iter()
.find(|(k, _)| k.as_str() == Some(key))
.and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
}
+40
View File
@@ -0,0 +1,40 @@
use crate::protocol::codec::CodecError;
use crate::protocol::framing::OverflowError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TransportError {
#[error("server error: {message}")]
Server {
message: String,
error_key: Option<String>,
},
/// `FAIL_LOGIN_TOKEN`; session must be re-established
#[error("session expired: {0}")]
SessionExpired(String),
#[error("request timed out")]
Timeout,
#[error("connection closed")]
ConnectionClosed,
#[error("connect timed out")]
ConnectTimeout,
#[error("TLS error: {0}")]
Tls(String),
#[error("invalid configuration: {0}")]
Config(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error(transparent)]
Codec(#[from] CodecError),
#[error(transparent)]
Overflow(#[from] OverflowError),
}
@@ -0,0 +1,73 @@
# Russian Trusted Root CA — CN=Russian Trusted Root CA, serial 1000, 2022-03-01..2032-02-27
# SHA-256(DER): D2:6D:2D:02:31:B7:C3:9F:92:CC:73:85:12:BA:54:10:35:19:E4:40:5D:68:B5:BD:70:3E:97:88:CA:8E:CF:31
# Extracted from ru.oneme.app v26.23.1 (res/raw/rootca_ssl_rsa2022.cer)
-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
-----END CERTIFICATE-----
# Russian Trusted Sub CA — CN=Russian Trusted Sub CA, serial 1005, 2024-07-15..2029-07-19, issued by Russian Trusted Root CA
# SHA-256(DER): 21:55:78:50:36:C9:00:DB:B5:F1:BB:2A:15:69:C8:0C:55:59:5B:D6:BF:94:86:7A:29:BB:DD:BC:7D:88:A3:F2
# Extracted from ru.oneme.app v26.23.1 (embedded in MaxTrustManagerProvider / jd7)
-----BEGIN CERTIFICATE-----
MIIG6DCCBNCgAwIBAgICEAUwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUxPzA9BgNVBAoM
NlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFuZCBDb21tdW5pY2F0aW9uczEg
MB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3QgQ0EwHhcNMjQwNzE1MTI1MDQxWhcNMjkwNzE5
MTI1MDQxWjBvMQswCQYDVQQGEwJSVTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwg
RGV2ZWxvcG1lbnQgYW5kIENvbW11bmljYXRpb25zMR8wHQYDVQQDDBZSdXNzaWFuIFRydXN0ZWQg
U3ViIENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1j0rkZECOt1S8o7IJY+4YKAx
uEa5xaHKHXT2EpkuC/0krqMOjUy2oPIRNgR5g8X0Jl6jamxeGLc4Q1tfju6or9oSRYThIUhRsFDQ
NBiBBEXoBgWxTfiKB2eyT97+pz5TBtBiRCPaLGRHYLRb9Jz2HkJlxbtNPjtDrF5DPHym+mZ1M1z3
hIQYAqJwLpsEBnsw/VxWMlxqHoeewd0huJMd71KQ5vOKlz7KrIZ6EobNNa6wItuvsfj3kYCK7O78
uLHGXXFxdr8Hae9lMUmC8F7AFwa+bO1LRlTlqW7rE3rLf+jj70N01N8T3o22v14YBaFBWQWncAVY
D2JuL3tH252+kdNOERf1fLbLRigJAbd+hOhWYlNf963TFDgnNPliHNIW72SygVBnI2V3JwO1dp1h
VKpK/zt8ziGdHW4gmOLTsH50YKdR4jNqUgQv4wASlKn9OpN6zHYc5G8h86fYBM+zxE5ikGI+I/vI
qBuI0eaDU92AWN/YjFLpu8tMu9kLRSCf1vug6FIfDPWVo7iPac/SI2v8jnnpaW7ph/Pz3WkzaG7Z
ZJsfFs+8dploWc6LOoDtbFBhMdGMxu024msC0PSjZb5ODXPIaO2NsA7fMiAtZcoK6anTUJh4zOP/
stA9qsJGNxdrEmiPXSmBZY/NY0wkZgZ6JTDhw7038bPvctkblJkCAwEAAaOCAYswggGHMB0GA1Ud
DgQWBBR3Pdk5r0K93FvKduru/c4+YSkwXzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qws
hzAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADCBmAYIKwYBBQUHAQEEgYswgYgw
QAYIKwYBBQUHMAKGNGh0dHA6Ly9udWMtY2RwLnZvc2tob2QucnUvY2RwL3Jvb3RjYV9zc2xfcnNh
MjAyMi5jcnQwRAYIKwYBBQUHMAKGOGh0dHA6Ly9udWMtY2RwLmRpZ2l0YWwuZ292LnJ1L2NkcC9y
b290Y2Ffc3NsX3JzYTIwMjIuY3J0MIGFBgNVHR8EfjB8MDqgOKA2hjRodHRwOi8vbnVjLWNkcC52
b3NraG9kLnJ1L2NkcC9yb290Y2Ffc3NsX3JzYTIwMjIuY3JsMD6gPKA6hjhodHRwOi8vbnVjLWNk
cC5kaWdpdGFsLmdvdi5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNybDANBgkqhkiG9w0BAQsF
AAOCAgEAmsINXtQ7wwUWvIeOr80MdJS/5G4xhyZOVEmeUorThquT672ycCg3XCxc4fwbiZqSSbBq
ntQ7RtiTAKMYMvBageKoVHbzz+R4jX01tKcTx8cDePrzdJ73bLNUorE7RU9QsW4KyiUeRmjMDV23
AUlEvuQFTwgkHXvbac1BBdPn9CrssQuF5EGohZKcQPFiAAc4SHbRNhlr7uAwgpc/erzI9EAcvA6B
VAXcVKoeGpV01uexUgZ6St5RP9UmDWNA7T4yVXWJ233N0Q8bl+6AswINQ3PosPu6yQQHQjr65YS0
6epK+AeI6j+oGR4xI7EhTQhQvaobnGmX/8QQ7XDRYCP2HXYxiffnn/CfZ/BVyKLYeY1ZipjEnzqd
QIC2+Q3WtY8jsVRQMP38WFRmtsIt5snehnPTs5bKGVIcYzj3o3Ex/K7agEz0zAJ0JR5ivXZOvNkT
0g9x1v+S1IkU3e/nX1a+tpRquMtnHX0L2lXArNHUbaOO9EJtd57WaIpofV5cVhhwShOgAuBc9UMJ
F3/n4t4RKiPxtsK8P67gcmphMhslj7AMYrYMej2NvQZY4m3ub3CPC/PrTjDONvb+8g5xrKtxBjYq
C74HSB4dg9G3WimSDUuP2Su6G2y2TUeyJuCvCLz289VoO0vg7cNdMobE3KCqAiiNhN2VBFxHAUKm
UoRcRdw=
-----END CERTIFICATE-----
+15
View File
@@ -0,0 +1,15 @@
//! Persistent TLS connection with seq-multiplexed request/response and a
//! broadcast stream of server pushes. tokio + rustls over the protocol codec.
mod client;
mod dispatcher;
mod error;
pub(crate) mod proxy;
pub(crate) mod tls;
mod wiretap;
pub use client::{Client, ClientConfig};
pub use error::TransportError;
pub use proxy::{ProxyConfig, ProxyKind};
pub use tls::{set_trust_mincifry_ca, trust_mincifry_ca};
pub use wiretap::{Direction, WireTap};
+303
View File
@@ -0,0 +1,303 @@
//! Proxying the outbound connect. Both schemes hand back a plain TCP stream to
//! the real target, so TLS and everything above it stays the same.
//!
//! - HTTP CONNECT: send `CONNECT host:port`, wait for `200`, then it's a tunnel.
//! - SOCKS5 (RFC 1928 + RFC 1929 user/pass): greeting, optional auth, then a
//! connect command carrying the target as a domain (proxy resolves it).
use std::io;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyKind {
Http,
Socks5,
}
/// An outbound proxy. `username`/`password`, if set, do auth: HTTP Basic or
/// SOCKS5 user/pass.
#[derive(Debug, Clone)]
pub struct ProxyConfig {
pub kind: ProxyKind,
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
}
impl ProxyConfig {
/// Parse `scheme://[user:pass@]host:port`. Schemes: `http`, `socks5`,
/// `socks5h` (both socks variants pass the target as a domain name).
pub fn parse(url: &str) -> Result<Self, String> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| format!("proxy url has no scheme: {url}"))?;
let kind = match scheme {
"http" => ProxyKind::Http,
"socks5" | "socks5h" => ProxyKind::Socks5,
other => return Err(format!("unsupported proxy scheme: {other}")),
};
let (auth, authority) = match rest.rsplit_once('@') {
Some((a, h)) => (Some(a), h),
None => (None, rest),
};
let (username, password) = match auth {
Some(a) => match a.split_once(':') {
Some((u, p)) => (Some(u.to_string()), Some(p.to_string())),
None => (Some(a.to_string()), None),
},
None => (None, None),
};
let (host, port) = authority
.rsplit_once(':')
.ok_or_else(|| format!("proxy url has no port: {url}"))?;
let port: u16 = port
.parse()
.map_err(|_| format!("bad proxy port: {port}"))?;
Ok(ProxyConfig {
kind,
host: host.to_string(),
port,
username,
password,
})
}
}
/// Open a TCP stream to `(target_host, target_port)`, directly or through
/// `proxy`. `connect_timeout` covers the lot, proxy handshake included.
pub async fn connect_tcp(
target_host: &str,
target_port: u16,
connect_timeout: Duration,
proxy: Option<&ProxyConfig>,
) -> io::Result<TcpStream> {
timeout(connect_timeout, async {
match proxy {
None => {
let tcp = TcpStream::connect((target_host, target_port)).await?;
tcp.set_nodelay(true).ok();
Ok(tcp)
}
Some(p) => {
let mut tcp = TcpStream::connect((p.host.as_str(), p.port)).await?;
tcp.set_nodelay(true).ok();
match p.kind {
ProxyKind::Http => http_connect(&mut tcp, target_host, target_port, p).await?,
ProxyKind::Socks5 => {
socks5_connect(&mut tcp, target_host, target_port, p).await?
}
}
Ok(tcp)
}
}
})
.await
.unwrap_or_else(|_| {
Err(io::Error::new(
io::ErrorKind::TimedOut,
"proxy connect timed out",
))
})
}
async fn http_connect(
stream: &mut TcpStream,
host: &str,
port: u16,
proxy: &ProxyConfig,
) -> io::Result<()> {
let mut req = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n");
if let Some(user) = &proxy.username {
let pass = proxy.password.as_deref().unwrap_or("");
let token = base64_encode(format!("{user}:{pass}").as_bytes());
req.push_str(&format!("Proxy-Authorization: Basic {token}\r\n"));
}
req.push_str("Proxy-Connection: keep-alive\r\n\r\n");
stream.write_all(req.as_bytes()).await?;
stream.flush().await?;
let mut buf = Vec::with_capacity(256);
let mut byte = [0u8; 1];
loop {
let n = stream.read(&mut byte).await?;
if n == 0 {
return Err(proxy_err("proxy closed the connection during CONNECT"));
}
buf.push(byte[0]);
if buf.ends_with(b"\r\n\r\n") {
break;
}
if buf.len() > 8192 {
return Err(proxy_err("proxy CONNECT response too long"));
}
}
let head = String::from_utf8_lossy(&buf);
let status = head
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(0);
if status != 200 {
return Err(proxy_err(&format!("proxy CONNECT failed: {status}")));
}
Ok(())
}
async fn socks5_connect(
stream: &mut TcpStream,
host: &str,
port: u16,
proxy: &ProxyConfig,
) -> io::Result<()> {
let has_auth = proxy.username.is_some();
if has_auth {
stream.write_all(&[0x05, 0x02, 0x00, 0x02]).await?;
} else {
stream.write_all(&[0x05, 0x01, 0x00]).await?;
}
stream.flush().await?;
let mut method = [0u8; 2];
stream.read_exact(&mut method).await?;
if method[0] != 0x05 {
return Err(proxy_err("not a SOCKS5 proxy"));
}
match method[1] {
0x00 => {}
0x02 => socks5_userpass(stream, proxy).await?,
0xFF => return Err(proxy_err("SOCKS5 proxy rejected auth methods")),
other => return Err(proxy_err(&format!("SOCKS5 unexpected method {other}"))),
}
let host_bytes = host.as_bytes();
if host_bytes.len() > 255 {
return Err(proxy_err("SOCKS5 target host too long"));
}
let mut req = Vec::with_capacity(7 + host_bytes.len());
req.extend_from_slice(&[0x05, 0x01, 0x00, 0x03]);
req.push(host_bytes.len() as u8);
req.extend_from_slice(host_bytes);
req.extend_from_slice(&port.to_be_bytes());
stream.write_all(&req).await?;
stream.flush().await?;
let mut head = [0u8; 4];
stream.read_exact(&mut head).await?;
if head[1] != 0x00 {
return Err(proxy_err(&format!(
"SOCKS5 connect failed (reply {})",
head[1]
)));
}
let skip = match head[3] {
0x01 => 4,
0x04 => 16,
0x03 => {
let mut len = [0u8; 1];
stream.read_exact(&mut len).await?;
len[0] as usize
}
other => return Err(proxy_err(&format!("SOCKS5 bad address type {other}"))),
};
let mut rest = vec![0u8; skip + 2];
stream.read_exact(&mut rest).await?;
Ok(())
}
async fn socks5_userpass(stream: &mut TcpStream, proxy: &ProxyConfig) -> io::Result<()> {
let user = proxy.username.as_deref().unwrap_or("");
let pass = proxy.password.as_deref().unwrap_or("");
if user.len() > 255 || pass.len() > 255 {
return Err(proxy_err("SOCKS5 credentials too long"));
}
let mut msg = Vec::with_capacity(3 + user.len() + pass.len());
msg.push(0x01);
msg.push(user.len() as u8);
msg.extend_from_slice(user.as_bytes());
msg.push(pass.len() as u8);
msg.extend_from_slice(pass.as_bytes());
stream.write_all(&msg).await?;
stream.flush().await?;
let mut reply = [0u8; 2];
stream.read_exact(&mut reply).await?;
if reply[1] != 0x00 {
return Err(proxy_err("SOCKS5 auth rejected"));
}
Ok(())
}
fn proxy_err(msg: &str) -> io::Error {
io::Error::other(msg.to_string())
}
fn base64_encode(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
out.push(if chunk.len() > 1 {
ALPHABET[((n >> 6) & 63) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[(n & 63) as usize] as char
} else {
'='
});
}
out
}
#[cfg(test)]
mod tests {
use super::{base64_encode, ProxyConfig, ProxyKind};
#[test]
fn base64_matches_known_vectors() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"user:pass"), "dXNlcjpwYXNz");
}
#[test]
fn parses_http_with_auth() {
let p = ProxyConfig::parse("http://bob:secret@10.0.0.1:8080").unwrap();
assert_eq!(p.kind, ProxyKind::Http);
assert_eq!(p.host, "10.0.0.1");
assert_eq!(p.port, 8080);
assert_eq!(p.username.as_deref(), Some("bob"));
assert_eq!(p.password.as_deref(), Some("secret"));
}
#[test]
fn parses_socks5_no_auth() {
let p = ProxyConfig::parse("socks5://127.0.0.1:1080").unwrap();
assert_eq!(p.kind, ProxyKind::Socks5);
assert_eq!(p.port, 1080);
assert!(p.username.is_none());
}
#[test]
fn rejects_bad_scheme_and_missing_port() {
assert!(ProxyConfig::parse("ftp://x:1").is_err());
assert!(ProxyConfig::parse("http://host").is_err());
}
}
+152
View File
@@ -0,0 +1,152 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::{ring, verify_tls12_signature, verify_tls13_signature, CryptoProvider};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme};
use tokio_rustls::TlsConnector;
use super::error::TransportError;
/// Минцифры Root + Sub CA, the anchors Max endpoints chain to; absent from the
/// Mozilla bundle. Extracted from the Max app; see the file header.
const MINCIFRY_CA_PEM: &str = include_str!("mincifry_ca.pem");
/// process-wide opt-in to the bundled Минцифры CA, off by default. set once at
/// startup; read by every TLS path (socket, media, ws2).
static TRUST_MINCIFRY: AtomicBool = AtomicBool::new(false);
pub fn set_trust_mincifry_ca(enabled: bool) {
TRUST_MINCIFRY.store(enabled, Ordering::Relaxed);
}
pub fn trust_mincifry_ca() -> bool {
TRUST_MINCIFRY.load(Ordering::Relaxed)
}
/// Mozilla roots, plus the Минцифры CA when the flag is on (additive: rustls
/// picks the matching anchor per connection).
fn root_store() -> Result<RootCertStore, TransportError> {
let mut roots = RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
};
if trust_mincifry_ca() {
for cert in rustls_pemfile::certs(&mut MINCIFRY_CA_PEM.as_bytes()) {
let cert = cert.map_err(|e| TransportError::Tls(format!("mincifry CA parse: {e}")))?;
roots
.add(cert)
.map_err(|e| TransportError::Tls(format!("mincifry CA add: {e}")))?;
}
}
Ok(roots)
}
/// Shared client config for every TLS path. `insecure` accepts any cert
/// (self-signed / MitM-debug only).
pub fn build_client_config(insecure: bool) -> Result<Arc<ClientConfig>, TransportError> {
let provider = Arc::new(ring::default_provider());
let config = if insecure {
ClientConfig::builder_with_provider(provider.clone())
.with_safe_default_protocol_versions()
.map_err(|e| TransportError::Tls(e.to_string()))?
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyCert(provider)))
.with_no_client_auth()
} else {
ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| TransportError::Tls(e.to_string()))?
.with_root_certificates(root_store()?)
.with_no_client_auth()
};
Ok(Arc::new(config))
}
/// TLS connector for the main socket and media uploads. For OS-trust-store
/// parity, swap the root store for `rustls-platform-verifier`.
pub fn build_connector(insecure: bool) -> Result<TlsConnector, TransportError> {
Ok(TlsConnector::from(build_client_config(insecure)?))
}
/// Accepts every cert. DEBUG ONLY, wide open to MitM.
#[derive(Debug)]
struct AcceptAnyCert(Arc<CryptoProvider>);
impl ServerCertVerifier for AcceptAnyCert {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls12_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls13_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mincifry_flag_adds_two_anchors() {
let bundled = rustls_pemfile::certs(&mut MINCIFRY_CA_PEM.as_bytes())
.collect::<Result<Vec<_>, _>>()
.expect("bundle parses");
assert_eq!(bundled.len(), 2, "root + sub CA");
set_trust_mincifry_ca(false);
let base = root_store().unwrap().roots.len();
assert_eq!(base, webpki_roots::TLS_SERVER_ROOTS.len());
set_trust_mincifry_ca(true);
assert!(trust_mincifry_ca());
let with_ca = root_store().unwrap().roots.len();
assert_eq!(
with_ca,
base + 2,
"Минцифры anchors added on top of Mozilla"
);
assert!(build_client_config(false).is_ok());
assert!(build_client_config(true).is_ok());
set_trust_mincifry_ca(false);
}
}
@@ -0,0 +1,29 @@
//! Optional tap on the traffic: fires once per packet each way, with the
//! uncompressed msgpack, before dispatch. Keep the callback cheap; it runs on
//! the I/O tasks.
use std::sync::Arc;
/// packet direction as the tap saw it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// client -> server (request, send, ping)
Out,
/// server -> client (response or push)
In,
}
impl Direction {
pub fn as_str(&self) -> &'static str {
match self {
Direction::Out => "out",
Direction::In => "in",
}
}
}
/// `(direction, cmd, opcode, seq, msgpack)`. cmd is the command byte
/// ([`crate::protocol::cmd`]): out is always REQUEST; in is OK/NOT_FOUND/ERROR,
/// or PUSH (== REQUEST == 0) for a push. payload is uncompressed msgpack; run it
/// through [`crate::protocol::value_to_json`] for a log line.
pub type WireTap = Arc<dyn Fn(Direction, u8, u16, u16, &[u8]) + Send + Sync>;
+163
View File
@@ -0,0 +1,163 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use kolibri_net::calls::{ConversationParams, Ws2ClientInfo, Ws2Signaling};
use serde_json::json;
use tokio::net::TcpListener;
use tokio::time::timeout;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
fn make_vcp() -> String {
let payload = json!({
"tkn": "TOK123",
"wse": "wss://vid.example/ws2",
"stne": "stun:s.example:3478",
"trne": "turn:t1.example:3478, turn:t2.example:3478",
"trnu": "user:42",
"trnp": "secret",
"iv": true,
"et": 9999999999i64,
});
let bytes = serde_json::to_vec(&payload).unwrap();
let raw_len = bytes.len();
let compressed = lz4_flex::block::compress(&bytes);
let b64 = base64::engine::general_purpose::STANDARD.encode(&compressed);
format!("{raw_len}:{b64}")
}
#[test]
fn vcp_decodes_all_fields() {
let params = ConversationParams::decode(&make_vcp()).expect("decode");
assert_eq!(params.token, "TOK123");
assert_eq!(params.ws_endpoint, "wss://vid.example/ws2");
assert_eq!(params.stun.as_deref(), Some("stun:s.example:3478"));
assert_eq!(params.turn.len(), 2);
assert_eq!(params.turn[1], "turn:t2.example:3478");
assert_eq!(params.turn_user.as_deref(), Some("user:42"));
assert_eq!(params.turn_password.as_deref(), Some("secret"));
assert!(params.is_video);
assert_eq!(params.user_id(), 42);
let ice = params.ice_servers();
assert_eq!(ice.len(), 2);
assert_eq!(ice[1].username.as_deref(), Some("user:42"));
}
#[test]
fn vcp_builds_ws2_url() {
let params = ConversationParams::decode(&make_vcp()).unwrap();
let url = params.ws2_url("conv-1", &Ws2ClientInfo::default());
assert!(url.starts_with("wss://vid.example/ws2?"));
assert!(url.contains("userId=42"));
assert!(url.contains("conversationId=conv-1"));
assert!(url.contains("token=TOK123"));
assert!(url.contains("clientType=ONE_ME"));
}
#[test]
fn vcp_rejects_garbage() {
assert!(ConversationParams::decode("not-a-vcp").is_none());
assert!(ConversationParams::decode(":abc").is_none());
assert!(ConversationParams::decode("0:abc").is_none());
}
/// Mock ws2 server: sends an app-level `ping` on connect, echoes commands as
/// responses, and pushes a `connection` notification after `accept-call`.
async fn mock_ws2(listener: TcpListener, got_pong: Arc<AtomicBool>) {
let (stream, _) = listener.accept().await.unwrap();
let ws = accept_async(stream).await.unwrap();
let (mut write, mut read) = ws.split();
write.send(Message::text("ping")).await.unwrap();
while let Some(msg) = read.next().await {
let Ok(Message::Text(t)) = msg else { break };
let text = t.as_str();
if text == "pong" {
got_pong.store(true, Ordering::SeqCst);
continue;
}
let v: serde_json::Value = serde_json::from_str(text).unwrap();
let seq = v["sequence"].as_i64().unwrap();
let command = v["command"].as_str().unwrap().to_string();
let resp = json!({"sequence": seq, "response": command, "type": "response"});
write.send(Message::text(resp.to_string())).await.unwrap();
if command == "accept-call" {
let notif =
json!({"type": "notification", "notification": "connection", "topology": "P2P"});
write.send(Message::text(notif.to_string())).await.unwrap();
}
}
}
#[tokio::test]
async fn ws2_command_response_notification_and_ping() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let got_pong = Arc::new(AtomicBool::new(false));
tokio::spawn(mock_ws2(listener, got_pong.clone()));
let url = format!("ws://127.0.0.1:{}/ws2", addr.port());
let sig = Ws2Signaling::connect(&url, None).await.unwrap();
let mut notifs = sig.notifications();
let resp = sig.accept_call().await.unwrap();
assert_eq!(resp["response"], "accept-call");
assert_eq!(resp["sequence"], 1);
let notif = timeout(Duration::from_secs(2), notifs.recv())
.await
.expect("notification timed out")
.expect("notif channel closed");
assert_eq!(notif["notification"], "connection");
assert_eq!(notif["topology"], "P2P");
// the client should have answered the server's app-level ping
for _ in 0..20 {
if got_pong.load(Ordering::SeqCst) {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
assert!(got_pong.load(Ordering::SeqCst), "client did not reply pong");
}
#[tokio::test]
async fn ws2_transmit_sdp_shape() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// capture the command the server receives
let captured: Arc<tokio::sync::Mutex<Option<serde_json::Value>>> =
Arc::new(tokio::sync::Mutex::new(None));
let captured2 = captured.clone();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let ws = accept_async(stream).await.unwrap();
let (mut write, mut read) = ws.split();
while let Some(msg) = read.next().await {
let Ok(Message::Text(t)) = msg else { break };
let v: serde_json::Value = serde_json::from_str(t.as_str()).unwrap();
*captured2.lock().await = Some(v.clone());
let resp =
json!({"sequence": v["sequence"], "response": "transmit-data", "type": "response"});
write.send(Message::text(resp.to_string())).await.unwrap();
}
});
let url = format!("ws://127.0.0.1:{}/ws2", addr.port());
let sig = Ws2Signaling::connect(&url, None).await.unwrap();
sig.transmit_sdp(42, "offer", "v=0...").await.unwrap();
let cmd = captured.lock().await.clone().expect("no command captured");
assert_eq!(cmd["command"], "transmit-data");
assert_eq!(cmd["participantId"], 42);
assert_eq!(cmd["data"]["sdp"]["type"], "offer");
assert_eq!(cmd["data"]["sdp"]["sdp"], "v=0...");
}
+243
View File
@@ -0,0 +1,243 @@
//! Media uploader tests against a local mock HTTP server (plain HTTP, no TLS)
//! exercising the request shaping, response parsing, and parallel chunking.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use kolibri_net::media::{upload_file, upload_file_path, upload_video, upload_video_path};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
/// Read one HTTP/1.1 request; return (method, content_range, body).
async fn read_request(stream: &mut TcpStream) -> (String, Option<String>, Vec<u8>) {
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
// Read until headers complete.
let header_end = loop {
let n = stream.read(&mut tmp).await.unwrap();
if n == 0 {
return (String::new(), None, Vec::new());
}
buf.extend_from_slice(&tmp[..n]);
if let Some(p) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break p + 4;
}
};
let header_str = String::from_utf8_lossy(&buf[..header_end]).to_string();
let mut lines = header_str.split("\r\n");
let method = lines
.next()
.and_then(|l| l.split_whitespace().next())
.unwrap_or("")
.to_string();
let mut content_length = 0usize;
let mut content_range = None;
for line in lines {
if let Some((k, v)) = line.split_once(':') {
let key = k.trim().to_ascii_lowercase();
if key == "content-length" {
content_length = v.trim().parse().unwrap_or(0);
} else if key == "content-range" {
content_range = Some(v.trim().to_string());
}
}
}
let mut body = buf[header_end..].to_vec();
while body.len() < content_length {
let n = stream.read(&mut tmp).await.unwrap();
if n == 0 {
break;
}
body.extend_from_slice(&tmp[..n]);
}
(method, content_range, body)
}
#[tokio::test]
async fn upload_file_posts_body_with_content_range() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let (method, range, body) = read_request(&mut stream).await;
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.await
.unwrap();
stream.flush().await.unwrap();
(method, range, body)
});
let data = vec![0xAB; 5000];
let url = format!("http://127.0.0.1:{}/upload", addr.port());
let resp = upload_file(&url, &data, "clip.bin", false, None, None, "test-ua")
.await
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"ok");
let (method, range, body) = server.await.unwrap();
assert_eq!(method, "POST");
assert_eq!(range.as_deref(), Some("bytes 0-4999/5000"));
assert_eq!(body, data);
}
#[tokio::test]
async fn upload_file_path_streams_body_off_disk() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let (method, range, body) = read_request(&mut stream).await;
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.await
.unwrap();
stream.flush().await.unwrap();
(method, range, body)
});
let data = vec![0xCD; 5000];
let path = std::env::temp_dir().join(format!("kolibri_upload_{}.bin", addr.port()));
std::fs::write(&path, &data).unwrap();
let url = format!("http://127.0.0.1:{}/upload", addr.port());
let resp = upload_file_path(
&url,
path.to_str().unwrap(),
"clip.bin",
None,
None,
false,
None,
None,
"test-ua",
)
.await
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"ok");
let (method, range, body) = server.await.unwrap();
assert_eq!(method, "POST");
assert_eq!(range.as_deref(), Some("bytes 0-4999/5000"));
assert_eq!(body, data);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn upload_video_chunks_in_parallel_and_covers_all_bytes() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let received = Arc::new(AtomicUsize::new(0));
let posts = Arc::new(AtomicUsize::new(0));
let received2 = received.clone();
let posts2 = posts.clone();
let server = tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(v) => v,
Err(_) => break,
};
let received = received2.clone();
let posts = posts2.clone();
tokio::spawn(async move {
let (method, _range, body) = read_request(&mut stream).await;
let resp: &[u8] = if method == "GET" {
// resume offset 0
b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\n0"
} else {
received.fetch_add(body.len(), Ordering::SeqCst);
posts.fetch_add(1, Ordering::SeqCst);
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"
};
stream.write_all(resp).await.ok();
stream.flush().await.ok();
});
}
});
let total = 5 * 1024 * 1024; // 5 MB
let data = vec![0x7F; total];
let url = format!("http://127.0.0.1:{}/video", addr.port());
let ok = upload_video(&url, data, 2 * 1024 * 1024, 4, false, None, None)
.await
.unwrap();
assert!(ok);
// 5 MB / 2 MB chunk = 3 chunks, all bytes delivered.
assert_eq!(posts.load(Ordering::SeqCst), 3);
assert_eq!(received.load(Ordering::SeqCst), total);
server.abort();
let _ = tokio::time::timeout(Duration::from_millis(100), server).await;
}
#[tokio::test]
async fn upload_video_path_reads_chunks_off_disk() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let received = Arc::new(AtomicUsize::new(0));
let posts = Arc::new(AtomicUsize::new(0));
let received2 = received.clone();
let posts2 = posts.clone();
let server = tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(v) => v,
Err(_) => break,
};
let received = received2.clone();
let posts = posts2.clone();
tokio::spawn(async move {
let (method, _range, body) = read_request(&mut stream).await;
let resp: &[u8] = if method == "GET" {
b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\n0"
} else {
received.fetch_add(body.len(), Ordering::SeqCst);
posts.fetch_add(1, Ordering::SeqCst);
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"
};
stream.write_all(resp).await.ok();
stream.flush().await.ok();
});
}
});
let total = 5 * 1024 * 1024; // 5 MB
let data = vec![0x3C; total];
let path = std::env::temp_dir().join(format!("kolibri_video_{}.bin", addr.port()));
std::fs::write(&path, &data).unwrap();
let url = format!("http://127.0.0.1:{}/video", addr.port());
let ok = upload_video_path(
&url,
path.to_str().unwrap(),
2 * 1024 * 1024,
4,
false,
None,
None,
)
.await
.unwrap();
assert!(ok);
assert_eq!(posts.load(Ordering::SeqCst), 3);
assert_eq!(received.load(Ordering::SeqCst), total);
server.abort();
let _ = tokio::time::timeout(Duration::from_millis(100), server).await;
let _ = std::fs::remove_file(&path);
}
+252
View File
@@ -0,0 +1,252 @@
//! End-to-end session tests: full connect → sessionInit handshake → online,
//! against a self-signed TLS server that answers the handshake.
use std::sync::Arc;
use std::time::Duration;
use kolibri_net::protocol::{codec, framing::PacketReceiver, opcodes, packet::cmd};
use kolibri_net::{ClientConfig, HandshakeConfig, Session, SessionConfig, SessionState, UserAgent};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::time::timeout;
use tokio_rustls::TlsAcceptor;
fn server_config() -> Arc<rustls::ServerConfig> {
let certified = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let cert_der = certified.cert.der().clone();
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(certified.key_pair.serialize_der());
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(
vec![cert_der],
rustls::pki_types::PrivateKeyDer::Pkcs8(key_der),
)
.unwrap();
Arc::new(config)
}
/// Handshake response payload: {callsSeed: 7, device_name: "Rusty"}.
fn handshake_response() -> Vec<u8> {
let value = rmpv::Value::Map(vec![
(rmpv::Value::from("callsSeed"), rmpv::Value::from(7i64)),
(rmpv::Value::from("device_name"), rmpv::Value::from("Rusty")),
]);
let mut out = Vec::new();
rmpv::encode::write_value(&mut out, &value).unwrap();
out
}
/// Server: answer sessionInit with the handshake payload; echo any other
/// request; silently accept pings (no matching waiter on the client).
async fn run_server(listener: TcpListener, acceptor: TlsAcceptor) {
let (tcp, _) = listener.accept().await.unwrap();
let mut tls = acceptor.accept(tcp).await.unwrap();
let mut receiver = PacketReceiver::new();
let mut buf = vec![0u8; 16 * 1024];
loop {
let n = match tls.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
let packets = receiver.feed(&buf[..n]).unwrap();
for raw in packets {
let req = codec::decode(&raw).unwrap();
match req.opcode {
opcodes::SESSION_INIT => {
let resp =
codec::encode_with_cmd(cmd::OK, req.opcode, &handshake_response(), req.seq);
tls.write_all(&resp).await.unwrap();
tls.flush().await.unwrap();
}
opcodes::PING => {}
_ => {
let resp = codec::encode_with_cmd(cmd::OK, req.opcode, &req.payload, req.seq);
tls.write_all(&resp).await.unwrap();
tls.flush().await.unwrap();
}
}
}
}
}
fn handshake_config() -> HandshakeConfig {
HandshakeConfig {
instance_id: "inst-123".to_string(),
device_id: "dev-abc".to_string(),
client_session_id: 42,
user_agent: UserAgent {
device_type: "ANDROID".to_string(),
app_version: "1.0.0".to_string(),
os_version: "Android 14".to_string(),
timezone: "Europe/Moscow".to_string(),
screen: "420dpi 420dpi 1080x2340".to_string(),
push_device_type: "GCM".to_string(),
arch: "arm64-v8a".to_string(),
locale: "ru".to_string(),
build_number: 100,
device_name: "Pixel".to_string(),
device_locale: "ru".to_string(),
is_pwa: None,
header_user_agent: None,
},
}
}
async fn start_session(auto_reconnect: bool) -> Session {
let acceptor = TlsAcceptor::from(server_config());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(run_server(listener, acceptor));
let client = ClientConfig::new("127.0.0.1", addr.port()).insecure(true);
let mut config = SessionConfig::new(client, handshake_config());
config.auto_reconnect = auto_reconnect;
config.ping_interval = Duration::from_millis(200);
Session::new(config)
}
#[tokio::test]
async fn connect_performs_handshake_and_goes_online() {
let session = start_session(false).await;
let info = timeout(Duration::from_secs(5), session.connect())
.await
.expect("connect timed out")
.expect("handshake failed");
assert_eq!(info.calls_seed, Some(7));
assert_eq!(info.device_name.as_deref(), Some("Rusty"));
assert_eq!(session.state(), SessionState::Online);
}
#[tokio::test]
async fn request_routes_through_session() {
let session = start_session(false).await;
session.connect().await.unwrap();
let payload = {
let value = rmpv::Value::Map(vec![(rmpv::Value::from("q"), rmpv::Value::from("hi"))]);
let mut out = Vec::new();
rmpv::encode::write_value(&mut out, &value).unwrap();
out
};
let resp = session
.request(opcodes::CHATS_LIST, &payload)
.await
.unwrap();
assert!(resp.is_ok());
assert_eq!(resp.payload, payload);
}
#[tokio::test]
async fn state_transitions_reach_online() {
let session = start_session(false).await;
let mut states = session.subscribe_state();
session.connect().await.unwrap();
// Drain observed states; the terminal one must be Online.
let mut last = *states.borrow_and_update();
while states.has_changed().unwrap_or(false) {
last = *states.borrow_and_update();
}
assert_eq!(last, SessionState::Online);
}
#[tokio::test]
async fn keepalive_ping_is_sent() {
// ping_interval is 200ms; if the session stays online for >0.5s without the
// server closing on an unexpected packet, pings are being accepted.
let session = start_session(false).await;
session.connect().await.unwrap();
tokio::time::sleep(Duration::from_millis(600)).await;
assert_eq!(session.state(), SessionState::Online);
}
/// Server that drops the first connection right after the handshake, then serves
/// the second connection normally — to exercise auto-reconnect.
async fn run_flaky_server(listener: TcpListener, acceptor: TlsAcceptor) {
// First connection: answer the handshake, then drop the socket.
if let Ok((tcp, _)) = listener.accept().await {
if let Ok(mut tls) = acceptor.accept(tcp).await {
let mut receiver = PacketReceiver::new();
let mut buf = vec![0u8; 16 * 1024];
'first: loop {
let n = match tls.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
for raw in receiver.feed(&buf[..n]).unwrap() {
let req = codec::decode(&raw).unwrap();
if req.opcode == opcodes::SESSION_INIT {
let resp = codec::encode_with_cmd(
cmd::OK,
req.opcode,
&handshake_response(),
req.seq,
);
tls.write_all(&resp).await.unwrap();
tls.flush().await.unwrap();
break 'first; // drop the connection
}
}
}
}
}
// Second connection: serve normally.
run_server(listener, acceptor).await;
}
#[tokio::test]
async fn auto_reconnects_after_drop() {
let acceptor = TlsAcceptor::from(server_config());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(run_flaky_server(listener, acceptor));
let client = ClientConfig::new("127.0.0.1", addr.port()).insecure(true);
let mut config = SessionConfig::new(client, handshake_config());
config.auto_reconnect = true;
config.ping_interval = Duration::from_millis(150);
let session = Session::new(config);
let mut states = session.subscribe_state();
// First handshake succeeds on conn1 (which the server then drops). The drop
// may be detected before this returns, so we don't assert Online here.
session.connect().await.unwrap();
// Observe: … Disconnected (drop) → … → Online again (reconnect on conn2).
let mut saw_disconnected = false;
let mut reconnected = false;
let deadline = Duration::from_secs(8);
let result = timeout(deadline, async {
while states.changed().await.is_ok() {
match *states.borrow_and_update() {
SessionState::Disconnected => saw_disconnected = true,
SessionState::Online if saw_disconnected => {
reconnected = true;
break;
}
_ => {}
}
}
})
.await;
assert!(result.is_ok(), "did not reconnect within {deadline:?}");
assert!(reconnected, "session did not return to Online after drop");
}
#[tokio::test]
async fn disconnect_stops_session() {
let session = start_session(false).await;
session.connect().await.unwrap();
session.disconnect();
assert_eq!(session.state(), SessionState::Disconnected);
let err = session.request(opcodes::PING, &[]).await;
assert!(err.is_err());
}
+203
View File
@@ -0,0 +1,203 @@
//! End-to-end transport tests against a real self-signed TLS server, exercising
//! the TLS handshake, stream framing, seq-matched request/response, server
//! pushes, and error mapping.
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use kolibri_net::protocol::{codec, framing::PacketReceiver, opcodes, packet::cmd};
use kolibri_net::{Client, ClientConfig, Direction, TransportError, WireTap};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::time::timeout;
use tokio_rustls::TlsAcceptor;
/// Custom opcode the mock server answers with an error packet.
const OP_MAKE_ERROR: u16 = 999;
fn server_config() -> Arc<rustls::ServerConfig> {
let certified = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let cert_der = certified.cert.der().clone();
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(certified.key_pair.serialize_der());
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(
vec![cert_der],
rustls::pki_types::PrivateKeyDer::Pkcs8(key_der),
)
.unwrap();
Arc::new(config)
}
fn msgpack_map(pairs: &[(&str, &str)]) -> Vec<u8> {
let value = rmpv::Value::Map(
pairs
.iter()
.map(|(k, v)| (rmpv::Value::from(*k), rmpv::Value::from(*v)))
.collect(),
);
let mut out = Vec::new();
rmpv::encode::write_value(&mut out, &value).unwrap();
out
}
/// Handle exactly one client connection: echo each request as an OK response;
/// answer OP_MAKE_ERROR with an error packet; after a PING, also emit a push.
async fn run_server(listener: TcpListener, acceptor: TlsAcceptor) {
let (tcp, _) = listener.accept().await.unwrap();
let mut tls = acceptor.accept(tcp).await.unwrap();
let mut receiver = PacketReceiver::new();
let mut buf = vec![0u8; 16 * 1024];
loop {
let n = match tls.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
let packets = receiver.feed(&buf[..n]).unwrap();
for raw in packets {
let req = codec::decode(&raw).unwrap();
if req.opcode == OP_MAKE_ERROR {
let payload = msgpack_map(&[("message", "BOOM"), ("error", "E_BOOM")]);
let resp = codec::encode_with_cmd(cmd::ERROR, req.opcode, &payload, req.seq);
tls.write_all(&resp).await.unwrap();
} else {
let resp = codec::encode_with_cmd(cmd::OK, req.opcode, &req.payload, req.seq);
tls.write_all(&resp).await.unwrap();
if req.opcode == opcodes::PING {
let push_payload = msgpack_map(&[("event", "hello")]);
let push =
codec::encode_with_cmd(cmd::PUSH, opcodes::NOTIF_MESSAGE, &push_payload, 0);
tls.write_all(&push).await.unwrap();
}
}
tls.flush().await.unwrap();
}
}
}
async fn start() -> Client {
let acceptor = TlsAcceptor::from(server_config());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(run_server(listener, acceptor));
Client::connect(ClientConfig::new("127.0.0.1", addr.port()).insecure(true))
.await
.unwrap()
}
#[tokio::test]
async fn request_response_roundtrip() {
let client = start().await;
let payload = msgpack_map(&[("a", "b")]);
let resp = client.request(opcodes::MSG_SEND, &payload).await.unwrap();
assert!(resp.is_ok());
assert_eq!(resp.opcode, opcodes::MSG_SEND);
assert_eq!(
resp.seq, 1,
"first request must use seq 1 like the Dart sender"
);
assert_eq!(resp.payload, payload);
}
#[tokio::test]
async fn large_payload_compresses_and_roundtrips() {
let client = start().await;
// A repetitive >32 byte value triggers LZ4-frame compression on both sides.
let big: Vec<u8> = {
let value = rmpv::Value::from("x".repeat(500));
let mut out = Vec::new();
rmpv::encode::write_value(&mut out, &value).unwrap();
out
};
let resp = client.request(opcodes::CHAT_HISTORY, &big).await.unwrap();
assert_eq!(resp.payload, big);
}
#[tokio::test]
async fn server_error_maps_to_err() {
let client = start().await;
let err = client
.request(OP_MAKE_ERROR, &msgpack_map(&[("q", "w")]))
.await
.unwrap_err();
match err {
TransportError::Server { message, error_key } => {
assert_eq!(message, "BOOM");
assert_eq!(error_key.as_deref(), Some("E_BOOM"));
}
other => panic!("expected Server error, got {other:?}"),
}
}
#[tokio::test]
async fn receives_server_push() {
let client = start().await;
let mut pushes = client.subscribe();
client
.request(opcodes::PING, &msgpack_map(&[("x", "y")]))
.await
.unwrap();
let push = timeout(Duration::from_secs(2), pushes.recv())
.await
.expect("push did not arrive")
.expect("push channel closed");
assert!(push.is_push());
assert_eq!(push.opcode, opcodes::NOTIF_MESSAGE);
}
#[tokio::test]
async fn wire_tap_sees_both_directions() {
let acceptor = TlsAcceptor::from(server_config());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(run_server(listener, acceptor));
let seen: Arc<Mutex<Vec<(Direction, u16)>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = seen.clone();
let tap: WireTap = Arc::new(move |dir, _cmd, opcode, _seq, _payload| {
recorder.lock().unwrap().push((dir, opcode));
});
let client = Client::connect_with_tap(
ClientConfig::new("127.0.0.1", addr.port()).insecure(true),
Some(tap),
)
.await
.unwrap();
client
.request(opcodes::MSG_SEND, &msgpack_map(&[("a", "b")]))
.await
.unwrap();
let events = seen.lock().unwrap().clone();
assert!(
events.contains(&(Direction::Out, opcodes::MSG_SEND)),
"outgoing not tapped: {events:?}"
);
assert!(
events.contains(&(Direction::In, opcodes::MSG_SEND)),
"incoming not tapped: {events:?}"
);
}
#[tokio::test]
async fn sequence_numbers_increment() {
let client = start().await;
let p = msgpack_map(&[("k", "v")]);
let first = client.request(opcodes::MSG_SEND, &p).await.unwrap();
let second = client.request(opcodes::MSG_SEND, &p).await.unwrap();
assert_eq!(first.seq, 1);
assert_eq!(second.seq, 2);
}
+162
View File
@@ -0,0 +1,162 @@
use kolibri_net::protocol::{codec, compress, framing, opcodes, packet};
use kolibri_net::{decode, encode};
/// MessagePack for the map {"a": 1}: fixmap(1) + fixstr "a" + int 1 = 4 bytes.
const MSGPACK_A1: [u8; 4] = [0x81, 0xA1, 0x61, 0x01];
#[test]
fn header_layout_matches_dart_wire_format() {
// Uncompressed small payload → flag byte 0, big-endian seq/opcode/len.
let bytes = encode(opcodes::LOGIN, &MSGPACK_A1, 5);
let expected: Vec<u8> = vec![
10, // ver
0, // cmd (request)
0, 5, // seq = 5
0, 19, // opcode = LOGIN (19)
0, 0, 0, 4, // packedLen: flag 0 | len 4
0x81, 0xA1, 0x61, 0x01, // payload
];
assert_eq!(bytes, expected);
}
#[test]
fn roundtrip_uncompressed() {
let bytes = encode(opcodes::MSG_SEND, &MSGPACK_A1, 42);
let pkt = decode(&bytes).unwrap();
assert_eq!(pkt.ver, packet::PROTOCOL_VERSION);
assert_eq!(pkt.cmd, packet::cmd::REQUEST);
assert_eq!(pkt.seq, 42);
assert_eq!(pkt.opcode, opcodes::MSG_SEND);
assert_eq!(pkt.payload, MSGPACK_A1);
}
#[test]
fn empty_payload_packet() {
let bytes = encode(opcodes::PING, &[], 1);
// header only, len 0
assert_eq!(bytes.len(), packet::HEADER_SIZE);
let pkt = decode(&bytes).unwrap();
assert!(pkt.payload.is_empty());
assert_eq!(pkt.opcode, opcodes::PING);
}
#[test]
fn compression_kicks_in_past_threshold() {
// A payload >= 32 bytes must be compressed (flag != 0) and round-trip.
let big: Vec<u8> = std::iter::repeat_n(0xABu8, 200).collect();
let bytes = encode(opcodes::MSG_SEND, &big, 7);
// Flag byte is the high byte of packedLen at offset 6.
assert_ne!(bytes[6], 0, "expected compression flag to be set");
let pkt = decode(&bytes).unwrap();
assert_eq!(pkt.payload, big);
}
#[test]
fn below_threshold_stays_uncompressed() {
let small: Vec<u8> = std::iter::repeat_n(0x01u8, 31).collect();
let bytes = encode(opcodes::MSG_SEND, &small, 1);
assert_eq!(bytes[6], 0, "expected no compression flag");
let pkt = decode(&bytes).unwrap();
assert_eq!(pkt.payload, small);
}
#[test]
fn framing_reassembles_across_chunks() {
let a = encode(opcodes::PING, &MSGPACK_A1, 1);
let b = encode(opcodes::MSG_SEND, &MSGPACK_A1, 2);
let c = encode(opcodes::CHATS_LIST, &[], 3);
let mut stream = Vec::new();
stream.extend_from_slice(&a);
stream.extend_from_slice(&b);
stream.extend_from_slice(&c);
let mut rx = framing::PacketReceiver::new();
let mut got = Vec::new();
// Feed one byte at a time to stress partial-header / partial-payload paths.
for &byte in &stream {
for raw in rx.feed(&[byte]).unwrap() {
got.push(decode(&raw).unwrap());
}
}
assert_eq!(got.len(), 3);
assert_eq!(got[0].seq, 1);
assert_eq!(got[1].seq, 2);
assert_eq!(got[2].seq, 3);
assert_eq!(rx.buffered_len(), 0);
}
#[test]
fn framing_handles_multiple_packets_in_one_chunk() {
let a = encode(opcodes::PING, &MSGPACK_A1, 10);
let b = encode(opcodes::PING, &MSGPACK_A1, 11);
let mut joined = a.clone();
joined.extend_from_slice(&b);
let mut rx = framing::PacketReceiver::new();
let packets = rx.feed(&joined).unwrap();
assert_eq!(packets.len(), 2);
assert_eq!(decode(&packets[0]).unwrap().seq, 10);
assert_eq!(decode(&packets[1]).unwrap().seq, 11);
}
#[test]
fn lz4_block_literals_only() {
// token 0x50 = 5 literals, then "hello".
let src = [0x50, b'h', b'e', b'l', b'l', b'o'];
let out = compress::decompress_lz4_block(&src, compress::MAX_DECOMPRESSED_SIZE).unwrap();
assert_eq!(out, b"hello");
}
#[test]
fn lz4_block_with_match_copy() {
// 1 literal 'a', then a back-reference of length 4 at offset 1 → "aaaaa".
let src = [0x10, b'a', 0x01, 0x00];
let out = compress::decompress_lz4_block(&src, compress::MAX_DECOMPRESSED_SIZE).unwrap();
assert_eq!(out, b"aaaaa");
}
#[test]
fn lz4_block_via_sniffer_default_path() {
// No magic prefix → sniffer must fall through to block decode.
let src = [0x50, b'w', b'o', b'r', b'l', b'd'];
let out = compress::decompress(&src).unwrap();
assert_eq!(out, b"world");
}
#[test]
fn lz4_frame_roundtrip_and_sniff() {
let data: Vec<u8> = (0..500).map(|i| (i % 7) as u8).collect();
let framed = compress::compress_lz4_frame(&data);
// Frame magic present.
assert_eq!(&framed[0..4], &[0x04, 0x22, 0x4D, 0x18]);
let out = compress::decompress(&framed).unwrap();
assert_eq!(out, data);
}
#[test]
fn zstd_sniff_and_decompress() {
let data: Vec<u8> = (0..1000).map(|i| (i % 13) as u8).collect();
let compressed = zstd::stream::encode_all(&data[..], 3).unwrap();
assert_eq!(&compressed[0..4], &[0x28, 0xB5, 0x2F, 0xFD]);
let out = compress::decompress(&compressed).unwrap();
assert_eq!(out, data);
}
#[test]
fn decoded_payload_reads_back_as_msgpack_value() {
let bytes = encode(opcodes::MSG_SEND, &MSGPACK_A1, 1);
let pkt = decode(&bytes).unwrap();
let value = pkt.value().unwrap();
let map = value.as_map().expect("expected a map");
assert_eq!(map.len(), 1);
assert_eq!(map[0].0.as_str(), Some("a"));
assert_eq!(map[0].1.as_u64(), Some(1));
}
#[test]
fn packet_total_len_reads_header() {
let bytes = encode(opcodes::MSG_SEND, &MSGPACK_A1, 1);
assert_eq!(codec::packet_total_len(&bytes), Some(bytes.len()));
// Fewer than a full header → None.
assert_eq!(codec::packet_total_len(&bytes[..4]), None);
}
+344
View File
@@ -0,0 +1,344 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'
show RustStreamSink;
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'
show ExternalLibrary;
import 'src/rust/api/session.dart' hide authMode;
import 'src/rust/api/session.dart' as _rust;
import 'src/rust/frb_generated.dart';
export 'src/rust/api/session.dart'
show
KolibriSession,
SessionOptions,
HandshakeInfo,
PushEvent,
WireLogEvent,
UploadEvent,
UploadEvent_Progress,
UploadEvent_Done,
UploadEvent_Error;
export 'src/rust/api/tls.dart' show setTrustMincifryCa, trustMincifryCa;
export 'src/rust/api/calls.dart'
show
CallSignaling,
CallParams,
IceServer,
ConnectionInfo,
TransmittedData,
decodeVcp,
connectCallSignaling,
parseConnection,
parseTransmittedData;
bool _initialized = false;
/// Load the native library and init the bindings; call once. On Flutter omit
/// [libraryPath] (bundled library used); for `dart run` pass the built dylib path.
Future<void> initKolibri({String? libraryPath}) async {
if (_initialized) return;
await RustLib.init(
externalLibrary:
libraryPath == null ? null : ExternalLibrary.open(libraryPath),
);
_initialized = true;
}
SessionOptions _options({
required String host,
required int port,
required String deviceId,
required String instanceId,
required String appVersion,
required int buildNumber,
required String deviceType,
required String osVersion,
required String timezone,
required String screen,
required String pushDeviceType,
required String arch,
required String locale,
required String deviceName,
required String deviceLocale,
required int clientSessionId,
required int pingIntervalSecs,
required bool pingInteractive,
required bool autoReconnect,
required bool insecureTls,
required String? proxy,
}) {
return SessionOptions(
host: host,
port: port,
deviceId: deviceId,
instanceId: instanceId,
appVersion: appVersion,
buildNumber: buildNumber,
deviceType: deviceType,
osVersion: osVersion,
timezone: timezone,
screen: screen,
pushDeviceType: pushDeviceType,
arch: arch,
locale: locale,
deviceName: deviceName,
deviceLocale: deviceLocale,
clientSessionId: clientSessionId,
pingIntervalSecs: BigInt.from(pingIntervalSecs),
pingInteractive: pingInteractive,
autoReconnect: autoReconnect,
insecureTls: insecureTls,
proxy: proxy,
);
}
/// Create a session with sensible defaults; override any device field to spoof.
KolibriSession openSession({
required String host,
int port = 443,
String deviceId = 'kolibri-dart',
String instanceId = 'kolibri-dart',
String appVersion = '26.20.2',
int buildNumber = 6758,
String deviceType = 'ANDROID',
String osVersion = 'Android 14',
String timezone = 'Europe/Moscow',
String screen = '420dpi 420dpi 1080x2340',
String pushDeviceType = 'GCM',
String arch = 'arm64-v8a',
String locale = 'ru',
String deviceName = 'Dart',
String deviceLocale = 'ru',
int clientSessionId = 1700000000,
int pingIntervalSecs = 30,
bool pingInteractive = true,
bool autoReconnect = true,
bool insecureTls = false,
String? proxy,
}) {
return KolibriSession(
options: _options(
host: host,
port: port,
deviceId: deviceId,
instanceId: instanceId,
appVersion: appVersion,
buildNumber: buildNumber,
deviceType: deviceType,
osVersion: osVersion,
timezone: timezone,
screen: screen,
pushDeviceType: pushDeviceType,
arch: arch,
locale: locale,
deviceName: deviceName,
deviceLocale: deviceLocale,
clientSessionId: clientSessionId,
pingIntervalSecs: pingIntervalSecs,
pingInteractive: pingInteractive,
autoReconnect: autoReconnect,
insecureTls: insecureTls,
proxy: proxy,
),
);
}
/// Like [openSession], but also returns a stream of every packet in both
/// directions (requests, pushes, handshake, ping) rendered for logging.
(KolibriSession, Stream<WireLogEvent>) openSessionWithWireLog({
required String host,
int port = 443,
String deviceId = 'kolibri-dart',
String instanceId = 'kolibri-dart',
String appVersion = '26.20.2',
int buildNumber = 6758,
String deviceType = 'ANDROID',
String osVersion = 'Android 14',
String timezone = 'Europe/Moscow',
String screen = '420dpi 420dpi 1080x2340',
String pushDeviceType = 'GCM',
String arch = 'arm64-v8a',
String locale = 'ru',
String deviceName = 'Dart',
String deviceLocale = 'ru',
int clientSessionId = 1700000000,
int pingIntervalSecs = 30,
bool pingInteractive = true,
bool autoReconnect = true,
bool insecureTls = false,
String? proxy,
}) {
final sink = RustStreamSink<WireLogEvent>();
final session = KolibriSession(
options: _options(
host: host,
port: port,
deviceId: deviceId,
instanceId: instanceId,
appVersion: appVersion,
buildNumber: buildNumber,
deviceType: deviceType,
osVersion: osVersion,
timezone: timezone,
screen: screen,
pushDeviceType: pushDeviceType,
arch: arch,
locale: locale,
deviceName: deviceName,
deviceLocale: deviceLocale,
clientSessionId: clientSessionId,
pingIntervalSecs: pingIntervalSecs,
pingInteractive: pingInteractive,
autoReconnect: autoReconnect,
insecureTls: insecureTls,
proxy: proxy,
),
wireLog: sink,
);
return (session, sink.stream);
}
/// Build a request from a Dart `Map`; the core does the msgpack. A `Uint8List`
/// value is sent as a binary field; an `int` map key is sent as an integer key
/// (JSON can't hold either, so both travel tagged and the core untags them).
extension KolibriRequestMap on KolibriSession {
Future<Map<String, dynamic>> requestMap(
int opcode,
Map<String, dynamic> payload,
) async {
final out = await requestJson(
opcode: opcode,
jsonIn: jsonEncode(_escapeBinary(payload)),
);
return _asMap(out);
}
}
/// A full response: packet command plus decoded payload. A server error is
/// reported here (isError/errorKey), not thrown.
class KolibriResponse {
KolibriResponse({
required this.cmd,
required this.opcode,
required this.payload,
this.errorMessage,
this.errorKey,
});
final int cmd;
final int opcode;
final Map<String, dynamic> payload;
final String? errorMessage;
final String? errorKey;
bool get isOk => cmd == 1;
bool get isNotFound => cmd == 2;
bool get isError => cmd == 3;
}
extension KolibriRequestMapFull on KolibriSession {
Future<KolibriResponse> requestMapFull(
int opcode,
Map<String, dynamic> payload,
) async {
final r = await requestFull(
opcode: opcode,
jsonIn: jsonEncode(_escapeBinary(payload)),
);
return KolibriResponse(
cmd: r.cmd,
opcode: r.opcode,
payload: _asMap(r.payloadJson),
errorMessage: r.errorMessage,
errorKey: r.errorKey,
);
}
}
/// Handshake payload decoded as a map.
extension KolibriHandshakeMap on HandshakeInfo {
Map<String, dynamic> get payloadMap => _asMap(payloadJson);
}
/// The push payload decoded as a map.
extension KolibriPushMap on PushEvent {
Map<String, dynamic> get payloadMap => _asMap(payloadJson);
}
/// Server pushes with their payloads already decoded to maps.
extension KolibriPushesMap on KolibriSession {
Stream<(int, Map<String, dynamic>)> pushesMap() =>
pushes().map((e) => (e.opcode, e.payloadMap));
}
Map<String, dynamic> _asMap(String jsonStr) {
final decoded = _unescapeBinary(jsonDecode(jsonStr));
return decoded is Map ? Map<String, dynamic>.from(decoded) : <String, dynamic>{};
}
/// Inverse of [_escapeBinary]: `{"$bin":"<base64>"}` -> `Uint8List`.
dynamic _unescapeBinary(dynamic value) {
if (value is Map) {
if (value.length == 1 && value[r'$bin'] is String) {
return base64.decode(value[r'$bin'] as String);
}
return value.map((k, v) => MapEntry(k, _unescapeBinary(v)));
}
if (value is List) {
return value.map(_unescapeBinary).toList();
}
return value;
}
dynamic _escapeBinary(dynamic value) {
if (value is Uint8List) {
return {r'$bin': base64.encode(value)};
}
if (value is Map) {
return value.map(
(k, v) => MapEntry(k is int ? '\$int:$k' : k, _escapeBinary(v)),
);
}
if (value is List) {
return value.map(_escapeBinary).toList();
}
return value;
}
/// 96-byte anti-spoof fingerprint (authRequest `mode` / login `chatCacheFingerprint`).
/// signature/dex/so default to the known app values; override (raw bytes) per app build.
Uint8List authMode(
int callsSeed,
String deviceId, {
List<int>? signature,
List<int>? dex,
List<int>? so,
}) {
return _rust.authMode(
signature: signature ?? _defaultSignatureDigest,
dex: dex ?? _defaultDexDigest,
so: so ?? _defaultSoDigest,
callsSeed: callsSeed,
deviceId: deviceId,
);
}
final Uint8List _defaultSignatureDigest = _hex(
'1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93');
final Uint8List _defaultSoDigest = _hex(
'90e2fb8745b17b42a10182f8d8ac590e3fca5b311e2ce2d5144fa2c18cb3090d');
final Uint8List _defaultDexDigest = _hex(
'0a6265f6e5d8231b9cba641f8c40475e6f3baeb06ed41b804b9bf7307aa4214e');
Uint8List _hex(String s) {
final out = Uint8List(s.length ~/ 2);
for (var i = 0; i < out.length; i++) {
out[i] = int.parse(s.substring(i * 2, i * 2 + 2), radix: 16);
}
return out;
}
+217
View File
@@ -0,0 +1,217 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `block`
CallParams? decodeVcp({required String vcp, required String conversationId}) =>
RustLib.instance.api
.crateApiCallsDecodeVcp(vcp: vcp, conversationId: conversationId);
Future<CallSignaling> connectCallSignaling(
{required String url, String? userAgent, String? proxy}) =>
RustLib.instance.api.crateApiCallsConnectCallSignaling(
url: url, userAgent: userAgent, proxy: proxy);
/// connection notification (JSON string). peer is the participant that isn't my_user_id.
ConnectionInfo parseConnection(
{required String notificationJson, required PlatformInt64 myUserId}) =>
RustLib.instance.api.crateApiCallsParseConnection(
notificationJson: notificationJson, myUserId: myUserId);
TransmittedData? parseTransmittedData({required String notificationJson}) =>
RustLib.instance.api
.crateApiCallsParseTransmittedData(notificationJson: notificationJson);
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<CallSignaling>>
abstract class CallSignaling implements RustOpaqueInterface {
Future<String> acceptCall();
Future<String> changeMediaSettings(
{required bool audio, required bool video, required bool screen});
void close();
Future<String> hangup({required String reason});
bool isConnected();
/// ws2 notifications as JSON strings
Stream<String> notifications();
/// raw command; extra_json is a JSON object string
Future<String> sendCommand(
{required String command, required String extraJson});
Future<String> transmitCandidate(
{required PlatformInt64 participantId,
required String candidate,
required String sdpMid,
required PlatformInt64 sdpMlineIndex});
Future<String> transmitSdp(
{required PlatformInt64 participantId,
required String sdpType,
required String sdp});
}
/// decoded vcp call params, plus the ready ws2 url for the given conversation
class CallParams {
final String token;
final String wsEndpoint;
final String? stun;
final List<String> turn;
final String? turnUser;
final String? turnPassword;
final bool isVideo;
final PlatformInt64 userId;
final List<IceServer> iceServers;
final String ws2Url;
const CallParams({
required this.token,
required this.wsEndpoint,
this.stun,
required this.turn,
this.turnUser,
this.turnPassword,
required this.isVideo,
required this.userId,
required this.iceServers,
required this.ws2Url,
});
@override
int get hashCode =>
token.hashCode ^
wsEndpoint.hashCode ^
stun.hashCode ^
turn.hashCode ^
turnUser.hashCode ^
turnPassword.hashCode ^
isVideo.hashCode ^
userId.hashCode ^
iceServers.hashCode ^
ws2Url.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CallParams &&
runtimeType == other.runtimeType &&
token == other.token &&
wsEndpoint == other.wsEndpoint &&
stun == other.stun &&
turn == other.turn &&
turnUser == other.turnUser &&
turnPassword == other.turnPassword &&
isVideo == other.isVideo &&
userId == other.userId &&
iceServers == other.iceServers &&
ws2Url == other.ws2Url;
}
/// parsed ws2 `connection` notification
class ConnectionInfo {
final String? topology;
final bool isSfu;
final Int64List participants;
final PlatformInt64? peer;
final List<IceServer> iceServers;
const ConnectionInfo({
this.topology,
required this.isSfu,
required this.participants,
this.peer,
required this.iceServers,
});
@override
int get hashCode =>
topology.hashCode ^
isSfu.hashCode ^
participants.hashCode ^
peer.hashCode ^
iceServers.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ConnectionInfo &&
runtimeType == other.runtimeType &&
topology == other.topology &&
isSfu == other.isSfu &&
participants == other.participants &&
peer == other.peer &&
iceServers == other.iceServers;
}
class IceServer {
final List<String> urls;
final String? username;
final String? credential;
const IceServer({
required this.urls,
this.username,
this.credential,
});
@override
int get hashCode => urls.hashCode ^ username.hashCode ^ credential.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is IceServer &&
runtimeType == other.runtimeType &&
urls == other.urls &&
username == other.username &&
credential == other.credential;
}
/// SDP or ICE candidate from a `transmitted-data` notification. kind is "sdp" or
/// "candidate"; only the matching fields are set.
class TransmittedData {
final String kind;
final String? sdpType;
final String? sdp;
final String? candidate;
final String? sdpMid;
final PlatformInt64? sdpMlineIndex;
const TransmittedData({
required this.kind,
this.sdpType,
this.sdp,
this.candidate,
this.sdpMid,
this.sdpMlineIndex,
});
@override
int get hashCode =>
kind.hashCode ^
sdpType.hashCode ^
sdp.hashCode ^
candidate.hashCode ^
sdpMid.hashCode ^
sdpMlineIndex.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TransmittedData &&
runtimeType == other.runtimeType &&
kind == other.kind &&
sdpType == other.sdpType &&
sdp == other.sdp &&
candidate == other.candidate &&
sdpMid == other.sdpMid &&
sdpMlineIndex == other.sdpMlineIndex;
}
+390
View File
@@ -0,0 +1,390 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'session.freezed.dart';
// These functions are ignored because they are not marked as `pub`: `cmd_label`, `drive_upload`, `error_fields`, `map_str`, `wire_json`
/// 96-byte anti-spoof fingerprint (authRequest `mode` / login `chatCacheFingerprint`).
/// signature/dex/so are raw digest bytes, passed in so they can change per app version
Uint8List authMode(
{required List<int> signature,
required List<int> dex,
required List<int> so,
required PlatformInt64 callsSeed,
required String deviceId}) =>
RustLib.instance.api.crateApiSessionAuthMode(
signature: signature,
dex: dex,
so: so,
callsSeed: callsSeed,
deviceId: deviceId);
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<KolibriSession>>
abstract class KolibriSession implements RustOpaqueInterface {
/// connect + sessionInit handshake
Future<HandshakeInfo> connect();
void disconnect();
/// `wire_log`, if given, gets every packet both ways (requests, pushes,
/// handshake, ping), across reconnects.
factory KolibriSession(
{required SessionOptions options,
RustStreamSink<WireLogEvent>? wireLog}) =>
RustLib.instance.api
.crateApiSessionKolibriSessionNew(options: options, wireLog: wireLog);
/// keepalive `interactive` flag (foreground/background hint)
bool pingInteractive();
/// server pushes; yields until the session is dropped
Stream<PushEvent> pushes();
/// awaits the response payload (raw msgpack); errors on server error or timeout
Future<Uint8List> request({required int opcode, required List<int> payload});
/// like `request_json`, but reports the packet command and, for an error
/// packet, its full payload (as tagged JSON) plus extracted message/key —
/// nothing is thrown, so the host can run its own rules over the error body
/// (e.g. treat `FAIL_LOGIN_TOKEN`/`FAIL_WRONG_PASSWORD` as expired). Only a
/// lost connection or timeout comes back as `Err`.
Future<RequestOutcome> requestFull(
{required int opcode, required String jsonIn});
/// JSON in, JSON out: `json_in` becomes msgpack (`{"$bin":"<b64>"}` ->
/// binary), and the response comes back as JSON.
Future<String> requestJson({required int opcode, required String jsonIn});
/// fire-and-forget; returns the seq number
int send({required int opcode, required List<int> payload});
/// flip `interactive` on a live session; one ping goes out now so the server
/// hears it right away
void setPingInteractive({required bool interactive});
String state();
/// generic file upload to a CDN url. streams progress, then Done/Error.
/// user_agent defaults to the session's handshake device.
Stream<UploadEvent> uploadFile(
{required String url,
required List<int> data,
required String filename,
String? userAgent});
/// like [`Self::upload_file`], but streams the body off disk from `path`
/// (never loads the whole file into memory).
Stream<UploadEvent> uploadFilePath(
{required String url,
required String path,
required String filename,
String? contentType,
String? connection,
String? userAgent});
/// photo upload, multipart/form-data. photoToken comes back in the Done body.
Stream<UploadEvent> uploadPhoto(
{required String url,
required List<int> data,
required String filename,
String? userAgent});
/// like [`Self::upload_photo`], but streams the file part off disk from `path`.
Stream<UploadEvent> uploadPhotoPath(
{required String url,
required String path,
required String filename,
String? userAgent});
/// video upload, parallel resumable chunks. Done{status:200} means success.
Stream<UploadEvent> uploadVideo(
{required String url,
required List<int> data,
required int chunkSize,
required int concurrency});
/// like [`Self::upload_video`], but reads each chunk off disk from `path` on
/// demand (only one chunk per worker in memory).
Stream<UploadEvent> uploadVideoPath(
{required String url,
required String path,
required int chunkSize,
required int concurrency});
/// HTTP User-Agent derived from the handshake device (opcode 6):
/// `OKMessages/{appVersion} ({osVersion}; {deviceName}; {screen})`. Same
/// string media uploads use; suitable for webviews that should look like
/// the native app.
String userAgent();
}
/// sessionInit handshake result. `payload` is raw msgpack, `payload_json` the
/// same rendered as JSON (for decoding without a msgpack package).
class HandshakeInfo {
final PlatformInt64? callsSeed;
final String? deviceName;
final Uint8List payload;
final String payloadJson;
const HandshakeInfo({
this.callsSeed,
this.deviceName,
required this.payload,
required this.payloadJson,
});
@override
int get hashCode =>
callsSeed.hashCode ^
deviceName.hashCode ^
payload.hashCode ^
payloadJson.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is HandshakeInfo &&
runtimeType == other.runtimeType &&
callsSeed == other.callsSeed &&
deviceName == other.deviceName &&
payload == other.payload &&
payloadJson == other.payloadJson;
}
/// server push; `payload` is raw msgpack, `payload_json` the same as JSON.
class PushEvent {
final int opcode;
final Uint8List payload;
final String payloadJson;
const PushEvent({
required this.opcode,
required this.payload,
required this.payloadJson,
});
@override
int get hashCode => opcode.hashCode ^ payload.hashCode ^ payloadJson.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PushEvent &&
runtimeType == other.runtimeType &&
opcode == other.opcode &&
payload == other.payload &&
payloadJson == other.payloadJson;
}
/// full request result: `cmd` is the packet command (1=ok, 2=not_found,
/// 3=error), `payload_json` the tagged JSON, `error_*` a server error. A server
/// error is reported here, not thrown.
class RequestOutcome {
final int cmd;
final int opcode;
final String payloadJson;
final String? errorMessage;
final String? errorKey;
const RequestOutcome({
required this.cmd,
required this.opcode,
required this.payloadJson,
this.errorMessage,
this.errorKey,
});
@override
int get hashCode =>
cmd.hashCode ^
opcode.hashCode ^
payloadJson.hashCode ^
errorMessage.hashCode ^
errorKey.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RequestOutcome &&
runtimeType == other.runtimeType &&
cmd == other.cmd &&
opcode == other.opcode &&
payloadJson == other.payloadJson &&
errorMessage == other.errorMessage &&
errorKey == other.errorKey;
}
/// device + connection options. device fields feed the sessionInit handshake.
class SessionOptions {
final String host;
final int port;
final String deviceId;
final String instanceId;
final String appVersion;
final PlatformInt64 buildNumber;
final String deviceType;
final String osVersion;
final String timezone;
final String screen;
final String pushDeviceType;
final String arch;
final String locale;
final String deviceName;
final String deviceLocale;
final PlatformInt64 clientSessionId;
final BigInt pingIntervalSecs;
final bool pingInteractive;
final bool autoReconnect;
final bool insecureTls;
/// proxy url `scheme://[user:pass@]host:port` (http/socks5/socks5h), or none
final String? proxy;
/// `isPwa` in the handshake userAgent; sent only when set
final bool? isPwa;
/// `headerUserAgent` in the handshake userAgent; sent only when set
final String? headerUserAgent;
const SessionOptions({
required this.host,
required this.port,
required this.deviceId,
required this.instanceId,
required this.appVersion,
required this.buildNumber,
required this.deviceType,
required this.osVersion,
required this.timezone,
required this.screen,
required this.pushDeviceType,
required this.arch,
required this.locale,
required this.deviceName,
required this.deviceLocale,
required this.clientSessionId,
required this.pingIntervalSecs,
required this.pingInteractive,
required this.autoReconnect,
required this.insecureTls,
this.proxy,
this.isPwa,
this.headerUserAgent,
});
@override
int get hashCode =>
host.hashCode ^
port.hashCode ^
deviceId.hashCode ^
instanceId.hashCode ^
appVersion.hashCode ^
buildNumber.hashCode ^
deviceType.hashCode ^
osVersion.hashCode ^
timezone.hashCode ^
screen.hashCode ^
pushDeviceType.hashCode ^
arch.hashCode ^
locale.hashCode ^
deviceName.hashCode ^
deviceLocale.hashCode ^
clientSessionId.hashCode ^
pingIntervalSecs.hashCode ^
pingInteractive.hashCode ^
autoReconnect.hashCode ^
insecureTls.hashCode ^
proxy.hashCode ^
isPwa.hashCode ^
headerUserAgent.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SessionOptions &&
runtimeType == other.runtimeType &&
host == other.host &&
port == other.port &&
deviceId == other.deviceId &&
instanceId == other.instanceId &&
appVersion == other.appVersion &&
buildNumber == other.buildNumber &&
deviceType == other.deviceType &&
osVersion == other.osVersion &&
timezone == other.timezone &&
screen == other.screen &&
pushDeviceType == other.pushDeviceType &&
arch == other.arch &&
locale == other.locale &&
deviceName == other.deviceName &&
deviceLocale == other.deviceLocale &&
clientSessionId == other.clientSessionId &&
pingIntervalSecs == other.pingIntervalSecs &&
pingInteractive == other.pingInteractive &&
autoReconnect == other.autoReconnect &&
insecureTls == other.insecureTls &&
proxy == other.proxy &&
isPwa == other.isPwa &&
headerUserAgent == other.headerUserAgent;
}
@freezed
sealed class UploadEvent with _$UploadEvent {
const UploadEvent._();
const factory UploadEvent.progress({
required BigInt sent,
required BigInt total,
}) = UploadEvent_Progress;
const factory UploadEvent.done({
required int status,
required Uint8List body,
}) = UploadEvent_Done;
const factory UploadEvent.error({
required String message,
}) = UploadEvent_Error;
}
/// one tapped packet for logs. `direction` "out"/"in", `cmd`
/// "request"/"ok"/"not_found"/"error"/"push", `json` the payload (lossy: binary
/// -> base64).
class WireLogEvent {
final String direction;
final String cmd;
final int opcode;
final int seq;
final String json;
const WireLogEvent({
required this.direction,
required this.cmd,
required this.opcode,
required this.seq,
required this.json,
});
@override
int get hashCode =>
direction.hashCode ^
cmd.hashCode ^
opcode.hashCode ^
seq.hashCode ^
json.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is WireLogEvent &&
runtimeType == other.runtimeType &&
direction == other.direction &&
cmd == other.cmd &&
opcode == other.opcode &&
seq == other.seq &&
json == other.json;
}
@@ -0,0 +1,436 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'session.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$UploadEvent {
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType && other is UploadEvent);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'UploadEvent()';
}
}
/// @nodoc
class $UploadEventCopyWith<$Res> {
$UploadEventCopyWith(UploadEvent _, $Res Function(UploadEvent) __);
}
/// Adds pattern-matching-related methods to [UploadEvent].
extension UploadEventPatterns on UploadEvent {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(UploadEvent_Progress value)? progress,
TResult Function(UploadEvent_Done value)? done,
TResult Function(UploadEvent_Error value)? error,
required TResult orElse(),
}) {
final _that = this;
switch (_that) {
case UploadEvent_Progress() when progress != null:
return progress(_that);
case UploadEvent_Done() when done != null:
return done(_that);
case UploadEvent_Error() when error != null:
return error(_that);
case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(UploadEvent_Progress value) progress,
required TResult Function(UploadEvent_Done value) done,
required TResult Function(UploadEvent_Error value) error,
}) {
final _that = this;
switch (_that) {
case UploadEvent_Progress():
return progress(_that);
case UploadEvent_Done():
return done(_that);
case UploadEvent_Error():
return error(_that);
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(UploadEvent_Progress value)? progress,
TResult? Function(UploadEvent_Done value)? done,
TResult? Function(UploadEvent_Error value)? error,
}) {
final _that = this;
switch (_that) {
case UploadEvent_Progress() when progress != null:
return progress(_that);
case UploadEvent_Done() when done != null:
return done(_that);
case UploadEvent_Error() when error != null:
return error(_that);
case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(BigInt sent, BigInt total)? progress,
TResult Function(int status, Uint8List body)? done,
TResult Function(String message)? error,
required TResult orElse(),
}) {
final _that = this;
switch (_that) {
case UploadEvent_Progress() when progress != null:
return progress(_that.sent, _that.total);
case UploadEvent_Done() when done != null:
return done(_that.status, _that.body);
case UploadEvent_Error() when error != null:
return error(_that.message);
case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(BigInt sent, BigInt total) progress,
required TResult Function(int status, Uint8List body) done,
required TResult Function(String message) error,
}) {
final _that = this;
switch (_that) {
case UploadEvent_Progress():
return progress(_that.sent, _that.total);
case UploadEvent_Done():
return done(_that.status, _that.body);
case UploadEvent_Error():
return error(_that.message);
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(BigInt sent, BigInt total)? progress,
TResult? Function(int status, Uint8List body)? done,
TResult? Function(String message)? error,
}) {
final _that = this;
switch (_that) {
case UploadEvent_Progress() when progress != null:
return progress(_that.sent, _that.total);
case UploadEvent_Done() when done != null:
return done(_that.status, _that.body);
case UploadEvent_Error() when error != null:
return error(_that.message);
case _:
return null;
}
}
}
/// @nodoc
class UploadEvent_Progress extends UploadEvent {
const UploadEvent_Progress({required this.sent, required this.total})
: super._();
final BigInt sent;
final BigInt total;
/// Create a copy of UploadEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$UploadEvent_ProgressCopyWith<UploadEvent_Progress> get copyWith =>
_$UploadEvent_ProgressCopyWithImpl<UploadEvent_Progress>(
this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is UploadEvent_Progress &&
(identical(other.sent, sent) || other.sent == sent) &&
(identical(other.total, total) || other.total == total));
}
@override
int get hashCode => Object.hash(runtimeType, sent, total);
@override
String toString() {
return 'UploadEvent.progress(sent: $sent, total: $total)';
}
}
/// @nodoc
abstract mixin class $UploadEvent_ProgressCopyWith<$Res>
implements $UploadEventCopyWith<$Res> {
factory $UploadEvent_ProgressCopyWith(UploadEvent_Progress value,
$Res Function(UploadEvent_Progress) _then) =
_$UploadEvent_ProgressCopyWithImpl;
@useResult
$Res call({BigInt sent, BigInt total});
}
/// @nodoc
class _$UploadEvent_ProgressCopyWithImpl<$Res>
implements $UploadEvent_ProgressCopyWith<$Res> {
_$UploadEvent_ProgressCopyWithImpl(this._self, this._then);
final UploadEvent_Progress _self;
final $Res Function(UploadEvent_Progress) _then;
/// Create a copy of UploadEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
$Res call({
Object? sent = null,
Object? total = null,
}) {
return _then(UploadEvent_Progress(
sent: null == sent
? _self.sent
: sent // ignore: cast_nullable_to_non_nullable
as BigInt,
total: null == total
? _self.total
: total // ignore: cast_nullable_to_non_nullable
as BigInt,
));
}
}
/// @nodoc
class UploadEvent_Done extends UploadEvent {
const UploadEvent_Done({required this.status, required this.body})
: super._();
final int status;
final Uint8List body;
/// Create a copy of UploadEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$UploadEvent_DoneCopyWith<UploadEvent_Done> get copyWith =>
_$UploadEvent_DoneCopyWithImpl<UploadEvent_Done>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is UploadEvent_Done &&
(identical(other.status, status) || other.status == status) &&
const DeepCollectionEquality().equals(other.body, body));
}
@override
int get hashCode => Object.hash(
runtimeType, status, const DeepCollectionEquality().hash(body));
@override
String toString() {
return 'UploadEvent.done(status: $status, body: $body)';
}
}
/// @nodoc
abstract mixin class $UploadEvent_DoneCopyWith<$Res>
implements $UploadEventCopyWith<$Res> {
factory $UploadEvent_DoneCopyWith(
UploadEvent_Done value, $Res Function(UploadEvent_Done) _then) =
_$UploadEvent_DoneCopyWithImpl;
@useResult
$Res call({int status, Uint8List body});
}
/// @nodoc
class _$UploadEvent_DoneCopyWithImpl<$Res>
implements $UploadEvent_DoneCopyWith<$Res> {
_$UploadEvent_DoneCopyWithImpl(this._self, this._then);
final UploadEvent_Done _self;
final $Res Function(UploadEvent_Done) _then;
/// Create a copy of UploadEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
$Res call({
Object? status = null,
Object? body = null,
}) {
return _then(UploadEvent_Done(
status: null == status
? _self.status
: status // ignore: cast_nullable_to_non_nullable
as int,
body: null == body
? _self.body
: body // ignore: cast_nullable_to_non_nullable
as Uint8List,
));
}
}
/// @nodoc
class UploadEvent_Error extends UploadEvent {
const UploadEvent_Error({required this.message}) : super._();
final String message;
/// Create a copy of UploadEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$UploadEvent_ErrorCopyWith<UploadEvent_Error> get copyWith =>
_$UploadEvent_ErrorCopyWithImpl<UploadEvent_Error>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is UploadEvent_Error &&
(identical(other.message, message) || other.message == message));
}
@override
int get hashCode => Object.hash(runtimeType, message);
@override
String toString() {
return 'UploadEvent.error(message: $message)';
}
}
/// @nodoc
abstract mixin class $UploadEvent_ErrorCopyWith<$Res>
implements $UploadEventCopyWith<$Res> {
factory $UploadEvent_ErrorCopyWith(
UploadEvent_Error value, $Res Function(UploadEvent_Error) _then) =
_$UploadEvent_ErrorCopyWithImpl;
@useResult
$Res call({String message});
}
/// @nodoc
class _$UploadEvent_ErrorCopyWithImpl<$Res>
implements $UploadEvent_ErrorCopyWith<$Res> {
_$UploadEvent_ErrorCopyWithImpl(this._self, this._then);
final UploadEvent_Error _self;
final $Res Function(UploadEvent_Error) _then;
/// Create a copy of UploadEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
$Res call({
Object? message = null,
}) {
return _then(UploadEvent_Error(
message: null == message
? _self.message
: message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on
+13
View File
@@ -0,0 +1,13 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
/// Trust the bundled Минцифры CA (socket, media, calls); off by default, set at startup.
void setTrustMincifryCa({required bool enabled}) =>
RustLib.instance.api.crateApiTlsSetTrustMincifryCa(enabled: enabled);
bool trustMincifryCa() => RustLib.instance.api.crateApiTlsTrustMincifryCa();
File diff suppressed because it is too large Load Diff
+597
View File
@@ -0,0 +1,597 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
import 'api/calls.dart';
import 'api/session.dart';
import 'api/tls.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:ffi' as ffi;
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
RustLibApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_CallSignalingPtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignalingPtr;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_KolibriSessionPtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSessionPtr;
@protected
AnyhowException dco_decode_AnyhowException(dynamic raw);
@protected
CallSignaling
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
dynamic raw);
@protected
KolibriSession
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
dynamic raw);
@protected
CallSignaling
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
dynamic raw);
@protected
KolibriSession
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
dynamic raw);
@protected
CallSignaling
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
dynamic raw);
@protected
KolibriSession
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
dynamic raw);
@protected
RustStreamSink<String> dco_decode_StreamSink_String_Sse(dynamic raw);
@protected
RustStreamSink<PushEvent> dco_decode_StreamSink_push_event_Sse(dynamic raw);
@protected
RustStreamSink<UploadEvent> dco_decode_StreamSink_upload_event_Sse(
dynamic raw);
@protected
RustStreamSink<WireLogEvent> dco_decode_StreamSink_wire_log_event_Sse(
dynamic raw);
@protected
String dco_decode_String(dynamic raw);
@protected
bool dco_decode_bool(dynamic raw);
@protected
bool dco_decode_box_autoadd_bool(dynamic raw);
@protected
CallParams dco_decode_box_autoadd_call_params(dynamic raw);
@protected
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw);
@protected
SessionOptions dco_decode_box_autoadd_session_options(dynamic raw);
@protected
TransmittedData dco_decode_box_autoadd_transmitted_data(dynamic raw);
@protected
CallParams dco_decode_call_params(dynamic raw);
@protected
ConnectionInfo dco_decode_connection_info(dynamic raw);
@protected
HandshakeInfo dco_decode_handshake_info(dynamic raw);
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
IceServer dco_decode_ice_server(dynamic raw);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
List<IceServer> dco_decode_list_ice_server(dynamic raw);
@protected
Int64List dco_decode_list_prim_i_64_strict(dynamic raw);
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
RustStreamSink<WireLogEvent>? dco_decode_opt_StreamSink_wire_log_event_Sse(
dynamic raw);
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
bool? dco_decode_opt_box_autoadd_bool(dynamic raw);
@protected
CallParams? dco_decode_opt_box_autoadd_call_params(dynamic raw);
@protected
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
@protected
TransmittedData? dco_decode_opt_box_autoadd_transmitted_data(dynamic raw);
@protected
PushEvent dco_decode_push_event(dynamic raw);
@protected
RequestOutcome dco_decode_request_outcome(dynamic raw);
@protected
SessionOptions dco_decode_session_options(dynamic raw);
@protected
TransmittedData dco_decode_transmitted_data(dynamic raw);
@protected
int dco_decode_u_16(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@protected
BigInt dco_decode_u_64(dynamic raw);
@protected
int dco_decode_u_8(dynamic raw);
@protected
void dco_decode_unit(dynamic raw);
@protected
UploadEvent dco_decode_upload_event(dynamic raw);
@protected
BigInt dco_decode_usize(dynamic raw);
@protected
WireLogEvent dco_decode_wire_log_event(dynamic raw);
@protected
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
@protected
CallSignaling
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
SseDeserializer deserializer);
@protected
KolibriSession
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
SseDeserializer deserializer);
@protected
CallSignaling
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
SseDeserializer deserializer);
@protected
KolibriSession
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
SseDeserializer deserializer);
@protected
CallSignaling
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
SseDeserializer deserializer);
@protected
KolibriSession
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
SseDeserializer deserializer);
@protected
RustStreamSink<String> sse_decode_StreamSink_String_Sse(
SseDeserializer deserializer);
@protected
RustStreamSink<PushEvent> sse_decode_StreamSink_push_event_Sse(
SseDeserializer deserializer);
@protected
RustStreamSink<UploadEvent> sse_decode_StreamSink_upload_event_Sse(
SseDeserializer deserializer);
@protected
RustStreamSink<WireLogEvent> sse_decode_StreamSink_wire_log_event_Sse(
SseDeserializer deserializer);
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@protected
bool sse_decode_box_autoadd_bool(SseDeserializer deserializer);
@protected
CallParams sse_decode_box_autoadd_call_params(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer);
@protected
SessionOptions sse_decode_box_autoadd_session_options(
SseDeserializer deserializer);
@protected
TransmittedData sse_decode_box_autoadd_transmitted_data(
SseDeserializer deserializer);
@protected
CallParams sse_decode_call_params(SseDeserializer deserializer);
@protected
ConnectionInfo sse_decode_connection_info(SseDeserializer deserializer);
@protected
HandshakeInfo sse_decode_handshake_info(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
IceServer sse_decode_ice_server(SseDeserializer deserializer);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
List<IceServer> sse_decode_list_ice_server(SseDeserializer deserializer);
@protected
Int64List sse_decode_list_prim_i_64_strict(SseDeserializer deserializer);
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
RustStreamSink<WireLogEvent>? sse_decode_opt_StreamSink_wire_log_event_Sse(
SseDeserializer deserializer);
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
bool? sse_decode_opt_box_autoadd_bool(SseDeserializer deserializer);
@protected
CallParams? sse_decode_opt_box_autoadd_call_params(
SseDeserializer deserializer);
@protected
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
@protected
TransmittedData? sse_decode_opt_box_autoadd_transmitted_data(
SseDeserializer deserializer);
@protected
PushEvent sse_decode_push_event(SseDeserializer deserializer);
@protected
RequestOutcome sse_decode_request_outcome(SseDeserializer deserializer);
@protected
SessionOptions sse_decode_session_options(SseDeserializer deserializer);
@protected
TransmittedData sse_decode_transmitted_data(SseDeserializer deserializer);
@protected
int sse_decode_u_16(SseDeserializer deserializer);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@protected
BigInt sse_decode_u_64(SseDeserializer deserializer);
@protected
int sse_decode_u_8(SseDeserializer deserializer);
@protected
void sse_decode_unit(SseDeserializer deserializer);
@protected
UploadEvent sse_decode_upload_event(SseDeserializer deserializer);
@protected
BigInt sse_decode_usize(SseDeserializer deserializer);
@protected
WireLogEvent sse_decode_wire_log_event(SseDeserializer deserializer);
@protected
int sse_decode_i_32(SseDeserializer deserializer);
@protected
void sse_encode_AnyhowException(
AnyhowException self, SseSerializer serializer);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
CallSignaling self, SseSerializer serializer);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
KolibriSession self, SseSerializer serializer);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
CallSignaling self, SseSerializer serializer);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
KolibriSession self, SseSerializer serializer);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
CallSignaling self, SseSerializer serializer);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
KolibriSession self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_String_Sse(
RustStreamSink<String> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_push_event_Sse(
RustStreamSink<PushEvent> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_upload_event_Sse(
RustStreamSink<UploadEvent> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_wire_log_event_Sse(
RustStreamSink<WireLogEvent> self, SseSerializer serializer);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_bool(bool self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_call_params(
CallParams self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_i_64(
PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_session_options(
SessionOptions self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_transmitted_data(
TransmittedData self, SseSerializer serializer);
@protected
void sse_encode_call_params(CallParams self, SseSerializer serializer);
@protected
void sse_encode_connection_info(
ConnectionInfo self, SseSerializer serializer);
@protected
void sse_encode_handshake_info(HandshakeInfo self, SseSerializer serializer);
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_ice_server(IceServer self, SseSerializer serializer);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_ice_server(
List<IceServer> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_i_64_strict(
Int64List self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_loose(List<int> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self, SseSerializer serializer);
@protected
void sse_encode_opt_StreamSink_wire_log_event_Sse(
RustStreamSink<WireLogEvent>? self, SseSerializer serializer);
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_bool(bool? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_call_params(
CallParams? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_i_64(
PlatformInt64? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_transmitted_data(
TransmittedData? self, SseSerializer serializer);
@protected
void sse_encode_push_event(PushEvent self, SseSerializer serializer);
@protected
void sse_encode_request_outcome(
RequestOutcome self, SseSerializer serializer);
@protected
void sse_encode_session_options(
SessionOptions self, SseSerializer serializer);
@protected
void sse_encode_transmitted_data(
TransmittedData self, SseSerializer serializer);
@protected
void sse_encode_u_16(int self, SseSerializer serializer);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@protected
void sse_encode_u_64(BigInt self, SseSerializer serializer);
@protected
void sse_encode_u_8(int self, SseSerializer serializer);
@protected
void sse_encode_unit(void self, SseSerializer serializer);
@protected
void sse_encode_upload_event(UploadEvent self, SseSerializer serializer);
@protected
void sse_encode_usize(BigInt self, SseSerializer serializer);
@protected
void sse_encode_wire_log_event(WireLogEvent self, SseSerializer serializer);
@protected
void sse_encode_i_32(int self, SseSerializer serializer);
}
// Section: wire_class
class RustLibWire implements BaseWire {
factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) =>
RustLibWire(lib.ffiDynamicLibrary);
/// Holds the symbol lookup function.
final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)
_lookup;
/// The symbols are looked up in [dynamicLibrary].
RustLibWire(ffi.DynamicLibrary dynamicLibrary)
: _lookup = dynamicLibrary.lookup;
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
ptr,
);
}
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignalingPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_kolibri_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling');
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling =
_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignalingPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
ptr,
);
}
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignalingPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_kolibri_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling');
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling =
_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignalingPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
ptr,
);
}
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSessionPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_kolibri_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession');
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession =
_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSessionPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
ptr,
);
}
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSessionPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_kolibri_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession');
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession =
_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSessionPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
}
+573
View File
@@ -0,0 +1,573 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
// Static analysis wrongly picks the IO variant, thus ignore this
// ignore_for_file: argument_type_not_assignable
import 'api/calls.dart';
import 'api/session.dart';
import 'api/tls.dart';
import 'dart:async';
import 'dart:convert';
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
RustLibApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_CallSignalingPtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_KolibriSessionPtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession;
@protected
AnyhowException dco_decode_AnyhowException(dynamic raw);
@protected
CallSignaling
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
dynamic raw);
@protected
KolibriSession
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
dynamic raw);
@protected
CallSignaling
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
dynamic raw);
@protected
KolibriSession
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
dynamic raw);
@protected
CallSignaling
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
dynamic raw);
@protected
KolibriSession
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
dynamic raw);
@protected
RustStreamSink<String> dco_decode_StreamSink_String_Sse(dynamic raw);
@protected
RustStreamSink<PushEvent> dco_decode_StreamSink_push_event_Sse(dynamic raw);
@protected
RustStreamSink<UploadEvent> dco_decode_StreamSink_upload_event_Sse(
dynamic raw);
@protected
RustStreamSink<WireLogEvent> dco_decode_StreamSink_wire_log_event_Sse(
dynamic raw);
@protected
String dco_decode_String(dynamic raw);
@protected
bool dco_decode_bool(dynamic raw);
@protected
bool dco_decode_box_autoadd_bool(dynamic raw);
@protected
CallParams dco_decode_box_autoadd_call_params(dynamic raw);
@protected
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw);
@protected
SessionOptions dco_decode_box_autoadd_session_options(dynamic raw);
@protected
TransmittedData dco_decode_box_autoadd_transmitted_data(dynamic raw);
@protected
CallParams dco_decode_call_params(dynamic raw);
@protected
ConnectionInfo dco_decode_connection_info(dynamic raw);
@protected
HandshakeInfo dco_decode_handshake_info(dynamic raw);
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
IceServer dco_decode_ice_server(dynamic raw);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
List<IceServer> dco_decode_list_ice_server(dynamic raw);
@protected
Int64List dco_decode_list_prim_i_64_strict(dynamic raw);
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
RustStreamSink<WireLogEvent>? dco_decode_opt_StreamSink_wire_log_event_Sse(
dynamic raw);
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
bool? dco_decode_opt_box_autoadd_bool(dynamic raw);
@protected
CallParams? dco_decode_opt_box_autoadd_call_params(dynamic raw);
@protected
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
@protected
TransmittedData? dco_decode_opt_box_autoadd_transmitted_data(dynamic raw);
@protected
PushEvent dco_decode_push_event(dynamic raw);
@protected
RequestOutcome dco_decode_request_outcome(dynamic raw);
@protected
SessionOptions dco_decode_session_options(dynamic raw);
@protected
TransmittedData dco_decode_transmitted_data(dynamic raw);
@protected
int dco_decode_u_16(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@protected
BigInt dco_decode_u_64(dynamic raw);
@protected
int dco_decode_u_8(dynamic raw);
@protected
void dco_decode_unit(dynamic raw);
@protected
UploadEvent dco_decode_upload_event(dynamic raw);
@protected
BigInt dco_decode_usize(dynamic raw);
@protected
WireLogEvent dco_decode_wire_log_event(dynamic raw);
@protected
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
@protected
CallSignaling
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
SseDeserializer deserializer);
@protected
KolibriSession
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
SseDeserializer deserializer);
@protected
CallSignaling
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
SseDeserializer deserializer);
@protected
KolibriSession
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
SseDeserializer deserializer);
@protected
CallSignaling
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
SseDeserializer deserializer);
@protected
KolibriSession
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
SseDeserializer deserializer);
@protected
RustStreamSink<String> sse_decode_StreamSink_String_Sse(
SseDeserializer deserializer);
@protected
RustStreamSink<PushEvent> sse_decode_StreamSink_push_event_Sse(
SseDeserializer deserializer);
@protected
RustStreamSink<UploadEvent> sse_decode_StreamSink_upload_event_Sse(
SseDeserializer deserializer);
@protected
RustStreamSink<WireLogEvent> sse_decode_StreamSink_wire_log_event_Sse(
SseDeserializer deserializer);
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@protected
bool sse_decode_box_autoadd_bool(SseDeserializer deserializer);
@protected
CallParams sse_decode_box_autoadd_call_params(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer);
@protected
SessionOptions sse_decode_box_autoadd_session_options(
SseDeserializer deserializer);
@protected
TransmittedData sse_decode_box_autoadd_transmitted_data(
SseDeserializer deserializer);
@protected
CallParams sse_decode_call_params(SseDeserializer deserializer);
@protected
ConnectionInfo sse_decode_connection_info(SseDeserializer deserializer);
@protected
HandshakeInfo sse_decode_handshake_info(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
IceServer sse_decode_ice_server(SseDeserializer deserializer);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
List<IceServer> sse_decode_list_ice_server(SseDeserializer deserializer);
@protected
Int64List sse_decode_list_prim_i_64_strict(SseDeserializer deserializer);
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
RustStreamSink<WireLogEvent>? sse_decode_opt_StreamSink_wire_log_event_Sse(
SseDeserializer deserializer);
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
bool? sse_decode_opt_box_autoadd_bool(SseDeserializer deserializer);
@protected
CallParams? sse_decode_opt_box_autoadd_call_params(
SseDeserializer deserializer);
@protected
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
@protected
TransmittedData? sse_decode_opt_box_autoadd_transmitted_data(
SseDeserializer deserializer);
@protected
PushEvent sse_decode_push_event(SseDeserializer deserializer);
@protected
RequestOutcome sse_decode_request_outcome(SseDeserializer deserializer);
@protected
SessionOptions sse_decode_session_options(SseDeserializer deserializer);
@protected
TransmittedData sse_decode_transmitted_data(SseDeserializer deserializer);
@protected
int sse_decode_u_16(SseDeserializer deserializer);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@protected
BigInt sse_decode_u_64(SseDeserializer deserializer);
@protected
int sse_decode_u_8(SseDeserializer deserializer);
@protected
void sse_decode_unit(SseDeserializer deserializer);
@protected
UploadEvent sse_decode_upload_event(SseDeserializer deserializer);
@protected
BigInt sse_decode_usize(SseDeserializer deserializer);
@protected
WireLogEvent sse_decode_wire_log_event(SseDeserializer deserializer);
@protected
int sse_decode_i_32(SseDeserializer deserializer);
@protected
void sse_encode_AnyhowException(
AnyhowException self, SseSerializer serializer);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
CallSignaling self, SseSerializer serializer);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
KolibriSession self, SseSerializer serializer);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
CallSignaling self, SseSerializer serializer);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
KolibriSession self, SseSerializer serializer);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
CallSignaling self, SseSerializer serializer);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
KolibriSession self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_String_Sse(
RustStreamSink<String> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_push_event_Sse(
RustStreamSink<PushEvent> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_upload_event_Sse(
RustStreamSink<UploadEvent> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_wire_log_event_Sse(
RustStreamSink<WireLogEvent> self, SseSerializer serializer);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_bool(bool self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_call_params(
CallParams self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_i_64(
PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_session_options(
SessionOptions self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_transmitted_data(
TransmittedData self, SseSerializer serializer);
@protected
void sse_encode_call_params(CallParams self, SseSerializer serializer);
@protected
void sse_encode_connection_info(
ConnectionInfo self, SseSerializer serializer);
@protected
void sse_encode_handshake_info(HandshakeInfo self, SseSerializer serializer);
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_ice_server(IceServer self, SseSerializer serializer);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_ice_server(
List<IceServer> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_i_64_strict(
Int64List self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_loose(List<int> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self, SseSerializer serializer);
@protected
void sse_encode_opt_StreamSink_wire_log_event_Sse(
RustStreamSink<WireLogEvent>? self, SseSerializer serializer);
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_bool(bool? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_call_params(
CallParams? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_i_64(
PlatformInt64? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_transmitted_data(
TransmittedData? self, SseSerializer serializer);
@protected
void sse_encode_push_event(PushEvent self, SseSerializer serializer);
@protected
void sse_encode_request_outcome(
RequestOutcome self, SseSerializer serializer);
@protected
void sse_encode_session_options(
SessionOptions self, SseSerializer serializer);
@protected
void sse_encode_transmitted_data(
TransmittedData self, SseSerializer serializer);
@protected
void sse_encode_u_16(int self, SseSerializer serializer);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@protected
void sse_encode_u_64(BigInt self, SseSerializer serializer);
@protected
void sse_encode_u_8(int self, SseSerializer serializer);
@protected
void sse_encode_unit(void self, SseSerializer serializer);
@protected
void sse_encode_upload_event(UploadEvent self, SseSerializer serializer);
@protected
void sse_encode_usize(BigInt self, SseSerializer serializer);
@protected
void sse_encode_wire_log_event(WireLogEvent self, SseSerializer serializer);
@protected
void sse_encode_i_32(int self, SseSerializer serializer);
}
// Section: wire_class
class RustLibWire implements BaseWire {
RustLibWire.fromExternalLibrary(ExternalLibrary lib);
void rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
int ptr) =>
wasmModule
.rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
ptr);
void rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
int ptr) =>
wasmModule
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
ptr);
void rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
int ptr) =>
wasmModule
.rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
ptr);
void rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
int ptr) =>
wasmModule
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
ptr);
}
@JS('wasm_bindgen')
external RustLibWasmModule get wasmModule;
@JS()
@anonymous
extension type RustLibWasmModule._(JSObject _) implements JSObject {
external void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
int ptr);
external void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallSignaling(
int ptr);
external void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
int ptr);
external void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerKolibriSession(
int ptr);
}
+19
View File
@@ -0,0 +1,19 @@
# The Flutter tooling requires that developers have CMake 3.10 or later
# installed. You should not increase this version, as doing so will cause
# the plugin to fail to compile for some customers of the plugin.
cmake_minimum_required(VERSION 3.10)
# Project-level configuration.
set(PROJECT_NAME "kolibri")
project(${PROJECT_NAME} LANGUAGES CXX)
include("../cargokit/cmake/cargokit.cmake")
apply_cargokit(${PROJECT_NAME} ../rust kolibri_dart "")
# List of absolute paths to libraries that should be bundled with the plugin.
# This list could contain prebuilt libraries, or libraries created by an
# external build triggered from this build file.
set(kolibri_bundled_libraries
"${${PROJECT_NAME}_cargokit_lib}"
PARENT_SCOPE
)
+1
View File
@@ -0,0 +1 @@
// This is an empty file to force CocoaPods to create a framework.
+45
View File
@@ -0,0 +1,45 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint kolibri_dart.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'kolibri'
s.version = '0.0.1'
s.summary = 'A new Flutter FFI plugin project.'
s.description = <<-DESC
A new Flutter FFI plugin project.
DESC
s.homepage = 'http://example.com'
s.license = { :file => '../LICENSE' }
s.author = { 'Your Company' => 'email@example.com' }
s.module_name = 'kolibri_dart'
# This will ensure the source files in Classes/ are included in the native
# builds of apps using this FFI plugin. Podspec does not support relative
# paths, so Classes contains a forwarder C file that relatively imports
# `../src/*` so that the C sources can be shared among all target platforms.
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'FlutterMacOS'
s.platform = :osx, '10.11'
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
s.swift_version = '5.0'
s.script_phase = {
:name => 'Build Rust library',
# First argument is relative path to the `rust` folder, second is name of rust library
:script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust kolibri_dart',
:execution_position => :before_compile,
:input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'],
# Let XCode know that the static library referenced in -force_load below is
# created by this build step.
:output_files => ["${PODS_CONFIGURATION_BUILD_DIR}/kolibri_dart/libkolibri_dart.a"],
}
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
# Flutter.framework does not contain a i386 slice.
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
'OTHER_LDFLAGS' => '-force_load ${PODS_CONFIGURATION_BUILD_DIR}/kolibri_dart/libkolibri_dart.a',
}
end
+37
View File
@@ -0,0 +1,37 @@
name: kolibri
description: >-
Dart/Flutter bindings for the Kolibri messaging protocol, powered by a
Rust core via flutter_rust_bridge. Supports Android, iOS, macOS, Linux
and Windows.
version: 0.1.4
environment:
sdk: ">=3.3.0 <4.0.0"
flutter: ">=3.3.0"
dependencies:
flutter:
sdk: flutter
flutter_rust_bridge: 2.12.0
freezed_annotation: ^3.1.0
plugin_platform_interface: ^2.0.2
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.13
freezed: ^3.2.0
flutter:
plugin:
platforms:
android:
ffiPlugin: true
ios:
ffiPlugin: true
linux:
ffiPlugin: true
macos:
ffiPlugin: true
windows:
ffiPlugin: true
+1378
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "kolibri_dart"
version = "0.1.0"
edition = "2021"
description = "Dart/Flutter bindings for the Kolibri messaging protocol (via flutter_rust_bridge)"
license = "MIT OR Apache-2.0"
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
flutter_rust_bridge = "=2.12.0"
kolibri-net = { path = "../kolibri-net" }
tokio = { version = "1", features = ["rt-multi-thread"] }
rmpv = "1"
serde_json = "1"
[workspace]
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "unwind"

Some files were not shown because too many files have changed in this diff Show More