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
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:
Vendored
+42
@@ -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.
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+108
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
rootProject.name = 'kolibri'
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
Vendored
+41
@@ -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.
|
||||
Vendored
+10
@@ -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
@@ -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"
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
rust_input: crate::api
|
||||
rust_root: rust/
|
||||
dart_output: lib/src/rust
|
||||
@@ -0,0 +1 @@
|
||||
// This is an empty file to force CocoaPods to create a framework.
|
||||
+46
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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, ¶ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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})"),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
Vendored
+344
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
+2916
File diff suppressed because it is too large
Load Diff
@@ -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>)>();
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
// This is an empty file to force CocoaPods to create a framework.
|
||||
+45
@@ -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
|
||||
Vendored
+37
@@ -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
File diff suppressed because it is too large
Load Diff
Vendored
+25
@@ -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
Reference in New Issue
Block a user