mirror of
https://github.com/yume-chan/ya-webadb.git
synced 2025-10-04 18:29:23 +02:00
parent
6750bbc367
commit
4d0f1a11cb
6 changed files with 135 additions and 59 deletions
|
@ -417,6 +417,8 @@ class ScrcpyPageState {
|
||||||
logLevel: ScrcpyLogLevel.Debug,
|
logLevel: ScrcpyLogLevel.Debug,
|
||||||
bitRate: 4_000_000,
|
bitRate: 4_000_000,
|
||||||
tunnelForward: this.tunnelForward,
|
tunnelForward: this.tunnelForward,
|
||||||
|
sendDeviceMeta: false,
|
||||||
|
sendDummyByte: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (encoders.length === 0) {
|
if (encoders.length === 0) {
|
||||||
|
@ -468,6 +470,8 @@ class ScrcpyPageState {
|
||||||
lockVideoOrientation: ScrcpyScreenOrientation.Unlocked,
|
lockVideoOrientation: ScrcpyScreenOrientation.Unlocked,
|
||||||
tunnelForward: this.tunnelForward,
|
tunnelForward: this.tunnelForward,
|
||||||
encoderName: this.selectedEncoder ?? encoders[0],
|
encoderName: this.selectedEncoder ?? encoders[0],
|
||||||
|
sendDeviceMeta: false,
|
||||||
|
sendDummyByte: false,
|
||||||
codecOptions: new CodecOptions({
|
codecOptions: new CodecOptions({
|
||||||
profile: decoder.maxProfile,
|
profile: decoder.maxProfile,
|
||||||
level: decoder.maxLevel,
|
level: decoder.maxLevel,
|
||||||
|
|
0
libraries/scrcpy/scripts/fetch-server.cjs
Normal file → Executable file
0
libraries/scrcpy/scripts/fetch-server.cjs
Normal file → Executable file
|
@ -25,11 +25,6 @@ function* splitLines(text: string): Generator<string, void, void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Size =
|
|
||||||
new Struct()
|
|
||||||
.uint16('width')
|
|
||||||
.uint16('height');
|
|
||||||
|
|
||||||
const VideoPacket =
|
const VideoPacket =
|
||||||
new Struct()
|
new Struct()
|
||||||
.int64('pts')
|
.int64('pts')
|
||||||
|
@ -95,6 +90,8 @@ export class ScrcpyClient {
|
||||||
// Provide an invalid encoder name
|
// Provide an invalid encoder name
|
||||||
// So the server will return all available encoders
|
// So the server will return all available encoders
|
||||||
options.value.encoderName = '_';
|
options.value.encoderName = '_';
|
||||||
|
// Disable control for faster connection in 1.22+
|
||||||
|
options.value.control = false;
|
||||||
|
|
||||||
// Scrcpy server will open connections, before initializing encoder
|
// Scrcpy server will open connections, before initializing encoder
|
||||||
// Thus although an invalid encoder name is given, the start process will success
|
// Thus although an invalid encoder name is given, the start process will success
|
||||||
|
@ -227,14 +224,6 @@ export class ScrcpyClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Device name, we don't need it
|
|
||||||
await this.videoStream.read(64);
|
|
||||||
|
|
||||||
// Initial video size
|
|
||||||
const { width, height } = await Size.deserialize(this.videoStream);
|
|
||||||
this._screenWidth = width;
|
|
||||||
this._screenHeight = height;
|
|
||||||
|
|
||||||
let buffer: ArrayBuffer | undefined;
|
let buffer: ArrayBuffer | undefined;
|
||||||
while (this._running) {
|
while (this._running) {
|
||||||
const { pts, data } = await VideoPacket.deserialize(this.videoStream);
|
const { pts, data } = await VideoPacket.deserialize(this.videoStream);
|
||||||
|
@ -307,7 +296,8 @@ export class ScrcpyClient {
|
||||||
|
|
||||||
private async receiveControl() {
|
private async receiveControl() {
|
||||||
if (!this.controlStream) {
|
if (!this.controlStream) {
|
||||||
throw new Error('receiveControl started before initialization');
|
// control disabled
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -329,32 +319,38 @@ export class ScrcpyClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async injectKeyCode(message: Omit<ScrcpyInjectKeyCodeControlMessage, 'type'>) {
|
private checkControlStream(caller: string) {
|
||||||
if (!this.controlStream) {
|
if (!this._running) {
|
||||||
throw new Error('injectKeyCode called before initialization');
|
throw new Error(`${caller} called before start`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.controlStream.write(ScrcpyInjectKeyCodeControlMessage.serialize({
|
if (!this.controlStream) {
|
||||||
|
throw new Error(`${caller} called with control disabled`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.controlStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async injectKeyCode(message: Omit<ScrcpyInjectKeyCodeControlMessage, 'type'>) {
|
||||||
|
const controlStream = this.checkControlStream('injectKeyCode');
|
||||||
|
|
||||||
|
await controlStream.write(ScrcpyInjectKeyCodeControlMessage.serialize({
|
||||||
...message,
|
...message,
|
||||||
type: ScrcpyControlMessageType.InjectKeycode,
|
type: ScrcpyControlMessageType.InjectKeycode,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async injectText(text: string) {
|
public async injectText(text: string) {
|
||||||
if (!this.controlStream) {
|
const controlStream = this.checkControlStream('injectText');
|
||||||
throw new Error('injectText called before initialization');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.controlStream.write(ScrcpyInjectTextControlMessage.serialize({
|
await controlStream.write(ScrcpyInjectTextControlMessage.serialize({
|
||||||
type: ScrcpyControlMessageType.InjectText,
|
type: ScrcpyControlMessageType.InjectText,
|
||||||
text,
|
text,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async injectTouch(message: Omit<ScrcpyInjectTouchControlMessage, 'type' | 'screenWidth' | 'screenHeight'>) {
|
public async injectTouch(message: Omit<ScrcpyInjectTouchControlMessage, 'type' | 'screenWidth' | 'screenHeight'>) {
|
||||||
if (!this.controlStream) {
|
const controlStream = this.checkControlStream('injectTouch');
|
||||||
throw new Error('injectTouch called before initialization');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.screenWidth || !this.screenHeight) {
|
if (!this.screenWidth || !this.screenHeight) {
|
||||||
return;
|
return;
|
||||||
|
@ -369,20 +365,17 @@ export class ScrcpyClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendingTouchMessage = true;
|
this.sendingTouchMessage = true;
|
||||||
const buffer = ScrcpyInjectTouchControlMessage.serialize({
|
await controlStream.write(ScrcpyInjectTouchControlMessage.serialize({
|
||||||
...message,
|
...message,
|
||||||
type: ScrcpyControlMessageType.InjectTouch,
|
type: ScrcpyControlMessageType.InjectTouch,
|
||||||
screenWidth: this.screenWidth,
|
screenWidth: this.screenWidth,
|
||||||
screenHeight: this.screenHeight,
|
screenHeight: this.screenHeight,
|
||||||
});
|
}));
|
||||||
await this.controlStream.write(buffer);
|
|
||||||
this.sendingTouchMessage = false;
|
this.sendingTouchMessage = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async injectScroll(message: Omit<ScrcpyInjectScrollControlMessage1_22, 'type' | 'screenWidth' | 'screenHeight'>) {
|
public async injectScroll(message: Omit<ScrcpyInjectScrollControlMessage1_22, 'type' | 'screenWidth' | 'screenHeight'>) {
|
||||||
if (!this.controlStream) {
|
const controlStream = this.checkControlStream('injectScroll');
|
||||||
throw new Error('injectScroll called before initialization');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.screenWidth || !this.screenHeight) {
|
if (!this.screenWidth || !this.screenHeight) {
|
||||||
return;
|
return;
|
||||||
|
@ -394,17 +387,15 @@ export class ScrcpyClient {
|
||||||
screenWidth: this.screenWidth,
|
screenWidth: this.screenWidth,
|
||||||
screenHeight: this.screenHeight,
|
screenHeight: this.screenHeight,
|
||||||
});
|
});
|
||||||
await this.controlStream.write(buffer);
|
await controlStream.write(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pressBackOrTurnOnScreen(action: AndroidKeyEventAction) {
|
public async pressBackOrTurnOnScreen(action: AndroidKeyEventAction) {
|
||||||
if (!this.controlStream) {
|
const controlStream = this.checkControlStream('pressBackOrTurnOnScreen');
|
||||||
throw new Error('pressBackOrTurnOnScreen called before initialization');
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = this.options!.serializeBackOrScreenOnControlMessage(action, this.device);
|
const buffer = this.options!.serializeBackOrScreenOnControlMessage(action, this.device);
|
||||||
if (buffer) {
|
if (buffer) {
|
||||||
await this.controlStream.write(buffer);
|
await controlStream.write(buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -414,8 +405,13 @@ export class ScrcpyClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
this._running = false;
|
this._running = false;
|
||||||
|
|
||||||
this.videoStream?.close();
|
this.videoStream?.close();
|
||||||
|
this.videoStream = undefined;
|
||||||
|
|
||||||
this.controlStream?.close();
|
this.controlStream?.close();
|
||||||
|
this.controlStream = undefined;
|
||||||
|
|
||||||
await this.process?.kill();
|
await this.process?.kill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,16 +3,33 @@ import { Disposable } from "@yume-chan/event";
|
||||||
import { ValueOrPromise } from "@yume-chan/struct";
|
import { ValueOrPromise } from "@yume-chan/struct";
|
||||||
import { delay } from "./utils";
|
import { delay } from "./utils";
|
||||||
|
|
||||||
|
export interface ScrcpyClientConnectionOptions {
|
||||||
|
control: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a byte on start to detect connection issues
|
||||||
|
*/
|
||||||
|
sendDummyByte: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send device name and size
|
||||||
|
*/
|
||||||
|
sendDeviceMeta: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class ScrcpyClientConnection implements Disposable {
|
export abstract class ScrcpyClientConnection implements Disposable {
|
||||||
protected device: Adb;
|
protected device: Adb;
|
||||||
|
|
||||||
public constructor(device: Adb) {
|
protected options: ScrcpyClientConnectionOptions;
|
||||||
|
|
||||||
|
public constructor(device: Adb, options: ScrcpyClientConnectionOptions) {
|
||||||
this.device = device;
|
this.device = device;
|
||||||
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(): ValueOrPromise<void> { }
|
public initialize(): ValueOrPromise<void> { }
|
||||||
|
|
||||||
public abstract getStreams(): ValueOrPromise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream]>;
|
public abstract getStreams(): ValueOrPromise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream | undefined]>;
|
||||||
|
|
||||||
public dispose(): void { }
|
public dispose(): void { }
|
||||||
}
|
}
|
||||||
|
@ -33,18 +50,26 @@ export class ScrcpyClientForwardConnection extends ScrcpyClientConnection {
|
||||||
throw new Error(`Can't connect to server after 100 retries`);
|
throw new Error(`Can't connect to server after 100 retries`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async connectAndReadByte(): Promise<AdbBufferedStream> {
|
private async connectVideoStream(): Promise<AdbBufferedStream> {
|
||||||
const stream = await this.connectAndRetry();
|
const stream = await this.connectAndRetry();
|
||||||
// server will write a `0` to signal connection success
|
if (this.options.sendDummyByte) {
|
||||||
await stream.read(1);
|
// server will write a `0` to signal connection success
|
||||||
|
await stream.read(1);
|
||||||
|
}
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getStreams(): Promise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream]> {
|
public async getStreams(): Promise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream | undefined]> {
|
||||||
return [
|
const videoStream = await this.connectVideoStream();
|
||||||
await this.connectAndReadByte(),
|
let controlStream: AdbBufferedStream | undefined;
|
||||||
await this.connectAndRetry()
|
if (this.options.control) {
|
||||||
];
|
controlStream = await this.connectAndRetry();
|
||||||
|
}
|
||||||
|
if (this.options.sendDeviceMeta) {
|
||||||
|
// 64 bytes device name + 2 bytes video width + 2 bytes video height
|
||||||
|
await videoStream.read(64 + 2 + 2);
|
||||||
|
}
|
||||||
|
return [videoStream, controlStream];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,11 +98,17 @@ export class ScrcpyClientReverseConnection extends ScrcpyClientConnection {
|
||||||
return new AdbBufferedStream(await this.streams.dequeue());
|
return new AdbBufferedStream(await this.streams.dequeue());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getStreams(): Promise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream]> {
|
public async getStreams(): Promise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream | undefined]> {
|
||||||
return [
|
const videoStream = await this.accept();
|
||||||
await this.accept(),
|
let controlStream: AdbBufferedStream | undefined;
|
||||||
await this.accept(),
|
if (this.options.control) {
|
||||||
];
|
controlStream = await this.accept();
|
||||||
|
}
|
||||||
|
if (this.options.sendDeviceMeta) {
|
||||||
|
// 64 bytes device name + 2 bytes video width + 2 bytes video height
|
||||||
|
await videoStream.read(64 + 2 + 2);
|
||||||
|
}
|
||||||
|
return [videoStream, controlStream];
|
||||||
}
|
}
|
||||||
|
|
||||||
public override dispose() {
|
public override dispose() {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Adb } from "@yume-chan/adb";
|
import type { Adb } from "@yume-chan/adb";
|
||||||
import Struct, { placeholder } from "@yume-chan/struct";
|
import Struct, { placeholder } from "@yume-chan/struct";
|
||||||
import { AndroidCodecLevel, AndroidCodecProfile } from "../codec";
|
import { AndroidCodecLevel, AndroidCodecProfile } from "../codec";
|
||||||
import { ScrcpyClientConnection, ScrcpyClientForwardConnection, ScrcpyClientReverseConnection } from "../connection";
|
import { ScrcpyClientConnection, ScrcpyClientConnectionOptions, ScrcpyClientForwardConnection, ScrcpyClientReverseConnection } from "../connection";
|
||||||
import { AndroidKeyEventAction, ScrcpyControlMessageType } from "../message";
|
import { AndroidKeyEventAction, ScrcpyControlMessageType } from "../message";
|
||||||
import type { ScrcpyInjectScrollControlMessage1_22 } from "./1_22";
|
import type { ScrcpyInjectScrollControlMessage1_22 } from "./1_22";
|
||||||
import { ScrcpyLogLevel, ScrcpyOptions, ScrcpyOptionValue, ScrcpyScreenOrientation, toScrcpyOptionValue } from "./common";
|
import { ScrcpyLogLevel, ScrcpyOptions, ScrcpyOptionValue, ScrcpyScreenOrientation, toScrcpyOptionValue } from "./common";
|
||||||
|
@ -43,6 +43,11 @@ export interface ScrcpyOptions1_16Type {
|
||||||
|
|
||||||
bitRate: number;
|
bitRate: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0 for unlimited.
|
||||||
|
*
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
maxFps: number;
|
maxFps: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -60,12 +65,14 @@ export interface ScrcpyOptions1_16Type {
|
||||||
/**
|
/**
|
||||||
* Send PTS so that the client may record properly
|
* Send PTS so that the client may record properly
|
||||||
*
|
*
|
||||||
* TODO: This is not implemented yet
|
* @default true
|
||||||
|
*
|
||||||
|
* TODO: Add support for `sendFrameMeta: false`
|
||||||
*/
|
*/
|
||||||
sendFrameMeta: boolean;
|
sendFrameMeta: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Scrcpy 1.22 changed how `control: false` works, and it's not supported yet
|
* @default true
|
||||||
*/
|
*/
|
||||||
control: boolean;
|
control: boolean;
|
||||||
|
|
||||||
|
@ -156,10 +163,16 @@ export class ScrcpyOptions1_16<T extends ScrcpyOptions1_16Type = ScrcpyOptions1_
|
||||||
}
|
}
|
||||||
|
|
||||||
public createConnection(device: Adb): ScrcpyClientConnection {
|
public createConnection(device: Adb): ScrcpyClientConnection {
|
||||||
|
const options: ScrcpyClientConnectionOptions = {
|
||||||
|
// Old scrcpy connection always have control stream no matter what the option is
|
||||||
|
control: true,
|
||||||
|
sendDummyByte: true,
|
||||||
|
sendDeviceMeta: true,
|
||||||
|
};
|
||||||
if (this.value.tunnelForward) {
|
if (this.value.tunnelForward) {
|
||||||
return new ScrcpyClientForwardConnection(device);
|
return new ScrcpyClientForwardConnection(device, options);
|
||||||
} else {
|
} else {
|
||||||
return new ScrcpyClientReverseConnection(device);
|
return new ScrcpyClientReverseConnection(device, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { Adb } from "@yume-chan/adb";
|
||||||
import Struct from "@yume-chan/struct";
|
import Struct from "@yume-chan/struct";
|
||||||
|
import { ScrcpyClientConnection, ScrcpyClientConnectionOptions, ScrcpyClientForwardConnection, ScrcpyClientReverseConnection } from "../connection";
|
||||||
import { ScrcpyInjectScrollControlMessage1_16 } from "./1_16";
|
import { ScrcpyInjectScrollControlMessage1_16 } from "./1_16";
|
||||||
import { ScrcpyOptions1_21, ScrcpyOptions1_21Type } from "./1_21";
|
import { ScrcpyOptions1_21, ScrcpyOptions1_21Type } from "./1_21";
|
||||||
|
|
||||||
|
@ -8,21 +10,23 @@ export interface ScrcpyOptions1_22Type extends ScrcpyOptions1_21Type {
|
||||||
/**
|
/**
|
||||||
* Send device name and size
|
* Send device name and size
|
||||||
*
|
*
|
||||||
* TODO: This is not implemented yet
|
* @default true
|
||||||
*/
|
*/
|
||||||
sendDeviceMeta: boolean;
|
sendDeviceMeta: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write a byte on start to detect connection issues
|
* Write a byte on start to detect connection issues
|
||||||
*
|
*
|
||||||
* TODO: This is not implemented yet
|
* @default true
|
||||||
*/
|
*/
|
||||||
sendDummyByte: boolean;
|
sendDummyByte: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implies `sendDeviceMeta: false`, `sendFrameMeta: false` and `sendDummyByte: false`
|
* Implies `sendDeviceMeta: false`, `sendFrameMeta: false` and `sendDummyByte: false`
|
||||||
*
|
*
|
||||||
* TODO: This is not implemented yet
|
* @default false
|
||||||
|
*
|
||||||
|
* TODO: Add support for `sendFrameMeta: false`
|
||||||
*/
|
*/
|
||||||
rawVideoStream: boolean;
|
rawVideoStream: boolean;
|
||||||
}
|
}
|
||||||
|
@ -36,6 +40,20 @@ export type ScrcpyInjectScrollControlMessage1_22 = typeof ScrcpyInjectScrollCont
|
||||||
|
|
||||||
export class ScrcpyOptions1_22<T extends ScrcpyOptions1_22Type = ScrcpyOptions1_22Type> extends ScrcpyOptions1_21<T> {
|
export class ScrcpyOptions1_22<T extends ScrcpyOptions1_22Type = ScrcpyOptions1_22Type> extends ScrcpyOptions1_21<T> {
|
||||||
public constructor(init: Partial<ScrcpyOptions1_22Type>) {
|
public constructor(init: Partial<ScrcpyOptions1_22Type>) {
|
||||||
|
if (init.rawVideoStream) {
|
||||||
|
// Set implied options for client-side processing
|
||||||
|
init.sendDeviceMeta = false;
|
||||||
|
init.sendFrameMeta = false;
|
||||||
|
init.sendDummyByte = false;
|
||||||
|
// TODO: Add support for `sendFrameMeta: false`
|
||||||
|
throw new Error('`rawVideoStream:true` is not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!init.sendFrameMeta) {
|
||||||
|
// TODO: Add support for `sendFrameMeta: false`
|
||||||
|
throw new Error('`sendFrameMeta:false` is not supported');
|
||||||
|
}
|
||||||
|
|
||||||
super(init);
|
super(init);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,6 +67,20 @@ export class ScrcpyOptions1_22<T extends ScrcpyOptions1_22Type = ScrcpyOptions1_
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override createConnection(device: Adb): ScrcpyClientConnection {
|
||||||
|
const defaultValue = this.getDefaultValue();
|
||||||
|
const options: ScrcpyClientConnectionOptions = {
|
||||||
|
control: this.value.control ?? defaultValue.control,
|
||||||
|
sendDummyByte: this.value.sendDummyByte ?? defaultValue.sendDummyByte,
|
||||||
|
sendDeviceMeta: this.value.sendDeviceMeta ?? defaultValue.sendDeviceMeta,
|
||||||
|
};
|
||||||
|
if (this.value.tunnelForward) {
|
||||||
|
return new ScrcpyClientForwardConnection(device, options);
|
||||||
|
} else {
|
||||||
|
return new ScrcpyClientReverseConnection(device, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public override serializeInjectScrollControlMessage(
|
public override serializeInjectScrollControlMessage(
|
||||||
message: ScrcpyInjectScrollControlMessage1_22,
|
message: ScrcpyInjectScrollControlMessage1_22,
|
||||||
): ArrayBuffer {
|
): ArrayBuffer {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue