Fix avatar URLs, photo IDs and profile update notifications
Tests / test (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-10-07 22:34:40 +03:00
parent 5423355d83
commit 12b692f4a9
6 changed files with 338 additions and 20 deletions
+12
View File
@@ -18,6 +18,18 @@
Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников.
## Исправление установки аватарки
Фото загружалось на сервер, но профиль и список фотографий возвращали относительный путь изображения и не передавали `photoId`. Клиент не мог открыть фото и корректно обновить кэш. Теперь эти ответы содержат полный HTTPS-адрес и стабильный ID фотографии; замена меняет URL и ID, удаление очищает оба значения. Push обновления профиля использует ожидаемую клиентом структуру `payload.profile`.
Ссылка на аватарку содержит токен, ограниченный конкретным изображением. Закрытую фотографию владелец видит через обычный загрузчик изображений без дополнительных заголовков; настройки доступа сервера продолжают действовать. Такой токен не даёт доступа к другим API.
Рабочий API обновлён до `releases/20261007-avatar`, предыдущий `20261007-komet` сохранён. Перед обновлением создана копия базы и вложений `/mnt/data/ikar_server/backups/ikar-20261007T193300Z.tar.gz`; целостность рабочей базы и копии проверена, соседние контейнеры не перезапускались. Новый APK для этого исправления не требуется.
Проверки: 152 серверных теста и 23 клиентских теста выбора и просмотра аватарок прошли. Публичная проверка загрузки настоящего JPEG воспроизвела проблему до обновления и прошла после: полный URL, ID, push, скачивание с проверкой SHA-256, галерея, повторная загрузка профиля, приватность, замена и удаление. Проверка выполнялась через API и HTTP-загрузчик; выбор фотографии на физическом телефоне в этой задаче не проверялся. Отчёты: `artifacts/avatar-before.json`, `artifacts/avatar-after.json`, `artifacts/avatar-server-tests.log`, `artifacts/avatar-client-tests.log`, `artifacts/deployment-avatar.json`. Повторно прошли 15 общих проверок API и все 8 сценариев невидимости.
Развёртывание: `python3 deploy_server_fixes.py <sha256> <new-release> <expected-current-release> <archive-name>`. Архив должен содержать свежий `publish` и файлы контейнера; скрипт проверяет текущий выпуск, создаёт резервную копию, обновляет API и возвращает прежний контейнер при сбое проверки.
## Доработки из Komet
Адаптированы изменения ветки `feature/FullStack` (`58576bc1`) и жесты из [PR №166](https://github.com/KometTeam/Komet/pull/166) (`10a194ed`). Ползунок качества видео получил полноценную область касания; качество и обрезка учитывают края системных жестов Android. Копирование, выделение и редактирование зашифрованных сообщений используют доступный расшифрованный текст. Недоступный текст не подменяется шифротекстом; локальное хранение и отправка сохраняют существующую схему шифрования.
@@ -0,0 +1,98 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;
using Ikar.Server.Controllers;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure.Storage;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
namespace Ikar.Server.Tests;
public sealed partial class QlyraCompatibilityControllerTests
{
[Fact]
public async Task AvatarUpload_ProfileAndGalleryReturnAbsoluteScopedUrlAndStablePhotoId()
{
var directory = Path.Combine(Path.GetTempPath(), $"ikar-avatar-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
await using var database = new SqliteTestDatabase();
await using var db = await database.CreateContextAsync();
var user = new User { Username = "avatar", NormalizedUsername = "AVATAR", DisplayName = "Avatar", PasswordHash = "test" };
db.Users.Add(user);
await db.SaveChangesAsync();
var uploads = ControllerTestFixture.CreateQlyraUploadStore(directory);
var controller = CreateController(db, user.Id, uploadStore: uploads);
controller.Request.Scheme = "https";
controller.Request.Host = new HostString("ikar.example");
var first = await CommitAvatarAsync(controller, uploads, user.Id);
var contact = first.GetProperty("profile").GetProperty("contact");
var photoId = contact.GetProperty("photoId").GetInt32();
Assert.True(photoId > 0);
var uri = new Uri(contact.GetProperty("baseUrl").GetString()!, UriKind.Absolute);
Assert.Equal("https", uri.Scheme);
Assert.Equal("ikar.kusoft.xyz", uri.Host);
Assert.Contains($"/api/users/{user.Id}/avatar/", uri.AbsolutePath);
var token = new JwtSecurityTokenHandler().ReadJwtToken(QueryHelpers.ParseQuery(uri.Query)["access_token"]!);
Assert.Equal(uri.AbsolutePath, token.Claims.Single(claim => claim.Type == "media_path").Value);
Assert.True(File.Exists(user.AvatarStoragePath));
var firstPath = user.AvatarStoragePath!;
var gallery = await AvatarPayloadAsync(controller, 39, new { contactId = contact.GetProperty("id").GetInt32() });
Assert.Equal(photoId, gallery.GetProperty("ids")[0].GetInt32());
Assert.Equal(uri.AbsolutePath, new Uri(gallery.GetProperty("urls")[0].GetString()!).AbsolutePath);
var reloaded = await AvatarPayloadAsync(controller, 16, new { firstName = "Renamed" });
Assert.Equal(photoId, reloaded.GetProperty("profile").GetProperty("contact").GetProperty("photoId").GetInt32());
var second = (await CommitAvatarAsync(controller, uploads, user.Id)).GetProperty("profile").GetProperty("contact");
Assert.NotEqual(photoId, second.GetProperty("photoId").GetInt32());
Assert.NotEqual(uri.AbsolutePath, new Uri(second.GetProperty("baseUrl").GetString()!).AbsolutePath);
Assert.False(File.Exists(firstPath));
var secondPath = user.AvatarStoragePath!;
var removed = (await AvatarPayloadAsync(controller, 43, new { photoId = second.GetProperty("photoId").GetInt32() })).GetProperty("profile").GetProperty("contact");
Assert.Equal(JsonValueKind.Null, removed.GetProperty("baseUrl").ValueKind);
Assert.Equal(JsonValueKind.Null, removed.GetProperty("photoId").ValueKind);
Assert.False(File.Exists(secondPath));
var empty = await AvatarPayloadAsync(controller, 39, new { contactId = contact.GetProperty("id").GetInt32() });
Assert.Empty(empty.GetProperty("ids").EnumerateArray());
Assert.Empty(empty.GetProperty("urls").EnumerateArray());
}
finally { Directory.Delete(directory, true); }
}
[Fact]
public async Task AvatarLegacyFileName_PhotoIdStaysStableAcrossRequests()
{
await using var database = new SqliteTestDatabase();
await using var db = await database.CreateContextAsync();
var user = new User { Username = "legacy-avatar", NormalizedUsername = "LEGACY-AVATAR", DisplayName = "Legacy", PasswordHash = "test", AvatarStoragePath = "/legacy/profile-photo.jpg" };
db.Users.Add(user);
await db.SaveChangesAsync();
var controller = CreateController(db, user.Id);
controller.Request.Scheme = "https";
controller.Request.Host = new HostString("ikar.example");
var first = (await AvatarPayloadAsync(controller, 16, new { firstName = "First" })).GetProperty("profile").GetProperty("contact");
var second = (await AvatarPayloadAsync(controller, 16, new { firstName = "Second" })).GetProperty("profile").GetProperty("contact");
Assert.Equal(first.GetProperty("photoId").GetInt32(), second.GetProperty("photoId").GetInt32());
Assert.Equal("profile-photo.jpg", new Uri(first.GetProperty("baseUrl").GetString()!).Segments.Last());
}
private static async Task<JsonElement> CommitAvatarAsync(QlyraCompatibilityController controller, QlyraUploadStore uploads, Guid userId)
{
var reservation = uploads.Reserve(userId, "photo");
byte[] bytes = [255, 216, 255, 217];
await using var stream = new MemoryStream(bytes);
Assert.NotNull(await uploads.SaveAsync(reservation.Token, stream, "photo.jpg", "image/jpeg", bytes.Length, CancellationToken.None));
return await AvatarPayloadAsync(controller, 16, new { photoToken = reservation.Token, avatarType = "USER_AVATAR" });
}
private static async Task<JsonElement> AvatarPayloadAsync(QlyraCompatibilityController controller, int opcode, object payload)
{
var response = Assert.IsType<OkObjectResult>(await controller.Dispatch(opcode,
new(JsonSerializer.SerializeToElement(payload)), CancellationToken.None));
return JsonSerializer.SerializeToElement(response.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)).GetProperty("payload");
}
}
@@ -1358,7 +1358,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
}
await dbContext.SaveChangesAsync(cancellationToken);
var profile = await BuildProfileAsync(user, cancellationToken);
await PublishPacketAsync([user.Id], 159, profile, cancellationToken);
await PublishPacketAsync([user.Id], 159, new { profile }, cancellationToken);
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { profile }));
}
@@ -1619,10 +1619,11 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
}
var targetId = await clientIdMapper.FindEntityIdAsync("user", ReadInt(payload, "contactId") ?? 0, cancellationToken);
var user = targetId is null ? null : await dbContext.Users.SingleOrDefaultAsync(item => item.Id == targetId.Value, cancellationToken);
var urls = user is null || string.IsNullOrWhiteSpace(user.AvatarStoragePath)
? Array.Empty<string>()
: new[] { $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}" };
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { urls, total = urls.Length }));
var url = user is null ? null : BuildAvatarUrl(user);
var photoId = user is null ? null : await BuildAvatarPhotoIdAsync(user, cancellationToken);
var urls = url is null ? Array.Empty<string>() : new[] { url };
var ids = photoId is null ? Array.Empty<int>() : new[] { photoId.Value };
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { urls, ids, total = urls.Length }));
}
private async Task<IActionResult> RemoveProfilePhotoAsync(int opcode, CancellationToken cancellationToken)
@@ -2750,7 +2751,8 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
options = user.IsBot ? (string.IsNullOrWhiteSpace(botWebApp) ? new[] { "BOT" } : new[] { "BOT", "HAS_WEBAPP" }) : Array.Empty<string>(),
updateTime = user.CreatedAt.ToUnixTimeMilliseconds(),
names = nameEntries,
baseUrl = string.IsNullOrWhiteSpace(user.AvatarStoragePath) ? null : $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}"
photoId = await BuildAvatarPhotoIdAsync(user, cancellationToken),
baseUrl = BuildAvatarUrl(user)
};
}
@@ -3016,7 +3018,9 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
private string BuildPublicUrl(string path)
{
var url = new Uri(GetPublicBaseUri(), path).ToString();
if (path.Contains("/attachments/", StringComparison.Ordinal) && User.Identity?.IsAuthenticated == true)
if ((path.Contains("/attachments/", StringComparison.Ordinal) ||
path.StartsWith("/api/users/", StringComparison.Ordinal) && path.Contains("/avatar/", StringComparison.Ordinal)) &&
User.Identity?.IsAuthenticated == true)
{
var token = tokenService.CreateMediaToken(User.GetRequiredUserId(), User.GetRequiredSessionId(), path);
url += "?access_token=" + Uri.EscapeDataString(token);
@@ -3024,6 +3028,20 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
return url;
}
private string? BuildAvatarUrl(User user) => string.IsNullOrWhiteSpace(user.AvatarStoragePath)
? null
: BuildPublicUrl($"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}");
private async Task<int?> BuildAvatarPhotoIdAsync(User user, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(user.AvatarStoragePath)) return null;
var fileName = Path.GetFileName(user.AvatarStoragePath);
var photoGuid = Guid.TryParseExact(Path.GetFileNameWithoutExtension(fileName), "N", out var parsed)
? parsed
: new Guid(SHA256.HashData(Encoding.UTF8.GetBytes($"{user.Id:N}:{fileName}")).AsSpan(0, 16));
return await clientIdMapper.GetOrCreateAsync("avatar", photoGuid, cancellationToken);
}
private async Task<object> BuildProfileAsync(User user, CancellationToken cancellationToken)
{
var userId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken);
@@ -3038,9 +3056,8 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
accountStatus = user.IsBlocked ? 1 : 0,
updateTime = user.CreatedAt.ToUnixTimeMilliseconds(),
names = new[] { new { type = "ONEME", firstName = names.FirstName, lastName = names.LastName } },
baseUrl = string.IsNullOrWhiteSpace(user.AvatarStoragePath)
? null
: $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}",
photoId = await BuildAvatarPhotoIdAsync(user, cancellationToken),
baseUrl = BuildAvatarUrl(user),
about = user.About,
online = presenceTracker.IsOnline(user.Id)
},
+2 -1
View File
@@ -90,7 +90,8 @@ builder.Services
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrWhiteSpace(accessToken) &&
(path.StartsWithSegments("/hubs/messenger") ||
(path.StartsWithSegments("/api/chats") || path.StartsWithSegments("/api/stories")) && path.Value!.Contains("/attachments/")))
(path.StartsWithSegments("/api/chats") || path.StartsWithSegments("/api/stories")) && path.Value!.Contains("/attachments/") ||
path.StartsWithSegments("/api/users") && path.Value!.Contains("/avatar/")))
{
context.Token = accessToken;
}
+16 -9
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import hashlib
import json
import os
import re
import shutil
import sqlite3
import subprocess
@@ -13,11 +14,17 @@ import time
import urllib.request
root = Path('/mnt/data/ikar_server')
release = root / 'releases/20261007-komet'
release_name = sys.argv[2] if len(sys.argv) > 2 else '20261007-komet'
previous_name = sys.argv[3] if len(sys.argv) > 3 else '20261007-video'
archive_name = sys.argv[4] if len(sys.argv) > 4 else 'server-fixes.tar.gz'
if not all(re.fullmatch(r'[a-zA-Z0-9_.-]+', name) and name not in {'.', '..'}
for name in (release_name, previous_name, archive_name)):
raise ValueError('Invalid release or archive name')
release = root / 'releases' / release_name
current = root / 'current'
previous = current.resolve(strict=True)
expected_sha = sys.argv[1].lower()
if not os.path.ismount('/mnt/data') or previous != root / 'releases/20261007-video' or release.exists():
if not os.path.ismount('/mnt/data') or previous != root / 'releases' / previous_name or release.exists():
raise RuntimeError('Unexpected release or external disk state')
def snapshot():
@@ -38,20 +45,20 @@ with tempfile.TemporaryDirectory() as temp:
raise RuntimeError('Backup failed integrity verification')
shutil.copytree(previous, release, ignore=shutil.ignore_patterns('publish', 'release.tar.gz'))
with tarfile.open(root / 'server-fixes.tar.gz') as archive:
with tarfile.open(root / archive_name) as archive:
archive.extractall(release, filter='data')
actual_sha = hashlib.sha256((release / 'publish/Ikar.Server.dll').read_bytes()).hexdigest()
if actual_sha != expected_sha:
raise RuntimeError('Uploaded server differs from verified local build')
compose = release / 'compose.yaml'
text = compose.read_text()
old_image = 'image: ikar_server:20261007-video'
old_image = f'image: ikar_server:{previous_name}'
if text.count(old_image) != 1:
raise RuntimeError('Unexpected API image in compose')
compose.write_text(text.replace(old_image, 'image: ikar_server:20261007-komet', 1))
compose.write_text(text.replace(old_image, f'image: ikar_server:{release_name}', 1))
print('Building IKAR API image', flush=True)
with (root / 'build-komet.log').open('w') as log:
subprocess.run(['docker', 'build', '-f', 'Dockerfile.release', '-t', 'ikar_server:20261007-komet', '.'],
with (root / f'build-{release_name}.log').open('w') as log:
subprocess.run(['docker', 'build', '-f', 'Dockerfile.release', '-t', f'ikar_server:{release_name}', '.'],
cwd=release, stdout=log, stderr=subprocess.STDOUT, check=True)
def update(path):
@@ -82,7 +89,7 @@ try:
if integrity != 'ok' or 'PublicLastSeenUnixMs' not in columns:
raise RuntimeError('Database or presence schema verification failed')
subprocess.run(['docker', 'exec', 'ikar_server', 'ffmpeg', '-version'], stdout=subprocess.DEVNULL, check=True)
pending = root / 'current.next-komet'
pending = root / f'current.next-{release_name}'
pending.symlink_to(release, target_is_directory=True)
os.replace(pending, current)
except Exception:
@@ -98,5 +105,5 @@ report = {
'protectedServicesUnchanged': snapshot() == protected_before,
'protectedServices': protected_before,
}
(root / 'deployment-komet.json').write_text(json.dumps(report, indent=2))
(root / f'deployment-{release_name}.json').write_text(json.dumps(report, indent=2))
print(json.dumps(report), flush=True)
+183
View File
@@ -0,0 +1,183 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:image/image.dart' as img;
import 'package:ikar/core/transport/ikar_network.dart';
import 'live_ikar_smoke.dart' as live;
Future<void> main(List<String> args) async {
final label = args.isEmpty ? 'after' : args.first;
final base = Uri.parse('https://ikar.kusoft.xyz');
final client = IkarNetwork.client();
final checks = <String>[];
live.Device? owner;
final report = <String, dynamic>{'passed': false, 'checks': checks};
try {
final stamp = DateTime.now().microsecondsSinceEpoch;
owner = await live.Device.create(
base,
'node',
'+999$stamp',
'Avatar verification',
);
final input = img.decodePng(
await File('assets/ikar_icon.png').readAsBytes(),
)!;
final jpeg = img.encodeJpg(img.copyResize(input, width: 128, height: 128));
Future<Map> upload(List<int> bytes) async {
final slot = await owner!.ok(80, {'count': 1, 'profile': true});
final request = await client.postUrl(Uri.parse(slot['url']));
request.headers.contentType = ContentType('image', 'jpeg');
request.contentLength = bytes.length;
request.add(bytes);
final response = await request.close();
live.check(response.statusCode == 200, 'Avatar upload failed');
final token = jsonDecode(await utf8.decodeStream(response))['photoToken'];
final pushed = owner.transport.events
.firstWhere((event) => event['opcode'] == 159)
.timeout(const Duration(seconds: 10));
final result = await owner.ok(16, {
'photoToken': token,
'avatarType': 'USER_AVATAR',
});
final push = await pushed;
report['profilePushWrapped'] = push['payload']?['profile'] is Map;
return result['profile']['contact'] as Map;
}
final first = await upload(jpeg);
final url = Uri.parse(first['baseUrl'] as String);
report['absoluteAvatarUrl'] = url.isAbsolute;
report['photoIdReturned'] = first['photoId'] is int;
live.check(
url.isAbsolute && url.scheme == 'https',
'Server returned a relative avatar URL',
);
live.check(first['photoId'] is int, 'Server omitted avatar photo ID');
live.check(
report['profilePushWrapped'] == true,
'Profile push has incompatible structure',
);
checks.add(
'upload commits absolute HTTPS URL, photo ID and profile update push',
);
final image = await (await client.getUrl(url)).close();
final fetched = await image.fold<List<int>>(
[],
(bytes, chunk) => bytes..addAll(chunk),
);
live.check(
image.statusCode == 200 &&
sha256.convert(fetched) == sha256.convert(jpeg),
'Avatar download differs from uploaded photo',
);
checks.add('avatar image renders through a normal HTTP image loader');
final history = await owner.ok(39, {'contactId': owner.id});
live.check(
history['ids'][0] == first['photoId'] &&
Uri.parse(history['urls'][0]).isAbsolute,
'Avatar gallery differs from profile',
);
final login = await owner.ok(19, {});
live.check(
login['profile']['contact']['photoId'] == first['photoId'],
'Avatar did not survive profile reload',
);
checks.add('avatar gallery and profile reload preserve current photo ID');
final privacyRequest = await client.putUrl(
base.resolve('/api/users/me/privacy'),
);
privacyRequest.headers.set(
'Authorization',
'Bearer ${owner.transport.token}',
);
privacyRequest.headers.contentType = ContentType.json;
privacyRequest.write(
jsonEncode({
'phoneNumberVisibility': 'nobody',
'lastSeenVisibility': 'nobody',
'profilePhotoVisibility': 'nobody',
}),
);
final privacyResponse = await privacyRequest.close();
await privacyResponse.drain<void>();
live.check(
privacyResponse.statusCode == 200,
'Could not restrict avatar privacy',
);
final privateImage = await (await client.getUrl(url)).close();
await privateImage.drain<void>();
live.check(
privateImage.statusCode == 200,
'Owner cannot display private avatar',
);
final anonymousImage = await (await client.getUrl(
url.replace(query: ''),
)).close();
await anonymousImage.drain<void>();
live.check(
anonymousImage.statusCode == 404,
'Private avatar exposed anonymously',
);
final scopedRequest = await client.getUrl(base.resolve('/api/users/me'));
scopedRequest.headers.set(
'Authorization',
'Bearer ${url.queryParameters['access_token']}',
);
final scopedResponse = await scopedRequest.close();
await scopedResponse.drain<void>();
live.check(
scopedResponse.statusCode == 401,
'Avatar token granted broader API access',
);
checks.add(
'private avatar renders for owner, stays hidden anonymously, and token is resource scoped',
);
final nextJpeg = img.encodeJpg(img.copyRotate(input, angle: 90));
final replacement = await upload(nextJpeg);
live.check(
replacement['photoId'] != first['photoId'] &&
replacement['baseUrl'] != first['baseUrl'],
'Replacing avatar reused the old image cache key',
);
final oldImage = await (await client.getUrl(url)).close();
await oldImage.drain<void>();
live.check(
oldImage.statusCode == 404,
'Replaced avatar is still available',
);
checks.add(
'replacement changes photo ID and URL and removes previous image',
);
final removed = await owner.ok(43, {'photoId': replacement['photoId']});
live.check(
removed['profile']['contact']['baseUrl'] == null &&
removed['profile']['contact']['photoId'] == null,
'Avatar deletion failed',
);
live.check(
(await owner.ok(39, {'contactId': owner.id}))['urls'].isEmpty,
'Deleted avatar remains in gallery',
);
checks.add('avatar deletion clears profile and gallery');
report['passed'] = true;
} catch (error) {
report['error'] = error.toString();
rethrow;
} finally {
report['timestamp'] = DateTime.now().toUtc().toIso8601String();
await File(
'artifacts/avatar-$label.json',
).writeAsString(const JsonEncoder.withIndent(' ').convert(report));
stdout.writeln(jsonEncode(report));
if (owner != null) {
try {
await owner.ok(199, {});
} finally {
await owner.close();
}
}
client.close(force: true);
}
}