From 20ac23fad279baeea9936f35e3f88530c42dc3c6 Mon Sep 17 00:00:00 2001 From: Ruk Doe Date: Mon, 12 Oct 2026 19:57:20 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20=E4=BB=8A=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B(AVC444?= =?UTF-8?q?=20=E3=81=AE=E6=99=82=E9=96=93=E3=80=81connectionType=20?= =?UTF-8?q?=E3=81=A8=E8=A6=8B=E3=81=9F=E7=9B=AE=E3=81=AE=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E3=80=81=E9=80=81=E3=82=8B=E9=87=8F=E3=82=92=E6=B8=9B=E3=82=89?= =?UTF-8?q?=E3=81=99=E5=B7=A5=E5=A4=AB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2BadDnB3uH3FDGjaPMP4J --- README.md | 1 + docs/rdp-client.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ab23968..223a525 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ IdP には戻り先として `https:///callback` を登録する。Entra I - 解像度は窓の大きさに合わせ、窓を変えると追従する。回線や端末が追いつかないときは解像度を 75%、50% に下げ、余裕が戻ったら上げる - 画面の転送方式は自動で選ぶ。GFX を使い、ブラウザが H.264 を復号できれば H.264 も使う。接続先が GFX に対応していなければ旧来のビットマップ / RemoteFX になる。途中で復号に失敗したら、一段下の方式でつなぎ直す +- 送る量を減らすため、壁紙、ウィンドウをドラッグ中の中身、メニューのアニメーション、テーマ、カーソルの影を切ってもらう。GFX を使えない相手では、RDP の一括圧縮(MPPC)も使う - キーボードは OS の配列から JIS か US かを判定して接続先に伝える - つなぐ先ごとに、前に使った Windows のユーザー名を覚えておく。まだ使っていない先では、最後に使ったユーザー名を入れておく - ツールバー: Ctrl+Alt+Del、Windows キー、診断、全画面、切断。診断は方式や負荷、直近のエラーを左下に出す。クリップボードはテキストだけ共有する diff --git a/docs/rdp-client.md b/docs/rdp-client.md index 8d2d2a7..bfe339c 100644 --- a/docs/rdp-client.md +++ b/docs/rdp-client.md @@ -73,8 +73,9 @@ WebSocket に移るときは、`newToken` でトークンを取り直す。mado - H.264 を使わないときも、Windows は ClearCodec(NSCodec を含む)と RemoteFX Progressive(写真やスクロールで新しく見えた部分)で送る - 版 8.1(AVC420)だけを宣言すると、Windows は版 8 を選んで H.264 を使わなかった。そのため版 10 以上も宣言する。宣言する組み合わせは FreeRDP の `rdpgfx_send_supported_caps` と同じ。選ばれた版は診断の「GFX: 版=」で分かる - Progressive は、同じフレームの前の PDU で送ったタイルを、後の PDU の矩形にも描かせる(矩形だけでタイルの無い PDU も来る。符号化の文脈 codecContextId を並行して使う)。そのため、PDU ごとに「このフレームで復号したタイル全部」をその PDU の矩形で切って描く(FreeRDP の `update_tiles` と同じ)。その PDU のタイルだけを描くと、窓を動かしたあとの空いたところが描かれずに残像になった +- 接続の種類(Client Core の connectionType)を AUTODETECT にすると、Windows は Client Info の performanceFlags を無視して見た目を自分で決め、壁紙も出した。そのため connectionType は LAN にし、回線の測定は SUPPORT_NETCHAR_AUTODETECT だけで続ける。performanceFlags では、壁紙、ドラッグ中の中身、メニューのアニメーション、テーマ、カーソルの影を切る - 版 10.7 を選んだ Windows は、既定の設定では静止した画面を ClearCodec / Progressive で送り、H.264 は動きの多い部分に使う -- グループ ポリシー「H.264/AVC 444 グラフィックス モードを優先する」を有効にすると、全面を AVC444 で送ってくる。ブラウザ側は 2 本の復号と CPU での合成で 1 フレーム約 20ms かかり、動きが続くと遅延が溜まる。既定の設定のほうが軽い +- グループ ポリシー「H.264/AVC 444 グラフィックス モードを優先する」を有効にすると、全面を AVC444 で送ってくる。ブラウザ側は 2 本の復号と面の読み出しで 1 フレーム約 15ms かかる(合成は GPU で約 1ms)。動きが続くと遅延が溜まるので、既定の設定のほうが軽い - GPU を割り当てた VM(`tools/hyperv-test/add-gpu.ps1`)で「すべてのリモート デスクトップ サービス セッションでハードウェアの既定のグラフィックス アダプターを使用する」を有効にすると、接続から最初の画面まで約 40 秒かかった。無効なら約 3 秒 ## AVC444 From 3b775da3c8b95aa9c2016cfd74198a4119b59252 Mon Sep 17 00:00:00 2001 From: Ruk Doe Date: Mon, 12 Oct 2026 20:03:23 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor(web):=20=E6=8E=A5=E7=B6=9A?= =?UTF-8?q?=E3=81=BE=E3=82=8F=E3=82=8A=E3=81=AE=E3=82=B3=E3=83=A1=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=AE=E3=81=9A=E3=82=8C=E3=81=A8=E4=BD=BF=E3=81=A3?= =?UTF-8?q?=E3=81=A6=E3=81=84=E3=81=AA=E3=81=84=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=82=92=E7=89=87=E3=81=A5=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 自動検出を頼まないときも connectionType は LAN。mcs.ts のコメントを直す - GFX を H.264 と書いていたコメントを直す(connect.ts、channels.ts) - establish() は WebSocket だけでなく WebTransport からも来る。コメントを直す - 使っていないものを消す: RdpClient.errorInfo、ChunkStream.isClosed、 Clipboard.remoteHasText、PDUTYPE / PDUTYPE2 / CONTROL / PROTOCOL の使わない値 - 中だけで使うものは export しない(pdu.ts の RFX まわり、cliprdr.ts、input.ts の PTR など) - 重なりを減らす: トークンの取得、中止のときの文言と後片付け、ChunkStream の待ち、 RdpClient のチャネルへの送信、診断の GFX の行 - NTLM の unseal の文言、ツールのコメントを直す Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2BadDnB3uH3FDGjaPMP4J --- tools/rdp-input-smoke.ts | 3 ++- tools/rfx-bench.ts | 2 +- web/app.ts | 36 +++++++++++++----------------------- web/rdp/autodetect.ts | 2 +- web/rdp/channels.ts | 2 +- web/rdp/client.ts | 40 ++++++++++++++-------------------------- web/rdp/cliprdr.ts | 11 ++++------- web/rdp/connect.ts | 19 ++++++++++--------- web/rdp/credssp.ts | 7 +++---- web/rdp/input.ts | 2 +- web/rdp/mcs.ts | 4 ++-- web/rdp/ntlm.ts | 2 +- web/rdp/pdu.ts | 35 +++++------------------------------ web/rdp/stream.ts | 22 +++++++++------------- web/rdp/x224.ts | 1 - web/style.css | 4 ++-- 16 files changed, 69 insertions(+), 123 deletions(-) diff --git a/tools/rdp-input-smoke.ts b/tools/rdp-input-smoke.ts index cbf2d39..0e050e6 100644 --- a/tools/rdp-input-smoke.ts +++ b/tools/rdp-input-smoke.ts @@ -1,7 +1,8 @@ // xrdp を相手にする実機試験。docs/rdp-client.md の「試験」を参照。wslc の node:lts コンテナで動かす。 // つないで画面を PNG に保存したあと、Use default config のボタンを押す、ドックの端末を開いて echo mado と打つ、 // こちらでコピーした文字列を貼り付ける、を順に試す。 -// 環境変数は HOST、PORT、RDPUSER、RDPPASS、OUT。クリック前の画面は OUT の名前に .before.png を付けて保存する +// 環境変数は HOST、PORT、RDPUSER、RDPPASS、OUT、COMPRESS。COMPRESS=0 ならバルク圧縮を頼まない。 +// クリック前の画面は OUT の名前に .before.png を付けて保存する import * as fs from "node:fs"; import * as net from "node:net"; diff --git a/tools/rfx-bench.ts b/tools/rfx-bench.ts index 2cfe5b2..6c06ad4 100644 --- a/tools/rfx-bench.ts +++ b/tools/rfx-bench.ts @@ -1,5 +1,5 @@ // RemoteFX のタイルを復号する速さを計る。xrdp から取った実際のタイルを何度も復号して、1 タイルあたりの時間と内訳を出す。 -// wslc run --rm -v "D:\danything\mado:/app" -w /app oven/bun:1 bun tools/rfx-bench.ts +// wslc run --rm -v ":/app" -w /app oven/bun:1 bun tools/rfx-bench.ts import { decodeTile, parseQuant, reconstruct, rlgrDecode, yccToRgba } from "../web/rdp/rfx"; import { REAL_TILE, rlgrEncode } from "../web/rdp/rfx.fixture"; diff --git a/web/app.ts b/web/app.ts index 87f3316..866b410 100644 --- a/web/app.ts +++ b/web/app.ts @@ -105,7 +105,7 @@ type Login = { password: string; }; -/** 1 回つないで切れるまで。fallback があれば、その方式でつなぎ直す */ +/** 1 回のつなぎの終わり方。fallback があれば、その方式でつなぎ直す */ type SessionEnd = { message: string; fallback?: Mode }; /** 方式を切り替えた記録(診断用) */ @@ -166,15 +166,21 @@ async function runSession( const stops: (() => void)[] = []; try { // トークンは使い捨てで寿命が短いので、つなぐ直前に取る。つなぐ先はトークンに入っていて、サーバーが決める - const { token } = await getJson<{ token: string }>( - `/api/token?target=${encodeURIComponent(login.target)}`, - ); - // 画面の場所を表示してから大きさを測る。clientHeight を読んだ時点でレイアウトが決まる + const newToken = async () => + ( + await getJson<{ token: string }>( + `/api/token?target=${encodeURIComponent(login.target)}`, + ) + ).token; + const token = await newToken(); + // 画面の場所を表示してから大きさを測る。clientHeight を読んだ時点でレイアウトが決まる。 + // RDP の解像度は窓の大きさそのまま。混んでいたら、つないだあとで下げる home.hidden = true; toolbar.hidden = false; $("#current").textContent = login.target; screen.classList.remove("idle"); - const { width, height } = desktopSize(); + const width = screen.clientWidth || innerWidth; + const height = screen.clientHeight || innerHeight; const abort = new AbortController(); connecting = abort; const timer = setTimeout(() => abort.abort(), CONNECT_TIMEOUT_MS); @@ -182,12 +188,7 @@ async function runSession( url: session.ws, wt: session.wt, token, - newToken: async () => - ( - await getJson<{ token: string }>( - `/api/token?target=${encodeURIComponent(login.target)}`, - ) - ).token, + newToken, destination: login.target, username: login.username, password: login.password, @@ -328,9 +329,6 @@ function diagText( lines.push( `負荷: 遅延+${Math.round(gfx.latency.queued ?? 0)}ms 未描画 ${gfx.backlog} 解像度 ${Math.round(currentScale() * 100)}%`, ...scaleSwitches, - ); - if (gfx?.active) - lines.push( `GFX: 版=${gfx.confirmedVersion ?? "-"} ${Object.entries(gfx.stats) .map(([k, v]) => `${k}=${Math.round(v)}`) .join(" ")} clear未対応=${gfx.clearUnsupported}`, @@ -431,14 +429,6 @@ function watchClipboard(canvas: HTMLElement, channels: Channels): () => void { /** 幅は偶数でないと DisplayControl に断られる */ const even = (n: number) => n - (n % 2); -/** つなぐときの RDP の解像度。窓の大きさそのまま。混んでいたら、つないだあとで下げる */ -function desktopSize(): { width: number; height: number } { - return { - width: screen.clientWidth || innerWidth, - height: screen.clientHeight || innerHeight, - }; -} - /** * キーボード配列。OS の配列がはっきり US の形なら米国(0x0409)、それ以外は日本語(0x0411)。 * 配列を調べられないブラウザ(Chromium 以外)では日本語にする diff --git a/web/rdp/autodetect.ts b/web/rdp/autodetect.ts index e5a4d00..ca28af8 100644 --- a/web/rdp/autodetect.ts +++ b/web/rdp/autodetect.ts @@ -7,7 +7,7 @@ import { securityHeader } from "./pdu"; /** Basic Security Header の flags。サーバーの要求とクライアントの応答 */ export const SEC_AUTODETECT_REQ = 0x1000; -export const SEC_AUTODETECT_RSP = 0x2000; +const SEC_AUTODETECT_RSP = 0x2000; const TYPE_ID_AUTODETECT_REQUEST = 0x00; const TYPE_ID_AUTODETECT_RESPONSE = 0x01; diff --git a/web/rdp/channels.ts b/web/rdp/channels.ts index 3a13a66..f9f97fe 100644 --- a/web/rdp/channels.ts +++ b/web/rdp/channels.ts @@ -14,7 +14,7 @@ export type Channels = { export function attachChannels( client: RdpClient, onRemoteText: (text: string) => void, - /** GFX(H.264)の受け手。connect で gfx を指定したときに渡す */ + /** GFX の受け手。connect で gfx を頼んだときに渡す */ options: { gfx?: GfxClient } = {}, ): Channels { const dvc = new DynamicChannels((data) => diff --git a/web/rdp/client.ts b/web/rdp/client.ts index 759db89..9259338 100644 --- a/web/rdp/client.ts +++ b/web/rdp/client.ts @@ -51,8 +51,6 @@ export class RdpClient { private surface: SurfaceRenderer; /** メッセージ チャネルでの回線の自動検出。つなぐ途中で使ったものを引き継ぐ */ private autodetect: AutoDetect; - /** サーバーが切る理由(Set Error Info)。0 は異常なし */ - errorInfo = 0; /** Fast-Path の updateCode ごとの、受けた更新の数。診断に出す */ readonly stats: Record = {}; onChannel?: ChannelHandler; @@ -152,27 +150,24 @@ export class RdpClient { (this.session.channelOptions.get(name) ?? 0) & CHANNEL_OPTION_SHOW_PROTOCOL; for (const chunk of splitChannelData(data, show ? SHOW_PROTOCOL : 0)) - this.session.stream.write( - x224.data(mcs.sendDataRequest(this.session.userId, id, chunk)), - ); + this.sendTo(id, chunk); } private send(fastPathPdu: Uint8Array): void { this.session.stream.write(fastPathPdu); } - private sendIo(data: Uint8Array): void { + /** MCS の Send Data Request で、チャネルへ送る */ + private sendTo(channelId: number, data: Uint8Array): void { this.session.stream.write( - x224.data( - mcs.sendDataRequest( - this.session.userId, - this.session.ioChannelId, - data, - ), - ), + x224.data(mcs.sendDataRequest(this.session.userId, channelId, data)), ); } + private sendIo(data: Uint8Array): void { + this.sendTo(this.session.ioChannelId, data); + } + private handlePdu(p: x224.Pdu): void { // つないだあとの帯域の測定では、届いたものを全部数える。ヘッダーの数バイトは数えない this.autodetect.received(p.data.length); @@ -191,10 +186,7 @@ export class RdpClient { private handleMessage(data: Uint8Array): void { const id = this.session.messageChannelId; const reply = this.autodetect.handle(data); - if (reply && id !== undefined) - this.session.stream.write( - x224.data(mcs.sendDataRequest(this.session.userId, id, reply)), - ); + if (reply && id !== undefined) this.sendTo(id, reply); } private handleChannel(channelId: number, chunk: Uint8Array): void { @@ -234,12 +226,10 @@ export class RdpClient { break; case pdu.PDUTYPE2.SET_ERROR_INFO: { const code = pdu.parseErrorInfo(sd.payload); - if (code !== 0) { - this.errorInfo = code; + if (code !== 0) throw new mcs.Disconnected( `サーバーが切った (ErrorInfo 0x${code.toString(16)})`, ); - } break; } case pdu.PDUTYPE2.SHUTDOWN_DENIED: @@ -262,14 +252,14 @@ export class RdpClient { } this.rfxCodecId = pdu.serverRfxCodecId(d.capabilities); const s = this.session; - // Confirm Active と同期の 4 つを送る。つないだときと同じで、connect.ts も同じ関数を使う + // Confirm Active と同期の 4 つを送る。つないだときと同じ関数を使う for (const p of pdu.activationPdus(s.userId, d.shareId, { width: this.width, height: this.height, keyboardLayout: s.keyboardLayout, })) this.sendIo(p); - // 復号中のタイルなど、大きさが変わる前の画面を先に描き終えておく + // 復号中のタイルを描き終えてから、大きさを変える const { width, height } = this; this.surface.ordered(() => this.screen.resize(width, height)); } @@ -297,16 +287,14 @@ export class RdpClient { this.screen.setCursor("default"); break; case UPDATE.POINTER: - case UPDATE.LARGE_POINTER: { + case UPDATE.LARGE_POINTER: this.setPointer(() => pointer.parseNewPointer(data, code === UPDATE.LARGE_POINTER), ); break; - } - case UPDATE.COLOR: { + case UPDATE.COLOR: this.setPointer(() => pointer.parseColorPointer(data)); break; - } case UPDATE.CACHED: { const c = this.cursors.get(pointer.parseCachedPointer(data)); if (c) this.screen.setCursor(c); diff --git a/web/rdp/cliprdr.ts b/web/rdp/cliprdr.ts index 8ffde6a..c49fb14 100644 --- a/web/rdp/cliprdr.ts +++ b/web/rdp/cliprdr.ts @@ -16,7 +16,7 @@ const RESPONSE_OK = 0x0001; const RESPONSE_FAIL = 0x0002; const USE_LONG_FORMAT_NAMES = 0x0002; -export const CF_UNICODETEXT = 13; +const CF_UNICODETEXT = 13; function pdu( type: number, @@ -49,8 +49,6 @@ export function decodeText(data: Uint8Array): string { export class Clipboard { private ready = false; private local?: string; - /** サーバーがくれた書式の中に CF_UNICODETEXT があるか */ - private remoteHasText = false; /** 静的チャネル cliprdr へ送る。onRemoteText は、サーバー側でコピーされた文字列を受ける */ constructor( @@ -92,10 +90,10 @@ export class Clipboard { ); this.sendFormatList(); break; - case MSG.FORMAT_LIST: { - this.remoteHasText = parseFormatIds(body).includes(CF_UNICODETEXT); + case MSG.FORMAT_LIST: + // サーバーに文字列があれば、すぐに中身を頼む this.send(pdu(MSG.FORMAT_LIST_RESPONSE, RESPONSE_OK)); - if (this.remoteHasText) + if (parseFormatIds(body).includes(CF_UNICODETEXT)) this.send( pdu( MSG.FORMAT_DATA_REQUEST, @@ -104,7 +102,6 @@ export class Clipboard { ), ); break; - } case MSG.FORMAT_DATA_REQUEST: { const format = new Reader(body).u32(); if (format === CF_UNICODETEXT && this.local !== undefined) diff --git a/web/rdp/connect.ts b/web/rdp/connect.ts index cd405da..938dc69 100644 --- a/web/rdp/connect.ts +++ b/web/rdp/connect.ts @@ -21,8 +21,8 @@ export type ConnectOptions = { /** /api/token でもらった使い捨てのトークン */ token: string; /** - * トークンを取り直す。WebTransport の CONNECT はトークンを使うので、WebTransport が開けずに WebSocket に移るときに使う - * (サーバーが CONNECT を受けた後で、ブラウザの待ち時間が切れることがある) + * トークンを取り直す。WebTransport が開けずに WebSocket に移るときに使う。 + * サーバーが CONNECT でトークンを使ったあとで、ブラウザの待ち時間が切れることがある */ newToken?: () => Promise; /** つなぐ先。実際の行き先はトークンで決まるので、サーバーはこれを見ない */ @@ -37,7 +37,7 @@ export type ConnectOptions = { onLog?: (line: string) => void; /** 中止の合図。時間がかかりすぎたときや、利用者が取り消したときに使う */ signal?: AbortSignal; - /** GFX(H.264)を使う。受け手の GfxClient を attachChannels に渡しておく */ + /** GFX(Graphics Pipeline)を頼む。受け手の GfxClient は attachChannels に渡す */ gfx?: boolean; /** 試験用。NLA を使わない RDP サーバー(TLS だけ)も許す */ allowTls?: boolean; @@ -137,6 +137,8 @@ export async function openTransport( return { stream: await openers.ws(o.url), kind: "WebSocket", token }; } +const ABORTED = "つなぐのに時間がかかりすぎたか、取り消した"; + export async function connect( o: ConnectOptions, openers?: TransportOpeners, @@ -146,7 +148,7 @@ export async function connect( log(`中継: ${kind}`); if (o.signal?.aborted) { stream.close(); - throw new Error("つなぐのに時間がかかりすぎた(または取り消した)"); + throw new Error(ABORTED); } const abort = () => stream.close(); o.signal?.addEventListener("abort", abort, { once: true }); @@ -165,20 +167,19 @@ export async function connect( log("RDCleanPath OK"); const session = await establish(stream, o, resp.x224, cert); session.transport = kind; - o.signal?.removeEventListener("abort", abort); return session; } catch (e) { - o.signal?.removeEventListener("abort", abort); stream.close(); - if (o.signal?.aborted) - throw new Error("つなぐのに時間がかかりすぎた(または取り消した)"); + if (o.signal?.aborted) throw new Error(ABORTED); throw e; + } finally { + o.signal?.removeEventListener("abort", abort); } } /** * TLS が張れて、X.224 Connection Confirm とサーバーの証明書を受け取ったところから、つながるまで進める。 - * connect() は WebSocket でここまで来る。試験は TCP + TLS で直接ここから始める + * connect() は mado の中継と RDCleanPath でここまで来る。試験は TCP + TLS で直接ここから始める */ export async function establish( stream: Transport, diff --git a/web/rdp/credssp.ts b/web/rdp/credssp.ts index 46b926f..1f8c0ce 100644 --- a/web/rdp/credssp.ts +++ b/web/rdp/credssp.ts @@ -98,12 +98,11 @@ function tsCredentials( user: string, password: string, ): Uint8Array { - const u16 = utf16le; const creds = der.tlv( der.SEQUENCE, - der.field(0, der.octets(u16(domain))), - der.field(1, der.octets(u16(user))), - der.field(2, der.octets(u16(password))), + der.field(0, der.octets(utf16le(domain))), + der.field(1, der.octets(utf16le(user))), + der.field(2, der.octets(utf16le(password))), ); return der.tlv( der.SEQUENCE, diff --git a/web/rdp/input.ts b/web/rdp/input.ts index 3098aae..a26fe50 100644 --- a/web/rdp/input.ts +++ b/web/rdp/input.ts @@ -149,7 +149,7 @@ export function unicodeEvent(char: number, down: boolean): Uint8Array { .done(); } -export const PTR = { +const PTR = { WHEEL_NEGATIVE: 0x0100, WHEEL: 0x0200, HWHEEL: 0x0400, diff --git a/web/rdp/mcs.ts b/web/rdp/mcs.ts index 318a45d..82c17e0 100644 --- a/web/rdp/mcs.ts +++ b/web/rdp/mcs.ts @@ -21,7 +21,7 @@ export type ClientCore = { keyboardLayout?: number; /** SUPPORT_DYNVC_GFX_PROTOCOL を立てて、GFX(Graphics Pipeline)を使えると伝える */ gfx?: boolean; - /** 回線の自動検出を頼む。既定は true。頼まないときは LAN と伝える */ + /** 回線の自動検出を頼む。既定は true。connectionType は頼んでも頼まなくても LAN */ autodetect?: boolean; }; @@ -214,7 +214,7 @@ export type ServerData = { ioChannelId: number; /** 要求した静的チャネルに割り当てられた ID。要求した順に並ぶ */ channelIds: number[]; - /** SC_CORE の clientRequestedProtocols / earlyCapabilityFlags。無ければ 0 */ + /** SC_CORE の earlyCapabilityFlags。無ければ 0 */ earlyCapabilityFlags: number; /** 回線の自動検出に使うメッセージ チャネルの ID。頼まなかったときや、サーバーが使わないときは無い */ messageChannelId?: number; diff --git a/web/rdp/ntlm.ts b/web/rdp/ntlm.ts index 7130314..22516ca 100644 --- a/web/rdp/ntlm.ts +++ b/web/rdp/ntlm.ts @@ -273,7 +273,7 @@ export class Ntlm { unseal(msg: Uint8Array): Uint8Array { const seal = this.recvSeal; - if (!seal) throw new Error("NTLM: 認証の前に封印しようとした"); + if (!seal) throw new Error("NTLM: 認証の前に封印を解こうとした"); if (msg.length < 16) throw new Error("NTLM: 封印したメッセージが短い"); const r = new Reader(msg); r.u32(); diff --git a/web/rdp/pdu.ts b/web/rdp/pdu.ts index ad4db21..fd1988a 100644 --- a/web/rdp/pdu.ts +++ b/web/rdp/pdu.ts @@ -1,6 +1,6 @@ // 接続の手順([MS-RDPBCGR] 1.3.1.1)で使う PDU。Client Info、ライセンス、Share Control / Share Data、能力セット。 -import { Reader, Writer } from "./bytes"; +import { fromHex, Reader, Writer } from "./bytes"; import { keyboardSubType, keyboardType } from "./mcs"; import { type Bulk, PACKET_COMPR_TYPE_64K } from "./mppc"; @@ -165,24 +165,17 @@ export const PDUTYPE = { CONFIRM_ACTIVE: 0x3, DEACTIVATE_ALL: 0x6, DATA: 0x7, - SERVER_REDIR: 0xa, } as const; export const PDUTYPE2 = { UPDATE: 0x02, CONTROL: 0x14, POINTER: 0x1b, - INPUT: 0x1c, SYNCHRONIZE: 0x1f, - REFRESH_RECT: 0x21, - SUPPRESS_OUTPUT: 0x23, - SHUTDOWN_REQUEST: 0x24, SHUTDOWN_DENIED: 0x25, - SAVE_SESSION_INFO: 0x26, FONTLIST: 0x27, FONTMAP: 0x28, SET_ERROR_INFO: 0x2f, - MONITOR_LAYOUT: 0x37, FRAME_ACKNOWLEDGE: 0x38, } as const; @@ -268,7 +261,6 @@ export const synchronize = (targetUser: number) => export const CONTROL = { REQUEST: 1, GRANTED: 2, - DETACH: 3, COOPERATE: 4, } as const; @@ -329,36 +321,19 @@ export const CAP = { } as const; /** Frame Acknowledge 能力で伝える、応答していないフレームの最大数 */ -export const MAX_UNACKNOWLEDGED_FRAMES = 3; +const MAX_UNACKNOWLEDGED_FRAMES = 3; /** RemoteFX の codecId。クライアントが決めて宣言する */ -export const RFX_CODEC_ID = 3; +const RFX_CODEC_ID = 3; /** RemoteFX のコーデック GUID、76772F12-BD72-4463-AFB3-B73C9C6F7886。GUID は先頭 3 つの欄がリトル エンディアン */ -export const RFX_GUID = Uint8Array.of( - 0x12, - 0x2f, - 0x77, - 0x76, - 0x72, - 0xbd, - 0x63, - 0x44, - 0xaf, - 0xb3, - 0xb7, - 0x3c, - 0x9c, - 0x6f, - 0x78, - 0x86, -); +const RFX_GUID = fromHex("122f7776 72bd 6344 afb3 b73c9c6f7886"); /** * Bitmap Codecs 能力の bitmapCodecCount 以降。載せるのは RemoteFX だけ。 * 中身は 49 バイトの TS_RFX_CLNT_CAPS_CONTAINER で、RLGR1 と RLGR3、64x64 のタイル、ICT、DWT 5/3、画像モードを伝える */ -export function bitmapCodecs(): Uint8Array { +function bitmapCodecs(): Uint8Array { const icap = (entropy: number) => new Writer(8) .u16(0x0100) // version 1.0 diff --git a/web/rdp/stream.ts b/web/rdp/stream.ts index 3645527..3d208a8 100644 --- a/web/rdp/stream.ts +++ b/web/rdp/stream.ts @@ -3,7 +3,7 @@ import { concat } from "./bytes"; -/** CredSSP などが使う読み書きの口。試験では WebSocket の代わりに TCP をつなぐ */ +/** CredSSP などが使う読み書きの口。試験では中継の代わりに TCP をつなぐ */ export interface ByteStream { peek(n: number): Promise; read(n: number): Promise; @@ -43,25 +43,25 @@ abstract class ChunkStream implements Transport { w?.(); } - /** n バイト揃うまで待って返す */ - async read(n: number): Promise { + /** n バイト溜まるまで待つ。その前に閉じたら例外にする */ + private async fill(n: number): Promise { while (this.have < n) { if (this.closed) throw this.closed; await new Promise((r) => { this.waiter = r; }); } + } + + /** n バイト揃うまで待って返す */ + async read(n: number): Promise { + await this.fill(n); return this.take(n); } /** 先頭の n バイトを、取り出さずに見る */ async peek(n: number): Promise { - while (this.have < n) { - if (this.closed) throw this.closed; - await new Promise((r) => { - this.waiter = r; - }); - } + await this.fill(n); const first = this.chunks[0] as Uint8Array; if (first.length >= n) return first.subarray(0, n); const all = concat(...this.chunks); @@ -97,10 +97,6 @@ abstract class ChunkStream implements Transport { abstract write(b: Uint8Array): void; abstract close(): void; - - get isClosed(): boolean { - return !!this.closed; - } } export class WsStream extends ChunkStream { diff --git a/web/rdp/x224.ts b/web/rdp/x224.ts index e674f21..5d821c9 100644 --- a/web/rdp/x224.ts +++ b/web/rdp/x224.ts @@ -7,7 +7,6 @@ export const PROTOCOL = { RDP: 0, SSL: 1, HYBRID: 2, - HYBRID_EX: 8, } as const; /** RDP_NEG_REQ 付きの X.224 Connection Request。TPKT ヘッダーも含め、RDCleanPath の x224_pdu にそのまま入れる */ diff --git a/web/style.css b/web/style.css index cbd536e..4326b12 100644 --- a/web/style.css +++ b/web/style.css @@ -1,5 +1,5 @@ -/* 土台は Blades CSS(Pico CSS の後継。https://blades.ninja/)。ここで色・角丸・影を今風(灰色の濃淡と細い枠線)に寄せ、 - * 並べ方を決める。色は Pico の変数を上書きする(同じ詳細度のセレクタで、後に書いたほうが勝つ) */ +/* 土台は Blades CSS(Pico CSS の後継。https://blades.ninja/)。ここでは色、角丸、影と並べ方を決める。 + * 色は Pico の変数を上書きする。同じ詳細度のセレクタなので、後に書いたほうが勝つ */ @import "@anyblades/blades"; :root:not([data-theme="dark"]) { From c16debe702b95d942701881f7b7d2ae55a6f7ee4 Mon Sep 17 00:00:00 2001 From: Ruk Doe Date: Mon, 12 Oct 2026 20:05:57 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor(web):=20=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E3=81=AE=E5=BE=A9=E5=8F=B7=E3=81=A8=E6=8F=8F=E7=94=BB=E3=81=AE?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92=E6=95=B4=E7=90=86=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - タイルを矩形で切る処理が 3 か所にあったのを rfx.ts の cutTile にまとめる(GFX の RemoteFX、Progressive、Surface Commands) - 試験でしか使っていなかった ProgressiveDecoder を消す。試験は ProgressiveTiles と ProgressiveFrame で gfx.ts と同じ道を通す - Progressive の通常の帯の表を rfx.ts の BANDS と共用する - 矩形の重なりを gfx.ts の clipRect にまとめ、clampRects も gfx.ts へ移す - Yuv444 と Yuv444GlRef の共通部分(3 面、draw、close)を基底クラスにまとめる。evenRect の繰り返しを evenRects にする - 32 ビットの BGRA から RGBA への変換を bitmap.ts の bgraToRgba にまとめる。LITTLE_ENDIAN も 1 つにする - GfxClient の totalFrames を stats.frames にまとめる。面を捨てるときの処理を forgetSurface にまとめる - MapSurfaceToOutput と MapSurfaceToScaledOutput の同じ処理をまとめる - AVC444 の時間の統計を 1 回ごとに丸めていたのをやめる。1ms 未満が 0 に切り捨てられて合計が小さく出ていた - Surface Commands の使わない destRight / destBottom を引数から外す - ポインタの効かない分岐、autoscale の重なった条件、ClearCodec の余分な代入を消す - コメントを短い文に直す Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2BadDnB3uH3FDGjaPMP4J --- web/rdp/autoscale.ts | 6 +- web/rdp/bitmap.ts | 19 ++++- web/rdp/clearcodec.ts | 7 +- web/rdp/gfx-canvas.ts | 45 ++++++------ web/rdp/gfx.ts | 96 +++++++++++++------------ web/rdp/pointer.ts | 3 +- web/rdp/progressive.test.ts | 44 ++++++++---- web/rdp/progressive.ts | 98 ++++++------------------- web/rdp/rfx.ts | 45 +++++++++++- web/rdp/surface.ts | 83 ++++++++-------------- web/rdp/yuv444.test.ts | 3 +- web/rdp/yuv444.ts | 138 +++++++++++++----------------------- web/rdp/zgfx.ts | 9 ++- 13 files changed, 271 insertions(+), 325 deletions(-) diff --git a/web/rdp/autoscale.ts b/web/rdp/autoscale.ts index ab20c43..d5b9d91 100644 --- a/web/rdp/autoscale.ts +++ b/web/rdp/autoscale.ts @@ -37,11 +37,7 @@ export class FrameLatency { // 起動直後の時刻合わせなどで、どちらかの時計が飛んだ。前の遅れとは比べられないので測り直す const last = this.samples.at(-1); if (last && Math.abs(delay - last.delay) > CLOCK_JUMP_MS) this.samples = []; - while ( - this.samples.length && - this.samples[0] && - mono - this.samples[0].at > BASE_WINDOW_MS - ) + while (this.samples[0] && mono - this.samples[0].at > BASE_WINDOW_MS) this.samples.shift(); this.samples.push({ at: mono, delay }); let base = delay; diff --git a/web/rdp/bitmap.ts b/web/rdp/bitmap.ts index 0750c0c..0404cda 100644 --- a/web/rdp/bitmap.ts +++ b/web/rdp/bitmap.ts @@ -3,6 +3,7 @@ // 画面は 32 ビットで頼む。mcs.ts と pdu.ts の能力を参照。 import { Reader } from "./bytes"; +import { LITTLE_ENDIAN } from "./rfx"; export type BitmapRect = { left: number; @@ -65,6 +66,22 @@ export type BitmapJob = Pick< "width" | "height" | "bpp" | "compressed" | "data" >; +/** 上の行から並ぶ 32 ビットの BGRA(または BGRX)を RGBA にする。alpha でなければ不透明にする */ +export function bgraToRgba( + src: Uint8Array, + pixels: number, + alpha = false, +): Uint8ClampedArray { + const out = new Uint8ClampedArray(pixels * 4); + for (let i = 0; i < pixels * 4; i += 4) { + out[i] = src[i + 2] as number; + out[i + 1] = src[i + 1] as number; + out[i + 2] = src[i] as number; + out[i + 3] = alpha ? (src[i + 3] as number) : 255; + } + return out; +} + /** 矩形を RGBA にする。上の行から並ぶ width x height */ export function decodeBitmap(b: BitmapJob | BitmapRect): Uint8ClampedArray { if (b.bpp === 8) throw new Error("8 ビット色の画面は使えない"); @@ -444,8 +461,6 @@ function chromaTable(cll: number): Int16Array { return t; } -const LITTLE_ENDIAN = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1; - /** * Planar の 32bpp を、上の行から並ぶ RGBA にする。面はアルファ(無いこともある)、赤、緑、青の順。CLL が 0 でなければ赤、緑、青の代わりに Y、Co、Cg が来る。 * 行はほかの旧ビットマップと同じく下から上へ並ぶ。色の間引き(CS)には対応していない diff --git a/web/rdp/clearcodec.ts b/web/rdp/clearcodec.ts index 1016db0..e075fef 100644 --- a/web/rdp/clearcodec.ts +++ b/web/rdp/clearcodec.ts @@ -104,13 +104,12 @@ export class ClearDecoder { (this.shortVBarCursor + 1) % SHORT_VBAR_STORAGE; bar = this.fullBar(short, yOn, height, bg); } else { - // VBAR_CACHE_HIT + // VBAR_CACHE_HIT。キャッシュには足さない const hit = this.vBars[header & 0x7fff]; if (!hit) throw new Error("ClearCodec: キャッシュに無い棒"); - bar = hit; - if (bar.length !== height * 3) + if (hit.length !== height * 3) throw new Error("ClearCodec: 棒の高さが合わない"); - this.drawBar(bar, x, yStart, height, out, w, h); + this.drawBar(hit, x, yStart, height, out, w, h); continue; } this.vBars[this.vBarCursor] = bar; diff --git a/web/rdp/gfx-canvas.ts b/web/rdp/gfx-canvas.ts index 8bc940f..5067deb 100644 --- a/web/rdp/gfx-canvas.ts +++ b/web/rdp/gfx-canvas.ts @@ -1,8 +1,9 @@ -// GFX のブラウザ側: 面を canvas に持ち、フレームの終わりに変わったところを画面(Display)へ写す。H.264 は WebCodecs の VideoDecoder。 +// GFX のブラウザ側。面を canvas に持ち、フレームの終わりに変わったところを画面(Display)へ写す。H.264 は WebCodecs の VideoDecoder。 import type { Display } from "./display"; import { type AvcDecoder, + clipRect, codecString, type GfxOps, type Rect, @@ -66,7 +67,7 @@ export class CanvasGfxOps implements GfxOps { private createGl: () => Yuv444Gl | undefined = sharedYuv444Gl, ) {} - /** AVC444 の YUV444 を GPU で組み立てる。WebGL2 が無い・失敗したときは undefined(CPU で組み立てる) */ + /** AVC444 の YUV444 を GPU で組み立てる。WebGL2 が無いときや失敗したときは undefined で、CPU で組み立てることになる */ yuv444(id: number, width: number, height: number): Yuv444Target | undefined { if (this.gl === undefined) { const gl = this.createGl(); @@ -164,7 +165,7 @@ export class CanvasGfxOps implements GfxOps { rgba: Uint8ClampedArray, ): void { const s = this.surface(id); - // アルファを持たない面(XRGB)では、NSCodec や Planar が持つアルファは使わない(FreeRDP と同じ) + // アルファを持たない面(XRGB)では、NSCodec や Planar が持つアルファは使わない。FreeRDP と同じ if (!s.alpha) for (let i = 3; i < rgba.length; i += 4) rgba[i] = 255; s.ctx.putImageData( new ImageData(rgba as Uint8ClampedArray, w, h), @@ -279,8 +280,8 @@ export class CanvasGfxOps implements GfxOps { } /** - * フレームの終わり。描画の周期(vsync)に 1 回だけ画面へ写す: 周期の最初のフレームはすぐ写し(待たせない)、 - * 同じ周期に続いて来たものは、周期の終わりにまとめて 1 回写す(変わったところは面ごとに和集合で残っている) + * フレームの終わり。画面へ写すのは描画の周期(vsync)に 1 回だけ。周期の最初のフレームは待たせずにすぐ写す。 + * 同じ周期に続いて来たものは、周期の終わりにまとめて 1 回写す。変わったところは面ごとに和集合で残っている */ present(): void { if (this.waitingTick) { @@ -309,7 +310,12 @@ export class CanvasGfxOps implements GfxOps { const d = s.dirty; s.dirty = undefined; if (!d || !s.map) continue; - const r = clip(d, { left: 0, top: 0, right: s.width, bottom: s.height }); + const r = clipRect(d, { + left: 0, + top: 0, + right: s.width, + bottom: s.height, + }); if (r) changed.push({ left: s.map.x + r.left, @@ -329,7 +335,7 @@ export class CanvasGfxOps implements GfxOps { right: s.map.x + s.width, bottom: s.map.y + s.height, }; - const r = clip(c, onScreen); + const r = clipRect(c, onScreen); if (!r) continue; const w = r.right - r.left; const h = r.bottom - r.top; @@ -345,7 +351,7 @@ export class CanvasGfxOps implements GfxOps { } /** - * 次の描画の周期に cb を 1 回呼ぶ。requestAnimationFrame は隠れたタブなどで止まるので、50ms で打ち切る(画面が止まったままにしない) + * 次の描画の周期に cb を 1 回呼ぶ。requestAnimationFrame は隠れたタブなどで止まる。画面が止まったままにならないよう、50ms で打ち切る */ function nextTick(cb: () => void): void { let done = false; @@ -358,17 +364,6 @@ function nextTick(cb: () => void): void { setTimeout(once, 50); } -/** 2 つの矩形の重なり。無ければ undefined */ -function clip(a: Rect, b: Rect): Rect | undefined { - const left = Math.max(a.left, b.left); - const top = Math.max(a.top, b.top); - const right = Math.min(a.right, b.right); - const bottom = Math.min(a.bottom, b.bottom); - return right > left && bottom > top - ? { left, top, right, bottom } - : undefined; -} - /** 1 フレームの復号を待つ長さ。過ぎたら捨てる */ const AVC_TIMEOUT_MS = 1000; @@ -429,7 +424,7 @@ export class WebCodecsAvc implements AvcDecoder { }); } - /** AVC444 用: 1 枚を復号して YUV420 の 3 面を読み出す(I420 / NV12 でなければ null) */ + /** AVC444 用。1 枚を復号して YUV420 の 3 面を読み出す。I420 / NV12 でなければ null */ async decodeYuv(stream: Uint8Array, key: boolean): Promise { const frame = await this.decode(stream, key); if (!frame) return null; @@ -460,8 +455,7 @@ export class WebCodecsAvc implements AvcDecoder { this.hardware = false; this.needKey = true; this.codec = undefined; - for (const r of this.waiting.values()) r(null); - this.waiting.clear(); + this.dropWaiting(); }, }); this.decoder.configure({ @@ -476,9 +470,14 @@ export class WebCodecsAvc implements AvcDecoder { this.shutdown(); } - private shutdown(): void { + /** 待っている復号を全部 null で終わらせる */ + private dropWaiting(): void { for (const r of this.waiting.values()) r(null); this.waiting.clear(); + } + + private shutdown(): void { + this.dropWaiting(); if (this.decoder && this.decoder.state !== "closed") this.decoder.close(); this.decoder = undefined; } diff --git a/web/rdp/gfx.ts b/web/rdp/gfx.ts index b417f97..edac184 100644 --- a/web/rdp/gfx.ts +++ b/web/rdp/gfx.ts @@ -4,7 +4,7 @@ // 描画は GfxOps に任せる。ブラウザでは canvas、試験では偽物を使う。H.264 の復号は AvcDecoder に任せる。ブラウザでは VideoDecoder を使う。 import { FrameLatency, gfxTimestampMs } from "./autoscale"; -import { decodePlanar } from "./bitmap"; +import { bgraToRgba, decodePlanar } from "./bitmap"; import { Reader, Writer } from "./bytes"; import { ClearDecoder } from "./clearcodec"; import type { DynamicChannel, DynamicHandler } from "./dvc"; @@ -14,9 +14,9 @@ import { ProgressiveTiles, parseProgressive, } from "./progressive"; -import { decodeStream } from "./rfx"; +import { cutTile, decodeStream } from "./rfx"; import type { TilePool } from "./rfxpool"; -import { clampRects, type Yuv420, Yuv444, type Yuv444Target } from "./yuv444"; +import { type Yuv420, Yuv444, type Yuv444Target } from "./yuv444"; import { Zgfx } from "./zgfx"; export const GRAPHICS = "Microsoft::Windows::RDS::Graphics"; @@ -94,6 +94,29 @@ const MAX_CACHE_BYTES = 256 * 1024 * 1024; export type Rect = { left: number; top: number; right: number; bottom: number }; +/** 2 つの矩形の重なり。無ければ undefined */ +export function clipRect(a: Rect, b: Rect): Rect | undefined { + const left = Math.max(a.left, b.left); + const top = Math.max(a.top, b.top); + const right = Math.min(a.right, b.right); + const bottom = Math.min(a.bottom, b.bottom); + return right > left && bottom > top + ? { left, top, right, bottom } + : undefined; +} + +/** 描く範囲。矩形を w x h の面に収める。regions が空なら全体 */ +export function clampRects(regions: Rect[], w: number, h: number): Rect[] { + const all = { left: 0, top: 0, right: w, bottom: h }; + if (!regions.length) return [all]; + const out: Rect[] = []; + for (const r of regions) { + const c = clipRect(r, all); + if (c) out.push(c); + } + return out; +} + /** 面への描画。GFX の座標は面の左上が原点で、right / bottom は含まない */ export interface GfxOps { /** 画面(出力)の大きさ。ResetGraphics で来る */ @@ -377,7 +400,6 @@ export class GfxClient implements DynamicHandler { private surfacePixels = 0; /** ZGFX の伸長に失敗した。履歴が壊れたので、以後の絵は正しくならない */ private broken = false; - private totalFrames = 0; /** 受け取ったが、まだ描き終えていないフレーム(EndFrame)の数 */ private queuedFrames = 0; /** いま描いているフレームの、サーバー側の時刻。StartFrame で来る */ @@ -390,7 +412,7 @@ export class GfxClient implements DynamicHandler { private yuv444 = new Map(); /** サーバーが選んだ能力の版と flags。CapsConfirm で来る */ confirmed?: { version: number; flags: number }; - /** 診断用 */ + /** 診断用。frames は描き終えたフレームの数で、Frame Acknowledge の totalFramesDecoded にも使う */ readonly stats = { frames: 0, avc: 0, @@ -579,9 +601,7 @@ export class GfxClient implements DynamicHandler { } this.surfacePixels = pixels + width * height; this.surfaces.set(id, { width, height, alpha }); - this.progressiveTiles.deleteSurface(id); - this.progressiveFrame.deleteSurface(id); - this.dropYuv444(id); + this.forgetSurface(id); this.ops.createSurface(id, width, height, alpha); break; } @@ -590,22 +610,15 @@ export class GfxClient implements DynamicHandler { const old = this.surfaces.get(id); if (old) this.surfacePixels -= old.width * old.height; this.surfaces.delete(id); - this.progressiveTiles.deleteSurface(id); - this.progressiveFrame.deleteSurface(id); - this.dropYuv444(id); + this.forgetSurface(id); this.ops.deleteSurface(id); break; } - case CMD.MAPSURFACETOOUTPUT: { - const id = r.u16(); - r.u16(); - this.ops.mapSurface(id, r.u32(), r.u32()); - break; - } + // 拡縮して写す版は、10.7 では SCALEDMAP_DISABLE で断っている。10.7 より前の版でも使われないはず。来たら等倍で写す + case CMD.MAPSURFACETOOUTPUT: case CMD.MAPSURFACETOSCALEDOUTPUT: { - // 版 10.7 では SCALEDMAP_DISABLE で断っている。10.7 より前の版でも拡縮は使われないはず。来たら等倍で写す const id = r.u16(); - r.u16(); + r.u16(); // reserved this.ops.mapSurface(id, r.u32(), r.u32()); break; } @@ -626,10 +639,9 @@ export class GfxClient implements DynamicHandler { performance.now(), ); this.frameTimestamp = undefined; - this.totalFrames++; this.stats.frames++; this.channel?.send( - frameAcknowledge(frameId, this.totalFrames, this.queuedFrames), + frameAcknowledge(frameId, this.stats.frames, this.queuedFrames), ); break; } @@ -772,14 +784,7 @@ export class GfxClient implements DynamicHandler { switch (codec) { case CODEC.UNCOMPRESSED: { if (data.length < w * h * 4) throw new Error("GFX: 生の画素が短い"); - const rgba = new Uint8ClampedArray(w * h * 4); - const argb = format === PIXEL_FORMAT_ARGB; - for (let i = 0; i < w * h; i++) { - rgba[i * 4] = data[i * 4 + 2] as number; - rgba[i * 4 + 1] = data[i * 4 + 1] as number; - rgba[i * 4 + 2] = data[i * 4] as number; - rgba[i * 4 + 3] = argb ? (data[i * 4 + 3] as number) : 255; - } + const rgba = bgraToRgba(data, w * h, format === PIXEL_FORMAT_ARGB); this.stats.uncompressed++; this.ops.putRgba(id, dest.left, dest.top, w, h, rgba); break; @@ -806,21 +811,15 @@ export class GfxClient implements DynamicHandler { this.stats.rfx++; for (const t of decodeStream(data)) { const rects = t.rects.length ? t.rects : [{ x: 0, y: 0, w, h }]; - for (const rc of rects) { - const x0 = Math.max(t.x, rc.x); - const y0 = Math.max(t.y, rc.y); - const x1 = Math.min(t.x + 64, rc.x + rc.w, w); - const y1 = Math.min(t.y + 64, rc.y + rc.h, h); - if (x1 <= x0 || y1 <= y0) continue; - const cw = x1 - x0; - const ch = y1 - y0; - const part = new Uint8ClampedArray(cw * ch * 4); - for (let y = 0; y < ch; y++) { - const s = ((y0 - t.y + y) * 64 + (x0 - t.x)) * 4; - part.set(t.rgba.subarray(s, s + cw * 4), y * cw * 4); - } - this.ops.putRgba(id, dest.left + x0, dest.top + y0, cw, ch, part); - } + for (const p of cutTile(t.rgba, t.x, t.y, rects, w, h)) + this.ops.putRgba( + id, + dest.left + p.x, + dest.top + p.y, + p.w, + p.h, + p.rgba, + ); } break; } @@ -893,7 +892,7 @@ export class GfxClient implements DynamicHandler { const luma = lumaP && (await lumaP); const chroma = chromaP && (await chromaP); const t1 = performance.now(); - this.stats.avc444DecodeMs += Math.round(t1 - t0); + this.stats.avc444DecodeMs += t1 - t0; if ((m.luma && !luma) || (m.chroma && !chroma)) { this.stats.skipped++; // 鍵フレームを待つ間も null なので、すぐには諦めない @@ -933,10 +932,13 @@ export class GfxClient implements DynamicHandler { this.yuv444.set(id, st); st.draw(apply(st), dest.left, dest.top, put); } - this.stats.avc444ConvertMs += Math.round(performance.now() - t1); + this.stats.avc444ConvertMs += performance.now() - t1; } - private dropYuv444(id: number): void { + /** 面を作り直したときや消したときに、その面について覚えていたものを捨てる */ + private forgetSurface(id: number): void { + this.progressiveTiles.deleteSurface(id); + this.progressiveFrame.deleteSurface(id); this.yuv444.get(id)?.close(); this.yuv444.delete(id); } diff --git a/web/rdp/pointer.ts b/web/rdp/pointer.ts index 5632c64..df83410 100644 --- a/web/rdp/pointer.ts +++ b/web/rdp/pointer.ts @@ -122,9 +122,8 @@ function decode( default: throw new Error(`ポインタの色の深さ ${bpp} は使えない`); } - // 32 ビットはアルファで透明かが決まる。それ以外は AND マスクで決まる + // 32 ビットはアルファで透明かが決まる。それ以外は AND マスクで決まる。AND が 1 でも色があれば、反転の代わりに色を出す if (bpp !== 32 && transparent && r === 0 && g === 0 && b === 0) a = 0; - else if (bpp !== 32 && transparent) a = 255; out[d] = r; out[d + 1] = g; out[d + 2] = b; diff --git a/web/rdp/progressive.test.ts b/web/rdp/progressive.test.ts index c02ecde..beb3281 100644 --- a/web/rdp/progressive.test.ts +++ b/web/rdp/progressive.test.ts @@ -3,13 +3,33 @@ import { describe, expect, test } from "bun:test"; import { concat, Writer } from "./bytes"; import { - ProgressiveDecoder, - type ProgressivePart, + ProgressiveFrame, + ProgressiveTiles, + parseProgressive, parseProgressiveQuant, srlValues, } from "./progressive"; +import type { TilePart } from "./rfx"; import { rlgrEncode } from "./rfx.fixture"; +/** その場で復号する。gfx.ts と同じく ProgressiveTiles で復号し、ProgressiveFrame で切る。フレームは PDU ごとに始め直す */ +class Decoder { + private tiles = new ProgressiveTiles(); + deleteSurface(id: number): void { + this.tiles.deleteSurface(id); + } + decode( + surface: number, + data: Uint8Array, + width: number, + height: number, + ): TilePart[] { + const msg = parseProgressive(surface, data, width, height); + const rgba = msg.tiles.map((t) => this.tiles.decode(t.job)); + return new ProgressiveFrame().draw(surface, msg, rgba, width, height); + } +} + const block = (type: number, body: Uint8Array) => new Writer() .u16(type) @@ -138,7 +158,7 @@ function stream(...blocks: Uint8Array[]) { } /** すべての部分のすべての画素が同じ灰色なら、その値 */ -function gray(parts: ProgressivePart[]): number { +function gray(parts: TilePart[]): number { const v = parts[0]?.rgba[0] as number; for (const p of parts) for (let i = 0; i < p.rgba.length; i += 4) { @@ -171,7 +191,7 @@ describe("Progressive", () => { test("TILE_SIMPLE: DC だけなら一様な灰色(外挿・通常の逆 DWT とも)。Region の矩形と面で切る", () => { for (const extrapolate of [true, false]) { - const d = new ProgressiveDecoder(); + const d = new Decoder(); const parts = d.decode( 1, stream( @@ -196,7 +216,7 @@ describe("Progressive", () => { }); test("空のタイルは灰色(128)。2 つ目のタイルの位置", () => { - const d = new ProgressiveDecoder(); + const d = new Decoder(); const parts = d.decode( 1, stream( @@ -214,7 +234,7 @@ describe("Progressive", () => { }); test("差分(RFX_TILE_DIFFERENCE)は前の係数に足す。面を消すと前の係数も消える", () => { - const d = new ProgressiveDecoder(); + const d = new Decoder(); const run = () => gray(d.decode(1, stream(region([simple(0, 0, 1, dc(5))])), 64, 64)); expect( @@ -231,7 +251,7 @@ describe("Progressive", () => { }); test("TILE_FIRST(粗い段)→ TILE_UPGRADE(最後の段): 足りない下のビットを足す", () => { - const d = new ProgressiveDecoder(); + const d = new Decoder(); // quality 0 では、Y の全部の帯が 2 ビット粗くなる。ビットの位置は 8、ずらしは 7 const prog = [[all(2), all(0), all(0)]]; const a = d.decode( @@ -255,7 +275,7 @@ describe("Progressive", () => { }); test("upgrade: LL3 は符号によらず RAW の値をそのまま足す", () => { - const d = new ProgressiveDecoder(); + const d = new Decoder(); const prog = [[all(2), all(0), all(0)]]; d.decode(1, stream(region([first(0, 0, 0, dc(-10))], { prog })), 64, 64); // LL3 は符号によらず RAW の値をそのまま足す: -1280 + 3 * 32 = -1184 → (−1184 + 4096) >> 5 = 91 @@ -278,7 +298,7 @@ describe("Progressive", () => { return rlgrEncode(c, 1); }; const prog = [[all(2), all(0), all(0)]]; - const d = new ProgressiveDecoder(); + const d = new Decoder(); d.decode( 1, stream(region([first(0, 0, 0, coef(1, 10))], { prog })), @@ -295,7 +315,7 @@ describe("Progressive", () => { 64, ); // (1 << 2) + 3 = 7、(10 << 2) + 1 = 41 - const [whole] = new ProgressiveDecoder().decode( + const [whole] = new Decoder().decode( 1, stream(region([simple(0, 0, 0, coef(7, 41))])), 64, @@ -306,7 +326,7 @@ describe("Progressive", () => { }); test("面の外のタイルは飛ばし、ほかのタイルは描く", () => { - const out = new ProgressiveDecoder().decode( + const out = new Decoder().decode( 1, stream(region([simple(1, 0, 0, EMPTY), simple(0, 0, 0, EMPTY)])), 64, @@ -316,7 +336,7 @@ describe("Progressive", () => { }); test("不正な入力は例外", () => { - const d = new ProgressiveDecoder(); + const d = new Decoder(); expect(() => d.decode(1, block(0xccc9, EMPTY), 64, 64)).toThrow( "知らないブロック", ); diff --git a/web/rdp/progressive.ts b/web/rdp/progressive.ts index 3dd4326..ef47bbf 100644 --- a/web/rdp/progressive.ts +++ b/web/rdp/progressive.ts @@ -1,12 +1,22 @@ // RemoteFX Progressive([MS-RDPEGFX] 2.2.4.2)。GFX の WireToSurface2 で CAPROGRESSIVE として来る。 // 64x64 のタイルを、まず TILE_FIRST / TILE_SIMPLE で粗く送り、あとから TILE_UPGRADE で細かくしていく。 -// タイルごとに、逆量子化した係数(current)、符号(sign)、どのビットまで来たか(bitPos)を面ごとに覚えておく。 +// 面ごと、タイルごとに覚えておくものは 3 つ。逆量子化した係数 current、符号 sign、どのビットまで来たか bitPos。 // upgrade では、足りない下のビットを SRL と RAW の 2 つのビット列から係数に足して、復号し直す。 // RLGR と色変換は RemoteFX(rfx.ts)と同じ。逆 DWT には、帯を 33 / 31 などに分ける「外挿」の形(RFX_DWT_REDUCE_EXTRAPOLATE)がある。 // C の整数の割り算や INT16 のあふれといった細かいところは、FreeRDP の progressive.c に合わせる。 import { Reader } from "./bytes"; -import { ENTROPY_RLGR1, inverseDwt, rlgrDecode, TILE, yccToRgba } from "./rfx"; +import { + BANDS, + cutTile, + ENTROPY_RLGR1, + inverseDwt, + type Rect, + rlgrDecode, + TILE, + type TilePart, + yccToRgba, +} from "./rfx"; const WBT_SYNC = 0xccc0; const WBT_FRAME_BEGIN = 0xccc1; @@ -30,25 +40,7 @@ const MAX_TILES = 1 << 12; type Quant = number[]; type Yuv = [T, T, T]; -export type ProgressiveRect = { x: number; y: number; w: number; h: number }; - -/** 面に描く部分。タイルを Region の矩形で切ったもの。位置は面の座標 */ -export type ProgressivePart = ProgressiveRect & { rgba: Uint8ClampedArray }; - -/** 帯の [位置, 大きさ]。通常の形は RemoteFX と同じ */ -const NORMAL: [number, number][] = [ - [0, 1024], - [1024, 1024], - [2048, 1024], - [3072, 256], - [3328, 256], - [3584, 256], - [3840, 64], - [3904, 64], - [3968, 64], - [4032, 64], -]; -/** 外挿の形。1 段目は低域 33・高域 31、2 段目は 17・16、3 段目は 9・8 */ +/** 外挿の形の帯の [位置, 大きさ]。通常の形は rfx.ts の BANDS を使う。1 段目は低域 33・高域 31、2 段目は 17・16、3 段目は 9・8 */ const EXTRAPOLATED: [number, number][] = [ [0, 1023], // HL1 31x33 [1023, 1023], // LH1 33x31 @@ -94,7 +86,7 @@ function newTile(): Tile { } type Region = { - rects: ProgressiveRect[]; + rects: Rect[]; quants: Quant[]; /** quality の番号ごとの、Y・Cb・Cr の progressive の量子化 */ progQuants: Yuv[]; @@ -130,7 +122,7 @@ export type ProgressiveTile = { /** 読み取った 1 つの WireToSurface2。復号するタイルと、描く矩形を持つ。矩形は Region のもので、タイルの無い矩形もある */ export type ProgressiveMessage = { tiles: ProgressiveTile[]; - rects: ProgressiveRect[]; + rects: Rect[]; }; /** タイルの状態を持って復号する。Worker で復号するときは、タイルの位置ごとに受け持つ Worker を決めて、同じタイルは同じ Worker で復号する */ @@ -232,7 +224,7 @@ export class ProgressiveFrame { rgba: Uint8ClampedArray[], width: number, height: number, - ): ProgressivePart[] { + ): TilePart[] { let tiles = this.surfaces.get(surface); if (!tiles) { tiles = new Map(); @@ -244,32 +236,9 @@ export class ProgressiveFrame { y: t.y, rgba: rgba[i] as Uint8ClampedArray, }); - const out: ProgressivePart[] = []; + const out: TilePart[] = []; for (const t of tiles.values()) - clip(t.rgba, t.x, t.y, msg.rects, width, height, out); - return out; - } -} - -/** Progressive の復号器。Worker を使わないときと試験で、その場で復号する。タイルの状態は面ごとに持つので、面を消したら deleteSurface を呼ぶ */ -export class ProgressiveDecoder { - private tiles = new ProgressiveTiles(); - - deleteSurface(id: number): void { - this.tiles.deleteSurface(id); - } - - /** 1 つの WireToSurface2 の bitmapData を復号し、面に描く部分を返す。width / height は面の大きさ */ - decode( - surfaceId: number, - data: Uint8Array, - width: number, - height: number, - ): ProgressivePart[] { - const msg = parseProgressive(surfaceId, data, width, height); - const out: ProgressivePart[] = []; - for (const t of msg.tiles) - clip(this.tiles.decode(t.job), t.x, t.y, msg.rects, width, height, out); + out.push(...cutTile(t.rgba, t.x, t.y, msg.rects, width, height)); return out; } } @@ -378,33 +347,6 @@ function progQuant(reg: Region, quality: number): Yuv { return q; } -/** タイルを Region の矩形と面で切る */ -function clip( - rgba: Uint8ClampedArray, - tx: number, - ty: number, - rects: ProgressiveRect[], - width: number, - height: number, - out: ProgressivePart[], -): void { - for (const rc of rects) { - const x0 = Math.max(tx, rc.x); - const y0 = Math.max(ty, rc.y); - const x1 = Math.min(tx + TILE, rc.x + rc.w, width); - const y1 = Math.min(ty + TILE, rc.y + rc.h, height); - if (x1 <= x0 || y1 <= y0) continue; - const w = x1 - x0; - const h = y1 - y0; - const part = new Uint8ClampedArray(w * h * 4); - for (let y = 0; y < h; y++) { - const s = ((y0 - ty + y) * TILE + (x0 - tx)) * 4; - part.set(rgba.subarray(s, s + w * 4), y * w * 4); - } - out.push({ x: x0, y: y0, w, h, rgba: part }); - } -} - // ---- タイルの復号 ---- const COEF = new Int32Array(4096); @@ -430,7 +372,7 @@ function first( diff: boolean, extrapolate: boolean, ): Uint8ClampedArray { - const bands = extrapolate ? EXTRAPOLATED : NORMAL; + const bands = extrapolate ? EXTRAPOLATED : BANDS; for (let c = 0; c < 3; c++) { const bitPos = add(quant[c] as Quant, prog[c] as Quant); tile.bitPos[c] = bitPos; @@ -466,7 +408,7 @@ function upgrade( data: Uint8Array[], extrapolate: boolean, ): Uint8ClampedArray { - const bands = extrapolate ? EXTRAPOLATED : NORMAL; + const bands = extrapolate ? EXTRAPOLATED : BANDS; for (let c = 0; c < 3; c++) { const bitPos = add(quant[c] as Quant, prog[c] as Quant); const old = tile.bitPos[c] as Quant; diff --git a/web/rdp/rfx.ts b/web/rdp/rfx.ts index ccd3190..04705bd 100644 --- a/web/rdp/rfx.ts +++ b/web/rdp/rfx.ts @@ -222,7 +222,7 @@ export function rlgrDecode( // ---- 逆量子化・逆 DWT・色変換 ---- /** 係数の並び。1 成分は 4096 個で、HL1 LH1 HH1 | HL2 LH2 HH2 | HL3 LH3 HH3 | LL3 の順 */ -const BANDS: [offset: number, size: number][] = [ +export const BANDS: [offset: number, size: number][] = [ [0, 1024], // HL1 [1024, 1024], // LH1 [2048, 1024], // HH1 @@ -348,7 +348,8 @@ function horiz( dst[d + 2 * n - 1] = (hPrev << 1) + ePrev; } -const LITTLE_ENDIAN = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1; +export const LITTLE_ENDIAN = + new Uint8Array(new Uint32Array([1]).buffer)[0] === 1; /** Y・Cb・Cr を RGBA にする。入力は 5 ビットの小数つきで、Y は -128 のオフセット。16.16 の固定小数と 5 ビットを合わせて 21 ビット右へずらす */ export function yccToRgba( @@ -402,6 +403,46 @@ export function decodeTile( return yccToRgba(PLANES[0], PLANES[1], PLANES[2]); } +/** 面に描く部分。タイルを矩形で切ったもの */ +export type TilePart = Rect & { rgba: Uint8ClampedArray }; + +/** + * 64x64 のタイル(左上が tx, ty)を、rects のそれぞれと 0..width x 0..height の重なりで切り出す。 + * rects が空なら何も返さない。全体を描くときは、呼ぶ側が全体の矩形を渡す + */ +export function cutTile( + rgba: Uint8ClampedArray, + tx: number, + ty: number, + rects: Rect[], + width: number, + height: number, +): TilePart[] { + const out: TilePart[] = []; + for (const rc of rects) { + const x0 = Math.max(tx, rc.x); + const y0 = Math.max(ty, rc.y); + const x1 = Math.min(tx + TILE, rc.x + rc.w, width); + const y1 = Math.min(ty + TILE, rc.y + rc.h, height); + if (x1 <= x0 || y1 <= y0) continue; + const w = x1 - x0; + const h = y1 - y0; + let part: Uint8ClampedArray; + if (w === TILE) { + // 行をまるごと使うので、写さずに切り出す + part = rgba.subarray((y0 - ty) * TILE * 4, (y1 - ty) * TILE * 4); + } else { + part = new Uint8ClampedArray(w * h * 4); + for (let y = 0; y < h; y++) { + const s = ((y0 - ty + y) * TILE + (x0 - tx)) * 4; + part.set(rgba.subarray(s, s + w * 4), y * w * 4); + } + } + out.push({ x: x0, y: y0, w, h, rgba: part }); + } + return out; +} + // ---- ストリーム ---- /** RemoteFX のデータ(1 つの Surface コマンドの bitmapData)から、描くタイルを取り出して復号する */ diff --git a/web/rdp/surface.ts b/web/rdp/surface.ts index eab2f65..abafdd2 100644 --- a/web/rdp/surface.ts +++ b/web/rdp/surface.ts @@ -5,9 +5,16 @@ // 同じ左上にある古いもの(RemoteFX のタイルや旧ビットマップ)は、新しいものが描く範囲にすっぽり入るときに限って復号しない。 // 旧ビットマップも同じ列に載せて、受けた順に描く。Windows は RemoteFX を使わないとき planar などで送ってくる。 -import type { BitmapRect } from "./bitmap"; +import { type BitmapRect, bgraToRgba } from "./bitmap"; import { Reader } from "./bytes"; -import { decodeTile, parseTiles, type RawTile, type Rect, TILE } from "./rfx"; +import { + cutTile, + decodeTile, + parseTiles, + type RawTile, + type Rect, + TILE, +} from "./rfx"; import { type DecodeJob, decodeJob, SyncPool, type TilePool } from "./rfxpool"; const CMD_SET_SURFACE_BITS = 0x0001; @@ -39,14 +46,8 @@ type SurfaceEvents = { draw: SurfaceDraw; /** Frame Marker。0 が始まりで 1 が終わり */ frame?(action: number, frameId: number): void; - /** これを渡すと、RemoteFX は復号せずにこれへ渡す。bits は Surface Bits の本体で、復号は呼び出し側が後でする */ - rfx?( - bits: Uint8Array, - left: number, - top: number, - right: number, - bottom: number, - ): void; + /** これを渡すと、RemoteFX は復号せずにこれへ渡す。bits は Surface Bits の本体で、復号は呼び出し側が後でする。右下では切らないので渡さない */ + rfx?(bits: Uint8Array, left: number, top: number): void; }; /** @@ -67,8 +68,7 @@ export function handleSurfaceCommands( case CMD_STREAM_SURFACE_BITS: { const left = r.u16(); const top = r.u16(); - const right = r.u16(); - const bottom = r.u16(); + r.skip(4); // destRight, destBottom。commandArea を参照 const bpp = r.u8(); const flags = r.u8(); r.u8(); // reserved @@ -82,9 +82,9 @@ export function handleSurfaceCommands( if (width > MAX_SIDE || height > MAX_SIDE) throw new Error("Surface Bits が大きすぎる"); if (codecId === rfxCodecId && codecId !== CODEC_NONE) { - if (ev.rfx) ev.rfx(bits, left, top, right, bottom); + if (ev.rfx) ev.rfx(bits, left, top); else { - const area = commandArea(left, top, right, bottom, screen); + const area = commandArea(left, top, screen); for (const t of parseTiles(bits)) drawTile( decodeTile(t.mode, t.data, t.quant), @@ -155,22 +155,15 @@ function crop( function commandArea( left: number, top: number, - _right: number, - _bottom: number, screen: ScreenSize, ): Rect | null { return intersect( - { - x: left, - y: top, - w: Math.max(0, screen.width - left), - h: Math.max(0, screen.height - top), - }, + { x: left, y: top, w: screen.width - left, h: screen.height - top }, { x: 0, y: 0, w: screen.width, h: screen.height }, ); } -/** 復号したタイルを、Region の矩形と画面で切って描く */ +/** 復号したタイルを、Region の矩形と描いてよい範囲(画面の座標)で切って描く。タイルと矩形の位置はコマンドの左上 (left, top) からの相対 */ function drawTile( rgba: Uint8ClampedArray, t: { x: number; y: number; rects: Rect[] }, @@ -179,29 +172,15 @@ function drawTile( area: Rect | null, draw: SurfaceDraw, ): void { - const tile: Rect = { x: t.x, y: t.y, w: TILE, h: TILE }; - const clips = t.rects.length ? t.rects : [tile]; - for (const clip of clips) { - const part = intersect(tile, clip); - if (!part) continue; - const abs = intersect( - { x: part.x + left, y: part.y + top, w: part.w, h: part.h }, - area ?? { x: 0, y: 0, w: 0, h: 0 }, - ); - if (!abs) continue; - draw( - abs.x, - abs.y, - abs.w, - abs.h, - crop(rgba, TILE, { - x: abs.x - left - t.x, - y: abs.y - top - t.y, - w: abs.w, - h: abs.h, - }), - ); - } + if (!area) return; + const rects = t.rects.length + ? t.rects + : [{ x: t.x, y: t.y, w: TILE, h: TILE }]; + // area の左上は (left, top) なので、相対の座標では右下だけで切ればよい + const width = area.x + area.w - left; + const height = area.y + area.h - top; + for (const p of cutTile(rgba, t.x, t.y, rects, width, height)) + draw(p.x + left, p.y + top, p.w, p.h, p.rgba); } /** コーデックなし。32 ビットの BGRA が上の行から並ぶ。大きさを超える分は切る */ @@ -216,13 +195,7 @@ function drawRaw( ): void { if (bits.length < width * height * 4) throw new Error("Surface Bits(生)が短い"); - const rgba = new Uint8ClampedArray(width * height * 4); - for (let i = 0; i < width * height; i++) { - rgba[i * 4] = bits[i * 4 + 2] as number; - rgba[i * 4 + 1] = bits[i * 4 + 1] as number; - rgba[i * 4 + 2] = bits[i * 4] as number; - rgba[i * 4 + 3] = 255; - } + const rgba = bgraToRgba(bits, width * height); const abs = intersect( { x: left, y: top, w: width, h: height }, { x: 0, y: 0, w: screen.width, h: screen.height }, @@ -351,8 +324,8 @@ export class SurfaceRenderer { this.request(); } }, - rfx: (bits, left, top, right, bottom) => { - const area = commandArea(left, top, right, bottom, screen); + rfx: (bits, left, top) => { + const area = commandArea(left, top, screen); for (const tile of parseTiles(bits)) this.add(tile, left, top, area); this.request(); }, diff --git a/web/rdp/yuv444.test.ts b/web/rdp/yuv444.test.ts index 1ea324b..940fd2f 100644 --- a/web/rdp/yuv444.test.ts +++ b/web/rdp/yuv444.test.ts @@ -2,9 +2,8 @@ // 1 画素ずつ書いた素直な復元とで、Yuv444(v1 / v2)を確かめる。 import { describe, expect, test } from "bun:test"; -import type { Rect } from "./gfx"; +import { clampRects, type Rect } from "./gfx"; import { - clampRects, videoFrameYuv, type Yuv420, Yuv444, diff --git a/web/rdp/yuv444.ts b/web/rdp/yuv444.ts index f074701..33e873a 100644 --- a/web/rdp/yuv444.ts +++ b/web/rdp/yuv444.ts @@ -1,10 +1,10 @@ // AVC444 / AVC444v2([MS-RDPEGFX] 2.2.4.5・2.2.4.6・3.3.8.3)の YUV444 の組み立て。 -// 主の絵(YUV420 そのまま)と補助の絵(4:4:4 で足りない色差を YUV420 の形に詰めたもの)を、面ごとの YUV444 の面に重ねる。 +// 主の絵と補助の絵を、面ごとの YUV444 に重ねる。主の絵は YUV420 そのもの。補助の絵は、4:4:4 にするのに足りない色差を YUV420 の形に詰めたもの。 // 並べ方と色差の戻し方は FreeRDP の prim_YUV.c(general_LumaToYUV444・ChromaV1ToYUV444・ChromaV2ToYUV444・ChromaFilter)と同じ。 import type { Rect } from "./gfx"; -/** 復号した YUV420 の 1 枚(I420 の 3 面)。width / height は面の大きさ(切り取り前の符号化の大きさのことがある) */ +/** 復号した YUV420 の 1 枚。I420 の 3 面。width / height は面の大きさで、切り取る前の符号化の大きさのこともある */ export type Yuv420 = { width: number; height: number; @@ -19,8 +19,8 @@ export type Yuv420 = { uvStride?: number; }; -/** RGBA を面に置く(CPU で組み立てたとき) */ -export type PutRgba = ( +/** RGBA を面に置く。CPU で組み立てたときに使う */ +type PutRgba = ( x: number, y: number, w: number, @@ -39,18 +39,16 @@ export interface Yuv444Target { close(): void; } -/** 色差を戻したとき、元(主の絵)とこれより近ければ元のまま(圧縮の雑音で色がにじまないように。FreeRDP の CONDITIONAL_CLIP) */ +/** 色差を戻したとき、元の主の絵との差がこれより小さければ元のままにする。圧縮の雑音で色がにじまないように。FreeRDP の CONDITIONAL_CLIP */ const FILTER_THRESHOLD = 30; const clip = (v: number) => (v < 0 ? 0 : v > 255 ? 255 : v); -/** 面 1 枚ぶんの YUV444。主の絵と補助の絵が別々に(または片方だけ)来るので、前のものを持っておく */ -export class Yuv444 implements Yuv444Target { +/** CPU で持つ YUV444 の 3 面。Y=0、U=V=128 から始める。Yuv444 と Yuv444GlRef が使う */ +abstract class CpuYuv444 implements Yuv444Target { readonly y: Uint8Array; readonly u: Uint8Array; readonly v: Uint8Array; - /** 最後の主の絵(偶数の行・列の色差を戻すのに使う) */ - private main?: Yuv420; constructor( readonly width: number, @@ -61,13 +59,36 @@ export class Yuv444 implements Yuv444Target { this.v = new Uint8Array(width * height).fill(128); } - /** 主の絵: Y はそのまま、色差は 2x2 に広げる(補助の絵が来るまでは 4:2:0 の色) */ + abstract luma(p: Yuv420, rects: Rect[]): void; + abstract chroma(p: Yuv420, v2: boolean, rects: Rect[]): boolean; + /** 矩形を RGBA にする */ + abstract rgba(r: Rect): Uint8ClampedArray; + + draw(rects: Rect[], x: number, y: number, put: PutRgba): boolean { + for (const r of rects) + put( + x + r.left, + y + r.top, + r.right - r.left, + r.bottom - r.top, + this.rgba(r), + ); + return true; + } + + close(): void {} +} + +/** 面 1 枚ぶんの YUV444。主の絵と補助の絵は別々に来る。片方だけのこともあるので、前のものを持っておく */ +export class Yuv444 extends CpuYuv444 { + /** 最後の主の絵。偶数の行・列の色差を戻すのに使う */ + private main?: Yuv420; + + /** 主の絵。Y はそのまま、色差は 2x2 に広げる。補助の絵が来るまでは 4:2:0 の色になる */ luma(p: Yuv420, rects: Rect[]): void { this.main = p; const W = this.width; - for (const r0 of rects) { - const r = evenRect(r0, W, this.height, p); - if (!r) continue; + for (const r of evenRects(rects, W, this.height, p)) { for (let y = r.top; y < r.bottom; y++) { const s = y * p.yStride; this.y.set(p.y.subarray(s + r.left, s + r.right), y * W + r.left); @@ -96,7 +117,7 @@ export class Yuv444 implements Yuv444Target { } /** - * 補助の絵: 足りない色差(偶数の行・列以外)を埋め、偶数の行・列は主の絵の平均から戻す。 + * 補助の絵。偶数の行・列のところ以外は、足りない色差をここから埋める。偶数の行・列は主の絵の平均から戻す。 * v1: 補助の Y は 16 行ごとに前の 8 行が U、後の 8 行が V の奇数行。補助の U / V は U / V の偶数行・奇数列。 * v2: 補助の Y は左半分が U、右半分が V の奇数列。補助の U / V は左右の 4 分の 1 ずつが U / V の奇数行の 4 の倍数の列(U)と +2 の列(V) */ @@ -106,9 +127,7 @@ export class Yuv444 implements Yuv444Target { const W = this.width; const U = this.u; const V = this.v; - for (const r0 of rects) { - const r = evenRect(r0, W, this.height); - if (!r) continue; + for (const r of evenRects(rects, W, this.height)) { if (!v2) { // B4 / B5: 奇数行の全部の列 for (let y = r.top + 1; y < r.bottom; y += 2) { @@ -174,7 +193,7 @@ export class Yuv444 implements Yuv444Target { return true; } - /** 偶数の行・列: 主の絵の色差は 2x2 の平均なので、4 倍から残りの 3 つを引いて戻す */ + /** 偶数の行・列。主の絵の色差は 2x2 の平均なので、4 倍から残りの 3 つを引いて戻す */ private filter(main: Yuv420, r: Rect): void { const W = this.width; const U = this.u; @@ -205,7 +224,7 @@ export class Yuv444 implements Yuv444Target { } } - /** 矩形を RGBA に(BT.709・フル レンジ。FreeRDP の YUV2R / YUV2G / YUV2B と同じ係数) */ + /** BT.709 のフル レンジ。係数は FreeRDP の YUV2R / YUV2G / YUV2B と同じ */ rgba(r: Rect): Uint8ClampedArray { const w = r.right - r.left; const h = r.bottom - r.top; @@ -227,20 +246,6 @@ export class Yuv444 implements Yuv444Target { } return out; } - - draw(rects: Rect[], x: number, y: number, put: PutRgba): boolean { - for (const r of rects) - put( - x + r.left, - y + r.top, - r.right - r.left, - r.bottom - r.top, - this.rgba(r), - ); - return true; - } - - close(): void {} } /** 矩形を面(W x H)に収め、色差の 2x2 に合わせて偶数に広げる。p を渡すと、その絵にある範囲にも収める */ @@ -254,16 +259,12 @@ function evenRect(r: Rect, W: number, H: number, p?: Yuv420): Rect | undefined { : undefined; } -/** 描く範囲: 面に収めた矩形(regions が空なら全体) */ -export function clampRects(regions: Rect[], w: number, h: number): Rect[] { - if (!regions.length) return [{ left: 0, top: 0, right: w, bottom: h }]; +/** evenRect をそれぞれにかけ、空になったものを除く */ +function evenRects(rects: Rect[], W: number, H: number, p?: Yuv420): Rect[] { const out: Rect[] = []; - for (const r of regions) { - const left = Math.max(0, r.left); - const top = Math.max(0, r.top); - const right = Math.min(w, r.right); - const bottom = Math.min(h, r.bottom); - if (right > left && bottom > top) out.push({ left, top, right, bottom }); + for (const r of rects) { + const e = evenRect(r, W, H, p); + if (e) out.push(e); } return out; } @@ -453,28 +454,14 @@ function rgbAt(Y: number, U: number, V: number): [number, number, number] { } /** シェーダーの計算を TS で書いたもの(試験用)。GPU と同じく、状態から読んで別の面に描き、矩形を写し戻す */ -export class Yuv444GlRef implements Yuv444Target { - readonly y: Uint8Array; - readonly u: Uint8Array; - readonly v: Uint8Array; +export class Yuv444GlRef extends CpuYuv444 { private main?: GlPlanes; - constructor( - readonly width: number, - readonly height: number, - ) { - this.y = new Uint8Array(width * height); - this.u = new Uint8Array(width * height).fill(128); - this.v = new Uint8Array(width * height).fill(128); - } - luma(p: Yuv420, rects: Rect[]): void { const m = glPlanes(p); this.main = m; const W = this.width; - for (const r0 of rects) { - const r = evenRect(r0, W, this.height, p); - if (!r) continue; + for (const r of evenRects(rects, W, this.height, p)) { for (let y = r.top; y < r.bottom; y++) for (let x = r.left; x < r.right; x++) { const i = y * W + x; @@ -497,11 +484,8 @@ export class Yuv444GlRef implements Yuv444Target { const aux = (x: number, y: number) => auxAt(a, v2, W, x, y, prev(x, y)); const outU = this.u.slice(); const outV = this.v.slice(); - const list: Rect[] = []; - for (const r0 of rects) { - const r = evenRect(r0, W, this.height); - if (!r) continue; - list.push(r); + const list = evenRects(rects, W, this.height); + for (const r of list) { for (let y = r.top; y < r.bottom; y++) for (let x = r.left; x < r.right; x++) { let c: [number, number]; @@ -542,20 +526,6 @@ export class Yuv444GlRef implements Yuv444Target { } return out; } - - draw(rects: Rect[], x: number, y: number, put: PutRgba): boolean { - for (const r of rects) - put( - x + r.left, - y + r.top, - r.right - r.left, - r.bottom - r.top, - this.rgba(r), - ); - return true; - } - - close(): void {} } const GLSL_HEAD = `#version 300 es @@ -832,7 +802,7 @@ class TexPlanes { } } -/** GPU で組み立てた矩形を面へ写す(src の (sx, sy) から w x h を、面の (dx, dy) へ) */ +/** GPU で組み立てた矩形を面へ写す。src の (sx, sy) から w x h を、面の (dx, dy) へ */ export type GlBlit = ( src: CanvasImageSource, sx: number, @@ -1019,11 +989,7 @@ export class Yuv444GlTarget implements Yuv444Target { const prog = this.owner.luma; this.main.upload(p); this.hasMain = true; - const list: Rect[] = []; - for (const r of rects) { - const e = evenRect(r, this.width, this.height, p); - if (e) list.push(e); - } + const list = evenRects(rects, this.width, this.height, p); if (!list.length) return; // 状態を読まずに全部の値を書くので、a に直に描く。描く先を読むことにならないよう、a は外しておく this.bindState(null); @@ -1044,11 +1010,7 @@ export class Yuv444GlTarget implements Yuv444Target { const prog = this.owner.chroma; const aux = this.owner.aux; aux.upload(p); - const list: Rect[] = []; - for (const r of rects) { - const e = evenRect(r, this.width, this.height); - if (e) list.push(e); - } + const list = evenRects(rects, this.width, this.height); if (!list.length) return; gl.bindFramebuffer(gl.FRAMEBUFFER, this.fb); gl.viewport(0, 0, this.width, this.height); diff --git a/web/rdp/zgfx.ts b/web/rdp/zgfx.ts index b6fe3c0..2f4f514 100644 --- a/web/rdp/zgfx.ts +++ b/web/rdp/zgfx.ts @@ -216,8 +216,7 @@ export class Zgfx { out.reserve(1); const v = token.valueBase + r.bits(token.valueBits); out.buf[out.len++] = v; - hist[this.index] = v; - this.index = this.index + 1 === HISTORY_SIZE ? 0 : this.index + 1; + this.push(v); continue; } const distance = token.valueBase + r.bits(token.valueBits); @@ -260,8 +259,7 @@ export class Zgfx { for (let i = 0; i < count; i++) { const v = bytes[at + i] as number; out.buf[out.len++] = v; - hist[this.index] = v; - this.index = this.index + 1 === HISTORY_SIZE ? 0 : this.index + 1; + this.push(v); } r.skipBytes(count); } @@ -269,9 +267,10 @@ export class Zgfx { return out.buf.slice(0, out.len); } + /** 履歴の輪に 1 バイト足す */ private push(b: number): void { this.history[this.index] = b; - this.index = (this.index + 1) % HISTORY_SIZE; + this.index = this.index + 1 === HISTORY_SIZE ? 0 : this.index + 1; } } From c62313414529cb432c592f718d843aeb3f4c9e30 Mon Sep 17 00:00:00 2001 From: Ruk Doe Date: Mon, 12 Oct 2026 20:07:34 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor(server):=20=E3=82=B5=E3=83=BC?= =?UTF-8?q?=E3=83=90=E3=83=BC=E5=81=B4=E3=81=AE=E9=A3=9F=E3=81=84=E9=81=95?= =?UTF-8?q?=E3=81=84=E3=81=A8=E4=BD=BF=E3=81=A3=E3=81=A6=E3=81=84=E3=81=AA?= =?UTF-8?q?=E3=81=84=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92=E7=89=87=E4=BB=98?= =?UTF-8?q?=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /ws と /wt の中継の後始末を Relay.Serve にまとめる。接続の数、開け閉めのログ、思わぬ誤りのログを同じにする - QUIC の接続のコールバックで出た例外を捨てていたのを、ログに残す(OnShutdown を誰も付けていなかった) - 自動更新の MSI のログを mado.log と同じ logs に置く。data_dir を変えたときに場所がずれていた - 使っていないものを外す: datagram の送信、ReadSettings、PeerSettings、セッションの Authority / Gate / IsClosed、 RelayTokens.TtlSeconds、H3 の使っていない定数、ApiJson の使っていない型 - 中間証明書の取り出しを Acme.Intermediates にまとめる。DaysLeft で証明書を Dispose する - 待ち受けのアドレスの解釈、401 の返事、WebAssets の有無の確かめと返すのを 1 か所にする - コメントを直す: 宙に浮いていた QUIC_ADDR の説明、ApiJson の説明(ACME は使わない)、Qpack の文字列、 Program.cs の道の一覧(/logout と /update)、csproj の libmsquic、wxs のビルド例の版 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2BadDnB3uH3FDGjaPMP4J --- installer/mado.wxs | 2 +- server/Acme.cs | 25 +++++---- server/Mado.csproj | 2 +- server/MadoJson.cs | 8 +-- server/Program.cs | 42 +++++++-------- server/Quic/Http3.cs | 29 ++++------ server/Quic/MsQuic.cs | 89 ++++++++----------------------- server/Quic/Qpack.cs | 8 +-- server/Quic/WebTransport.cs | 24 ++------- server/Quic/WebTransportServer.cs | 14 ++--- server/Relay.cs | 67 +++++++++++++++-------- server/RelayTokens.cs | 2 - server/Updater.cs | 6 +-- server/WebAssets.cs | 14 ++--- server/WebTransportRelay.cs | 13 +---- tests/RelayTests.cs | 15 ++++++ tests/UpdaterTests.cs | 5 +- tray/Program.cs | 13 ++--- 18 files changed, 163 insertions(+), 215 deletions(-) diff --git a/installer/mado.wxs b/installer/mado.wxs index 2f2448f..68f4699 100644 --- a/installer/mado.wxs +++ b/installer/mado.wxs @@ -12,7 +12,7 @@ 設定が無いうちはサービスの起動が失敗するが、起動は待たないので、インストールは止まらない。 ビルド: wix build installer/mado.wxs -arch x64 -ext WixToolset.Firewall.wixext -ext WixToolset.Util.wixext - -d Version=0.4.0 -d PublishDir=publish -o dist/mado.msi + -d Version=<版> -d PublishDir=publish -o dist/mado.msi(CI の release.yml と同じ) --> NewNonce() static string B64(string s) => Base64Url.EncodeToString(Encoding.UTF8.GetBytes(s)); /// PEM の最初の証明書の残り日数 - public static double DaysLeft(string pem) => - (X509Certificate2.CreateFromPem(pem).NotAfter.ToUniversalTime() - DateTime.UtcNow).TotalDays; + public static double DaysLeft(string pem) + { + using var cert = X509Certificate2.CreateFromPem(pem); + return (cert.NotAfter.ToUniversalTime() - DateTime.UtcNow).TotalDays; + } + + /// PEM の 2 つ目からの証明書。Let's Encrypt の中間証明書 + public static X509Certificate2Collection Intermediates(string certPem) + { + var all = new X509Certificate2Collection(); + all.ImportFromPem(certPem); + if (all.Count > 0) all.RemoveAt(0); + return all; + } /// /// Kestrel に渡す形にする。Windows の SChannel は PEM から作った一時的な鍵を受けないので、一度 PKCS#12 にして読み直す。 - /// Let's Encrypt の中間証明書も一緒に送る + /// 中間証明書も一緒に送る /// public static SslStreamCertificateContext Context(string keyPem, string certPem) { using var leaf = X509Certificate2.CreateFromPem(certPem, keyPem); var cert = X509CertificateLoader.LoadPkcs12(leaf.Export(X509ContentType.Pkcs12), null); - var all = new X509Certificate2Collection(); - all.ImportFromPem(certPem); - var intermediates = new X509Certificate2Collection(); - foreach (var c in all) - if (c.Thumbprint != cert.Thumbprint) intermediates.Add(c); - return SslStreamCertificateContext.Create(cert, intermediates, offline: true); + return SslStreamCertificateContext.Create(cert, Intermediates(certPem), offline: true); } } diff --git a/server/Mado.csproj b/server/Mado.csproj index 9b4b0a3..c198925 100644 --- a/server/Mado.csproj +++ b/server/Mado.csproj @@ -32,7 +32,7 @@ $(PkgMicrosoft_Native_Quic_MsQuic_Schannel)/build/native/bin/x64/msquic.dll $(PkgMicrosoft_Native_Quic_MsQuic_Schannel)/build/native/bin/arm64/msquic.dll - + true diff --git a/server/MadoJson.cs b/server/MadoJson.cs index e6fcf50..bee6725 100644 --- a/server/MadoJson.cs +++ b/server/MadoJson.cs @@ -13,7 +13,7 @@ namespace Mado; [JsonSerializable(typeof(Config.Raw))] internal sealed partial class MadoJson : JsonSerializerContext; -/// API の返事、署名付きの値、OIDC・ACME・GitHub の API。名前は変換しない。camelCase か snake_case かは型の側で書く +/// API の返事、署名付きの値、OIDC と GitHub の API。名前は変換しない。camelCase か snake_case かは型の側で書く。ACME は JsonNode で読み書きする [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(SessionInfo))] [JsonSerializable(typeof(TokenInfo))] @@ -29,12 +29,6 @@ internal sealed partial class MadoJson : JsonSerializerContext; [JsonSerializable(typeof(Jwks))] [JsonSerializable(typeof(JwtHeader))] [JsonSerializable(typeof(JwtClaims))] -[JsonSerializable(typeof(JsonElement))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(object[]))] -[JsonSerializable(typeof(string[]))] internal sealed partial class ApiJson : JsonSerializerContext; internal sealed record SessionInfo( diff --git a/server/Program.cs b/server/Program.cs index 76d40d8..2418301 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -3,12 +3,13 @@ // https:/// 画面。web/ の RDP クライアントを bun でビルドして exe に埋め込んである // https:///login OIDC のログインへ // https:///callback OIDC から戻ってくる口 +// https:///logout ログアウト // https:///api/session ログイン中の名前・版・つなぐ先 // https:///api/token ?target=。つなぐ直前に取る使い捨ての中継のトークン // wss:///ws RDCleanPath の中継。Relay.cs // https:///wt (UDP) 同じ中継を WebTransport(HTTP/3)で。WebTransportRelay.cs と Quic/ // http:///.well-known/acme-challenge/* Let's Encrypt の確認。80 番のほかの道は https へ回す -// http://127.0.0.1:8788/status この PC の中からだけ見られる状態 +// http://127.0.0.1:8788/status この PC の中からだけ見られる状態。POST /update で更新をすぐ確かめる using System.Collections.Concurrent; using System.Net; @@ -29,7 +30,8 @@ return 1; } -var log = new FileLog(Path.Combine(Path.GetDirectoryName(config.File)!, "logs", "mado.log")); +var logDir = Path.Combine(Path.GetDirectoryName(config.File)!, "logs"); +var log = new FileLog(Path.Combine(logDir, "mado.log")); var version = "v" + (typeof(Config).Assembly.GetCustomAttribute()?.InformationalVersion .Split('+')[0] ?? "0.0.0"); log.Write($"mado {version}: {config.File} を読んだ({config.Host}、つなぐ先 {string.Join(' ', config.Targets.Select(t => $"{t.Name}={t.Host}:{t.Port}"))})"); @@ -53,8 +55,9 @@ SslStreamCertificateContext? current = null; int? certDaysLeft = null; var sessions = 0; +void CountSession(int d) => Interlocked.Add(ref sessions, d); var startedAt = DateTimeOffset.UtcNow.ToString("o"); -var updater = new Updater(config.Update.Repo, version, config.DataDir, http, log.Write); +var updater = new Updater(config.Update.Repo, version, config.DataDir, logDir, http, log.Write); // 自分で版を上げるのはサービスとして動いているときだけ。手元の dotnet run には入れる MSI が無い var canUpdate = config.Update.Enabled && OperatingSystem.IsWindows() && Microsoft.Extensions.Hosting.WindowsServices.WindowsServiceHelpers.IsWindowsService(); @@ -66,14 +69,15 @@ }); builder.Host.UseWindowsService(o => o.ServiceName = "mado"); builder.Logging.ClearProviders(); +// 待ち受けるアドレス。null は全部。WebTransport も同じ +var listenIp = config.Listen.Hostname is "::" or "0.0.0.0" or "" ? null : IPAddress.Parse(config.Listen.Hostname); builder.WebHost.ConfigureKestrel(k => { - var any = config.Listen.Hostname is "::" or "0.0.0.0" or "" ? null : IPAddress.Parse(config.Listen.Hostname); void Listen(int port, Action? configure = null) { // IPv6Any はデュアルスタックなので IPv4 も受ける - if (any is null) k.ListenAnyIP(port, configure ?? (_ => { })); - else k.Listen(any, port, configure ?? (_ => { })); + if (listenIp is null) k.ListenAnyIP(port, configure ?? (_ => { })); + else k.Listen(listenIp, port, configure ?? (_ => { })); } Listen(config.Listen.Http); if (secure) @@ -93,6 +97,7 @@ void Listen(int port, Action Results.Text(body, "text/plain; charset=utf-8", statusCode: status); User? SessionOf(HttpContext ctx) => signer.Verify(ctx.Request.Cookies[SessionCookie], ApiJson.Default.User); +IResult NeedLogin() => Results.Json(new LoginRequired("/login?to=/"), ApiJson.Default.LoginRequired, statusCode: 401); CookieOptions Cookie(int maxAge) => new() { HttpOnly = true, Secure = secure, SameSite = SameSiteMode.Lax, Path = "/", MaxAge = TimeSpan.FromSeconds(maxAge), @@ -106,12 +111,11 @@ void Listen(int port, Action tokens.Consume(token, null) is { } t && TargetOf(t.Target) is { } target ? (target, t.Name ?? t.Sub) : null, - log.Write, d => Interlocked.Add(ref sessions, d), app.Lifetime.ApplicationStopping); - wt = WebTransportServer.TryCreate(handler, ip, (ushort)config.WebTransportPort, log.Write); + log.Write, CountSession, app.Lifetime.ApplicationStopping); + wt = WebTransportServer.TryCreate(handler, listenIp, (ushort)config.WebTransportPort, log.Write); app.Lifetime.ApplicationStopping.Register(() => wt?.Dispose()); } var wtUrl = $"https://{config.Host}{(config.WebTransportPort == 443 ? "" : $":{config.WebTransportPort}")}{WebTransportRelay.Path}"; @@ -197,7 +201,7 @@ void Listen(int port, Action { ctx.Response.Headers.CacheControl = "no-store"; - if (SessionOf(ctx) is not { } s) return Results.Json(new LoginRequired("/login?to=/"), ApiJson.Default.LoginRequired, statusCode: 401); + if (SessionOf(ctx) is not { } s) return NeedLogin(); var ws = (origin.StartsWith("https") ? "wss" : "ws") + origin[(origin.IndexOf(':'))..] + "/ws"; return Results.Json(new SessionInfo(s.Name, version, [.. config.Targets.Select(t => t.Name)], ws, wt is { IsListening: true } ? wtUrl : null), ApiJson.Default.SessionInfo); }); @@ -205,7 +209,7 @@ void Listen(int port, Action { ctx.Response.Headers.CacheControl = "no-store"; - if (SessionOf(ctx) is not { } s) return Results.Json(new LoginRequired("/login?to=/"), ApiJson.Default.LoginRequired, statusCode: 401); + if (SessionOf(ctx) is not { } s) return NeedLogin(); if (TargetOf(ctx.Request.Query["target"]) is not { } target) return Text(404, "そのつなぐ先は無い"); var token = tokens.Issue(new RelayToken(s.Sub, target.Name, Oidc.RandomToken(16), s.Name)); return Results.Json(new TokenInfo(token), ApiJson.Default.TokenInfo); @@ -218,23 +222,13 @@ void Listen(int port, Action tokens.Consume(auth, user.Sub) is { } t ? TargetOf(t.Target) : null, m => log.Write($"{user.Name}: {m}"), ctx.RequestAborted); - } - finally - { - Interlocked.Decrement(ref sessions); - log.Write($"ws close: {user.Name}"); - } + await Relay.Serve("ws", user.Name, new WebSocketChannel(ws), auth => tokens.Consume(auth, user.Sub) is { } t ? TargetOf(t.Target) : null, + log.Write, CountSession, ctx.RequestAborted); }); app.MapGet("/{**path}", async (HttpContext ctx) => { - if (!WebAssets.Has(ctx.Request.Path.Value ?? "/")) { await Text(404, "not found").ExecuteAsync(ctx); return; } - await WebAssets.Serve(ctx); + if (!await WebAssets.TryServe(ctx)) await Text(404, "not found").ExecuteAsync(ctx); }); if (secure) diff --git a/server/Quic/Http3.cs b/server/Quic/Http3.cs index 2b0e19d..e15089a 100644 --- a/server/Quic/Http3.cs +++ b/server/Quic/Http3.cs @@ -2,12 +2,12 @@ // QUIC の上の HTTP/3 は stream ごとにフレームを並べる。フレームは「型、長さ、中身」で、型と長さは可変長整数。 namespace Mado.Quic; -/// QUIC の可変長整数(RFC 9000 16 節)。先頭 2 bit が長さ(1, 2, 4, 8 バイト)。 +/// QUIC の可変長整数(RFC 9000 16 節)。先頭 2 bit が長さ(1, 2, 4, 8 バイト) internal static class VarInt { public const ulong Max = (1UL << 62) - 1; - /// 読めたら値と使ったバイト数を返す。足りなければ false。 + /// 読めたら値と使ったバイト数を返す。足りなければ false public static bool TryRead(ReadOnlySpan s, out ulong value, out int length) { value = 0; @@ -31,7 +31,7 @@ public static int Write(Span d, ulong v) } } -/// 書き出し用の小さな器。 +/// 書き出し用の小さな器 internal sealed class ByteWriter { byte[] _buf = new byte[64]; @@ -77,7 +77,7 @@ public ByteWriter UInt32BE(uint v) public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray(); } -/// stream の受信を溜めて、頭から読んで捨てる。 +/// stream の受信を溜めて、頭から読んで捨てる internal sealed class RecvBuffer { byte[] _buf = new byte[256]; @@ -116,12 +116,11 @@ public void Consume(int n) } } -/// HTTP/3 の定数。WebTransport の値は Chromium(quiche)の http_constants.h に合わせる。 +/// HTTP/3 の定数。WebTransport の値は Chromium(quiche)の http_constants.h に合わせる internal static class H3 { // stream の型(単方向 stream の先頭の可変長整数) public const ulong StreamControl = 0x00; - public const ulong StreamPush = 0x01; public const ulong StreamQpackEncoder = 0x02; public const ulong StreamQpackDecoder = 0x03; public const ulong StreamWebTransportUni = 0x54; @@ -140,37 +139,31 @@ internal static class H3 // SETTINGS の識別子 public const ulong SettingQpackMaxTableCapacity = 0x01; - public const ulong SettingMaxFieldSectionSize = 0x06; public const ulong SettingQpackBlockedStreams = 0x07; public const ulong SettingEnableConnectProtocol = 0x08; // RFC 9220 public const ulong SettingH3Datagram = 0x33; // RFC 9297 - public const ulong SettingH3DatagramDraft04 = 0xffd277; public const ulong SettingEnableWebTransportDraft02 = 0x2b603742; public const ulong SettingWebTransportMaxSessionsDraft07 = 0xc671706a; - public const ulong SettingWtMaxSessions = 0x14e9cd29; // draft-09 以降。Chrome はまだ読まない // 誤りの番号(RFC 9114 8.1) public const ulong NoError = 0x100; - public const ulong GeneralProtocolError = 0x101; public const ulong InternalError = 0x102; public const ulong StreamCreationError = 0x103; public const ulong ClosedCriticalStream = 0x104; public const ulong FrameUnexpected = 0x105; - public const ulong FrameError = 0x106; public const ulong ExcessiveLoad = 0x107; public const ulong SettingsError = 0x109; public const ulong MissingSettings = 0x10a; public const ulong RequestRejected = 0x10b; - public const ulong RequestCancelled = 0x10c; public const ulong RequestIncomplete = 0x10d; public const ulong MessageError = 0x10e; - public const ulong QpackDecompressionFailed = 0x200; - public const ulong DatagramError = 0x33; + public const ulong QpackDecompressionFailed = 0x200; // RFC 9204 + public const ulong DatagramError = 0x33; // RFC 9297 // HTTP/2 にあって HTTP/3 で禁じた型(RFC 9114 7.2.8)。届いたら接続の誤り public static bool IsHttp2Only(ulong type) => type is 0x02 or 0x06 or 0x08 or 0x09; - /// フレームを 1 つ組み立てる。 + /// フレームを 1 つ組み立てる public static byte[] Frame(ulong type, ReadOnlySpan payload) { var w = new ByteWriter(); @@ -178,7 +171,7 @@ public static byte[] Frame(ulong type, ReadOnlySpan payload) return w.ToArray(); } - /// SETTINGS の中身(識別子と値の組の並び)。 + /// SETTINGS の中身(識別子と値の組の並び) public static byte[] Settings(IEnumerable<(ulong Id, ulong Value)> settings) { var p = new ByteWriter(); @@ -186,7 +179,7 @@ public static byte[] Settings(IEnumerable<(ulong Id, ulong Value)> settings) return Frame(FrameSettings, p.Span); } - /// SETTINGS の中身を読む。同じ識別子が 2 度来たら誤り。 + /// SETTINGS の中身を読む。同じ識別子が 2 度来たら誤り public static Dictionary? ParseSettings(ReadOnlySpan payload) { var d = new Dictionary(); @@ -216,6 +209,6 @@ public static bool TryReadFrame(ReadOnlySpan buf, ulong maxPayload, out ul return buf.Length >= headerLength + payloadLength; } - /// GREASE の値(0x1f * N + 0x21)。読み飛ばす。 + /// GREASE の値(0x1f * N + 0x21)。読み飛ばす public static bool IsGrease(ulong v) => v >= 0x21 && (v - 0x21) % 0x1f == 0; } diff --git a/server/Quic/MsQuic.cs b/server/Quic/MsQuic.cs index f0a85eb..98fe915 100644 --- a/server/Quic/MsQuic.cs +++ b/server/Quic/MsQuic.cs @@ -18,7 +18,6 @@ internal static unsafe class MsQuic public const uint ParamGlobalLibraryVersion = 0x01000004; public const uint ParamConfigurationSettings = 0x03000000; - public const uint ParamConnSettings = 0x05000004; public const uint ParamStreamId = 0x08000000; public const ushort CongestionCubic = 0; @@ -76,10 +75,6 @@ public static void Check(int status, string what) if (Failed(status)) throw new QuicNativeException(what, status); } - /// - /// QUIC_ADDR を作る。中身は sockaddr_in / sockaddr_in6。null は全アドレス(AF_UNSPEC で IPv4 と IPv6 の両方)。 - /// AF_INET6 の値は OS で違う(Windows 23、Linux 10) - /// /// QUIC_ADDR(sockaddr_in / sockaddr_in6)から IP を読む。IPv4 を IPv6 に写したものは IPv4 にする public static IPAddress? ReadAddress(byte* addr) { @@ -94,6 +89,10 @@ public static void Check(int status, string what) return null; } + /// + /// QUIC_ADDR を作る。null は全アドレス(AF_UNSPEC で IPv4 と IPv6 の両方)。 + /// AF_INET6 の値は OS で違う(Windows 23、Linux 10、macOS 30) + /// public static void WriteAddress(byte* addr, IPAddress? ip, ushort port) { new Span(addr, 32).Clear(); @@ -257,19 +256,11 @@ internal unsafe struct QuicListenerEvent internal unsafe struct QuicConnectionEvent { public const int Connected = 0; - public const int ShutdownInitiatedByTransport = 1; - public const int ShutdownInitiatedByPeer = 2; public const int ShutdownComplete = 3; public const int PeerStreamStarted = 6; public const int DatagramReceived = 11; - public const int DatagramSendStateChanged = 12; [FieldOffset(0)] public int Type; - // SHUTDOWN_INITIATED_BY_TRANSPORT - [FieldOffset(8)] public int TransportStatus; - [FieldOffset(16)] public ulong TransportErrorCode; - // SHUTDOWN_INITIATED_BY_PEER - [FieldOffset(8)] public ulong PeerErrorCode; // SHUTDOWN_COMPLETE: HandshakeCompleted:1 PeerAcknowledgedShutdown:1 AppCloseInProgress:1 [FieldOffset(8)] public byte ShutdownCompleteBits; // PEER_STREAM_STARTED @@ -277,9 +268,6 @@ internal unsafe struct QuicConnectionEvent [FieldOffset(16)] public int PeerStreamFlags; // DATAGRAM_RECEIVED [FieldOffset(8)] public QuicBuffer* DatagramBuffer; - // DATAGRAM_SEND_STATE_CHANGED - [FieldOffset(8)] public void* DatagramClientContext; - [FieldOffset(16)] public int DatagramSendState; } [StructLayout(LayoutKind.Explicit)] @@ -509,8 +497,9 @@ internal sealed unsafe class QuicConnection public Action? OnConnected; public Action? OnPeerStream; // bool は単方向か public DatagramHandler? OnDatagram; - public Action? OnShutdown; // 理由 public Action? OnClosed; + /// 上の層のコールバックが投げた例外。接続はそのまま続ける + public Action? OnError; public delegate void DatagramHandler(QuicConnection c, ReadOnlySpan data); @@ -532,18 +521,6 @@ internal void Release() /// 受けた接続に設定と証明書を付ける。listener のコールバックの中で呼ぶ internal int SetConfiguration(nint configuration) => MsQuic.Api->ConnectionSetConfiguration(Handle, configuration); - public QuicSettings ReadSettings() - { - QuicSettings s; - uint len = (uint)sizeof(QuicSettings); - lock (Gate) - { - if (_closed) return default; - MsQuic.Check(MsQuic.Api->GetParam(Handle, MsQuic.ParamConnSettings, &len, &s), "GetParam(CONN_SETTINGS)"); - } - return s; - } - /// 自分から stream を開く public QuicStream OpenStream(bool unidirectional) { @@ -566,18 +543,6 @@ public QuicStream OpenStream(bool unidirectional) } } - /// QUIC の datagram を送る。送り終わるまでバッファを保つ - public void SendDatagram(ReadOnlySpan data) - { - lock (Gate) - { - if (_closed) return; - var block = SendBlock.Create(data, null); - int st = MsQuic.Api->DatagramSend(Handle, SendBlock.Buffer(block), 1, 0, (void*)block); - if (MsQuic.Failed(st)) SendBlock.Complete(block, canceled: true); - } - } - public void Shutdown(ulong errorCode) { lock (Gate) @@ -593,7 +558,7 @@ static int Callback(nint handle, void* ctx, QuicConnectionEvent* ev) lock (self.Gate) { try { self.Dispatch(ev); } - catch (Exception e) { self.OnShutdown?.Invoke(self, $"コールバックで例外: {e.Message}"); } + catch (Exception e) { self.OnError?.Invoke(e); } } return MsQuic.StatusSuccess; } @@ -605,12 +570,6 @@ void Dispatch(QuicConnectionEvent* ev) case QuicConnectionEvent.Connected: OnConnected?.Invoke(this); break; - case QuicConnectionEvent.ShutdownInitiatedByTransport: - OnShutdown?.Invoke(this, $"transport status=0x{ev->TransportStatus:x} code=0x{ev->TransportErrorCode:x}"); - break; - case QuicConnectionEvent.ShutdownInitiatedByPeer: - OnShutdown?.Invoke(this, $"peer code=0x{ev->PeerErrorCode:x}"); - break; case QuicConnectionEvent.PeerStreamStarted: { var s = new QuicStream(this); @@ -619,16 +578,12 @@ void Dispatch(QuicConnectionEvent* ev) s.LoadId(); bool uni = (ev->PeerStreamFlags & MsQuic.StreamOpenUnidirectional) != 0; if (OnPeerStream != null) OnPeerStream(this, s, uni); - else s.Abort(0x100); + else s.Abort(H3.NoError); break; } case QuicConnectionEvent.DatagramReceived: OnDatagram?.Invoke(this, new ReadOnlySpan(ev->DatagramBuffer->Buffer, (int)ev->DatagramBuffer->Length)); break; - case QuicConnectionEvent.DatagramSendStateChanged: - // 3 以上(LOST_DISCARDED、ACKNOWLEDGED、ACKNOWLEDGED_SPURIOUS、CANCELED)で終わり - if (ev->DatagramSendState >= 3) SendBlock.Complete((nint)ev->DatagramClientContext, canceled: false); - break; case QuicConnectionEvent.ShutdownComplete: { bool appClose = (ev->ShutdownCompleteBits & 4) != 0; @@ -674,7 +629,6 @@ internal QuicStream(QuicConnection c) } internal nint Context => GCHandle.ToIntPtr(_self); - public bool IsClosed => _closed; internal void Attach(nint handle) => Handle = handle; @@ -691,9 +645,8 @@ public void Send(ReadOnlySpan data, bool fin = false) lock (Connection.Gate) { if (_closed) return; - var block = SendBlock.Create(data, null); - int st = MsQuic.Api->StreamSend(Handle, SendBlock.Buffer(block), 1, fin ? MsQuic.SendFlagFin : 0, (void*)block); - if (MsQuic.Failed(st)) { SendBlock.Complete(block, canceled: true); throw new QuicNativeException("StreamSend", st); } + int st = StartSend(data, fin, null); + if (MsQuic.Failed(st)) throw new QuicNativeException("StreamSend", st); } } @@ -707,17 +660,21 @@ public Task SendAsync(ReadOnlySpan data, bool fin = false) lock (Connection.Gate) { if (_closed) return Task.FromException(new IOException("stream が閉じている")); - var block = SendBlock.Create(data, tcs); - int st = MsQuic.Api->StreamSend(Handle, SendBlock.Buffer(block), 1, fin ? MsQuic.SendFlagFin : 0, (void*)block); - if (MsQuic.Failed(st)) - { - SendBlock.Complete(block, canceled: true); - return Task.FromException(new QuicNativeException("StreamSend", st)); - } + int st = StartSend(data, fin, tcs); + if (MsQuic.Failed(st)) return Task.FromException(new QuicNativeException("StreamSend", st)); } return tcs.Task; } + /// Gate を持って呼ぶ。失敗したらバッファはここで返す + int StartSend(ReadOnlySpan data, bool fin, TaskCompletionSource? waiter) + { + var block = SendBlock.Create(data, waiter); + int st = MsQuic.Api->StreamSend(Handle, SendBlock.Buffer(block), 1, fin ? MsQuic.SendFlagFin : 0, (void*)block); + if (MsQuic.Failed(st)) SendBlock.Complete(block, canceled: true); + return st; + } + /// 送信側を正しく閉じる(FIN) public void Finish() { @@ -763,9 +720,9 @@ internal static int Callback(nint handle, void* ctx, QuicStreamEvent* ev) try { self.Dispatch(ev); } catch (Exception) { - // 上の層の誤り。stream を打ち切って続ける(H3_INTERNAL_ERROR) + // 上の層の誤り。stream を打ち切って続ける if (ev->Type != QuicStreamEvent.ShutdownComplete && !self._closed) - MsQuic.Api->StreamShutdown(self.Handle, MsQuic.StreamShutdownAbort, 0x102); + MsQuic.Api->StreamShutdown(self.Handle, MsQuic.StreamShutdownAbort, H3.InternalError); } } return MsQuic.StatusSuccess; diff --git a/server/Quic/Qpack.cs b/server/Quic/Qpack.cs index d1c9905..9915f97 100644 --- a/server/Quic/Qpack.cs +++ b/server/Quic/Qpack.cs @@ -8,7 +8,7 @@ internal sealed class QpackException(string message) : Exception(message); internal static class Qpack { - /// 静的表(RFC 9204 付録 A)。番号は 0 から。 + /// 静的表(RFC 9204 付録 A)。番号は 0 から public static readonly (string Name, string Value)[] StaticTable = [ (":authority", ""), @@ -143,7 +143,7 @@ public static void WriteInt(ByteWriter w, byte firstBits, int prefixBits, ulong w.Byte((byte)v); } - /// 文字列。先頭バイトの huffmanBit が立っていれば Huffman 符号。長さは prefixBits の前置き整数。 + /// 文字列。長さは prefixBits の前置きの整数で、そのすぐ上の bit が立っていれば Huffman 符号 static string ReadString(ReadOnlySpan s, ref int pos, int prefixBits) { if (pos >= s.Length) throw new QpackException("文字列が途中で切れた"); @@ -158,7 +158,7 @@ static string ReadString(ReadOnlySpan s, ref int pos, int prefixBits) static string Latin1(ReadOnlySpan b) => Encoding.Latin1.GetString(b); - /// HEADERS フレームの中身(field section)を読む。 + /// HEADERS フレームの中身(field section)を読む public static List<(string Name, string Value)> Decode(ReadOnlySpan block) { int pos = 0; @@ -333,7 +333,7 @@ public static byte[] Decode(ReadOnlySpan src) return out_.ToArray(); } - /// 試験用の符号化。 + /// 符号化。試験だけで使う public static byte[] Encode(ReadOnlySpan src) { var w = new List(); diff --git a/server/Quic/WebTransport.cs b/server/Quic/WebTransport.cs index b64bbee..0a35692 100644 --- a/server/Quic/WebTransport.cs +++ b/server/Quic/WebTransport.cs @@ -54,7 +54,6 @@ static readonly (ulong, ulong)[] ServerSettings = bool _closed; // セッションの無いあいだ動く時計。SessionWait のうちに CONNECT が来なければ接続を閉じる Timer? _wait; - public Dictionary PeerSettings { get; private set; } = []; /// /// セッションが無いまま待つ時間。握手の後とセッションが終わった後に数え始める。 @@ -73,6 +72,7 @@ public Http3Connection(QuicConnection quic, IWebTransportHandler handler, Action quic.OnPeerStream = (_, s, uni) => { if (uni) AcceptUni(s); else AcceptBidi(s); }; quic.OnDatagram = (_, d) => Datagram(d); quic.OnClosed = _ => Closed(); + quic.OnError = e => _log($"webtransport: コールバックで例外: {e.Message}"); } public QuicConnection Quic => _quic; @@ -170,9 +170,8 @@ void ControlStream(QuicStream s, RecvBuffer buf, bool fin) if (!_peerSettingsSeen) { if (type != H3.FrameSettings) { Fail(H3.MissingSettings, "最初のフレームが SETTINGS でない"); return; } - var settings = H3.ParseSettings(payload); - if (settings == null) { Fail(H3.SettingsError, "SETTINGS が読めない"); return; } - PeerSettings = settings; + // 中身は使わない。形だけ確かめる + if (H3.ParseSettings(payload) is null) { Fail(H3.SettingsError, "SETTINGS が読めない"); return; } _peerSettingsSeen = true; } else if (type == H3.FrameSettings) { Fail(H3.FrameUnexpected, "SETTINGS が 2 度来た"); return; } @@ -317,15 +316,6 @@ void Datagram(ReadOnlySpan d) if (_sessions.TryGetValue(q * 4, out var session)) _handler.OnDatagram(session, d[n..]); // セッションが無ければ捨てる。閉じた直後に届くことがある } - - internal void SendDatagram(ulong sessionId, ReadOnlySpan payload) - { - var w = new ByteWriter(); - w.VarInt(sessionId / 4).Bytes(payload); - _quic.SendDatagram(w.Span); - } - - internal QuicStream OpenStream(bool unidirectional) => _quic.OpenStream(unidirectional); } /// 拡張 CONNECT(RFC 9220)の要求。WebTransport のときだけ受ける @@ -465,7 +455,6 @@ public void SendCapsule(ulong type, ReadOnlySpan payload, bool fin) internal sealed class WebTransportSession { public const ulong CapsuleCloseSession = 0x2843; - public const ulong CapsuleDrainSession = 0x78ae; // stream を打ち切るときの番号(draft-ietf-webtrans-http3) public const ulong ErrorSessionGone = 0x170d7b68; public const ulong ErrorBufferedStreamRejected = 0x3994bd84; @@ -477,7 +466,6 @@ internal sealed class WebTransportSession bool _finSent; public ulong Id { get; } - public string Authority { get; } public string Path { get; } public string Origin { get; } /// 扱い手が好きに使う @@ -489,14 +477,10 @@ public WebTransportSession(Http3Connection conn, RequestStream connect, ConnectR _connect = connect; connect.Session = this; Id = connect.Stream.Id; - Authority = request.Authority; Path = request.Path; Origin = request.Origin; } - public bool IsClosed => _closed; - public Lock Gate => _conn.Quic.Gate; - internal void Add(WebTransportStream s) { s.Session = this; @@ -507,7 +491,7 @@ internal void Add(WebTransportStream s) /// こちらから閉じる。CLOSE_WEBTRANSPORT_SESSION を送って CONNECT の stream に FIN を付ける。どのスレッドからでも呼べる public void Close(uint code, string reason) { - lock (Gate) + lock (_conn.Quic.Gate) { if (_closed) return; var p = new ByteWriter(); diff --git a/server/Quic/WebTransportServer.cs b/server/Quic/WebTransportServer.cs index cadefac..0aed60c 100644 --- a/server/Quic/WebTransportServer.cs +++ b/server/Quic/WebTransportServer.cs @@ -179,12 +179,11 @@ void AddIntermediates(string certPem) { try { - var all = new X509Certificate2Collection(); - all.ImportFromPem(certPem); - if (all.Count < 2) return; + var intermediates = Acme.Intermediates(certPem); + if (intermediates.Count == 0) return; using var store = new X509Store(StoreName.CertificateAuthority, StoreLocation.LocalMachine); store.Open(OpenFlags.ReadWrite); - for (var i = 1; i < all.Count; i++) store.Add(all[i]); + store.AddRange(intermediates); } catch (Exception e) { @@ -192,14 +191,11 @@ void AddIntermediates(string certPem) } } - internal static byte[] Pkcs12(string keyPem, string certPem, string? password = null) + static byte[] Pkcs12(string keyPem, string certPem, string? password = null) { using var leaf = X509Certificate2.CreateFromPem(certPem, keyPem); - var all = new X509Certificate2Collection(); - all.ImportFromPem(certPem); var col = new X509Certificate2Collection { leaf }; - foreach (var c in all) - if (c.Thumbprint != leaf.Thumbprint) col.Add(c); + col.AddRange(Acme.Intermediates(certPem)); return col.Export(X509ContentType.Pkcs12, password)!; } diff --git a/server/Relay.cs b/server/Relay.cs index 738811e..3f75218 100644 --- a/server/Relay.cs +++ b/server/Relay.cs @@ -11,7 +11,7 @@ namespace Mado; /// 1. 最初に RDCleanPath の要求を受け取る。複数のメッセージに分かれて来てもよい /// 2. RDP サーバーに TCP でつなぎ、要求に入っていた X.224 の接続要求を送り、接続確認を受け取る /// 3. 同じソケットの上で TLS を張り、サーバーの証明書の連なりを取る -/// 4. 接続確認と証明書を RDCleanPath の応答で返す。あとは WebSocket と TLS のあいだをそのまま流す +/// 4. 接続確認と証明書を RDCleanPath の応答で返す。あとはブラウザ側の口と TLS のあいだをそのまま流す /// /// ブラウザ側の口は IRelayChannel で包む。WebSocket はメッセージ、WebTransport はバイトの流れだが、 /// RDCleanPath の要求は DER で長さが分かるので、どちらも「届いた分を溜めて、揃ったら読む」で同じに扱える。 @@ -26,11 +26,34 @@ internal static class Relay /// つないでから RDCleanPath の要求が揃うまでの時間。ログインしていても、何も送らない口をいつまでも開けておかない public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); - /// authorize は proxy_auth の使い捨てのトークンを確かめて、つなぐ先を返す。通らなければ null - public static Task Run(WebSocket ws, Func authorize, Action log, CancellationToken ct) => - Run(new WebSocketChannel(ws), authorize, log, ct); + /// + /// 1 本の中継を最後まで動かす。/ws と /wt で同じ。接続の数を数え、開け閉めと思わぬ誤りをログに残す。 + /// kind はログに出す道の名前(ws / wt) + /// + public static async Task Serve(string kind, string user, IRelayChannel client, Func authorize, + Action log, Action sessions, CancellationToken ct) + { + sessions(+1); + log($"{kind} open: {user}"); + try + { + await Run(client, authorize, m => log($"{user}: {m}"), ct); + } + // 相手が切れただけ + catch (Exception e) when (e is IOException or WebSocketException or OperationCanceledException or ObjectDisposedException or Quic.QuicNativeException) { } + catch (Exception e) + { + log($"{user}: relay error: {e.Message}"); + } + finally + { + sessions(-1); + log($"{kind} close: {user}"); + } + } - public static async Task Run(IRelayChannel ws, Func authorize, Action log, CancellationToken ct, TimeSpan? requestTimeout = null) + /// authorize は proxy_auth の使い捨てのトークンを確かめて、つなぐ先を返す。通らなければ null + public static async Task Run(IRelayChannel client, Func authorize, Action log, CancellationToken ct, TimeSpan? requestTimeout = null) { var buf = new byte[64 * 1024]; var pending = new MemoryStream(); @@ -40,11 +63,11 @@ public static async Task Run(IRelayChannel ws, Func authorize, while (parsed is null) { int n; - try { n = await ws.ReceiveAsync(buf, wait.Token); } + try { n = await client.ReceiveAsync(buf, wait.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // WebSocket は受信を取り消すと壊れるので、誤りの PDU は送れないことがある(Fail は失敗を飲み込む) - await Fail(ws, 408, "RDCleanPath request timeout", log, ct); + await Fail(client, 408, "RDCleanPath request timeout", log, ct); return; } if (n == 0) return; @@ -52,30 +75,30 @@ public static async Task Run(IRelayChannel ws, Func authorize, // RDCleanPath の要求は 1 KB ほど。いつまでも揃わないものを溜め込まない if (pending.Length > MaxRequest) { - await Fail(ws, 413, "RDCleanPath request too large", log, ct); + await Fail(client, 413, "RDCleanPath request too large", log, ct); return; } try { parsed = RdCleanPath.Parse(pending.ToArray()); } catch (FormatException e) { - await Fail(ws, 400, $"bad RDCleanPath request: {e.Message}", log, ct); + await Fail(client, 400, $"bad RDCleanPath request: {e.Message}", log, ct); return; } } var (req, rest) = (parsed.Value.Request, parsed.Value.Remaining); if (req.Version != RdCleanPath.Version) { - await Fail(ws, 400, $"unsupported RDCleanPath version {req.Version}", log, ct); + await Fail(client, 400, $"unsupported RDCleanPath version {req.Version}", log, ct); return; } if (authorize(req.ProxyAuth) is not { } target) { - await Fail(ws, 401, "proxy_auth rejected", log, ct); + await Fail(client, 401, "proxy_auth rejected", log, ct); return; } if (req.X224 is null) { - await Fail(ws, 400, "no X.224 connection request (VMConnect is not supported)", log, ct); + await Fail(client, 400, "no X.224 connection request (VMConnect is not supported)", log, ct); return; } @@ -93,8 +116,8 @@ public static async Task Run(IRelayChannel ws, Func authorize, // RDP_NEG_FAILURE (0x03) なら TLS には進まず、そのまま返す。理由はブラウザが表示する if (cc.Length >= 12 && cc[11] == 0x03) { - await ws.SendAsync(RdCleanPath.Error(RdCleanPath.NegotiationError, x224: cc), ct); - await ws.CloseAsync("negotiation failure", ct); + await client.SendAsync(RdCleanPath.Error(RdCleanPath.NegotiationError, x224: cc), ct); + await client.CloseAsync("negotiation failure", ct); return; } var chain = new List(); @@ -112,12 +135,12 @@ await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions }, }, ct); if (chain.Count == 0) throw new IOException("RDP server sent no certificate"); - await ws.SendAsync(RdCleanPath.Response(cc, chain, $"{target.Host}:{target.Port}"), ct); + await client.SendAsync(RdCleanPath.Response(cc, chain, $"{target.Host}:{target.Port}"), ct); log($"relay up: {ssl.SslProtocol} {ssl.NegotiatedCipherSuite}"); } catch (Exception e) when (e is IOException or SocketException or System.Security.Authentication.AuthenticationException) { - await Fail(ws, 502, e.Message, log, ct); + await Fail(client, 502, e.Message, log, ct); return; } @@ -129,7 +152,7 @@ await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { while (true) { - var r = await ws.ReceiveAsync(buf, stop.Token); + var r = await client.ReceiveAsync(buf, stop.Token); if (r == 0) return; await ssl.WriteAsync(buf.AsMemory(0, r), stop.Token); } @@ -141,14 +164,14 @@ await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { var n = await ssl.ReadAsync(b, stop.Token); if (n == 0) return; - await ws.SendAsync(b.AsMemory(0, n), stop.Token); + await client.SendAsync(b.AsMemory(0, n), stop.Token); } }); await Task.WhenAny(up, down); await stop.CancelAsync(); await Task.WhenAll(up, down); } - await ws.CloseAsync("", CancellationToken.None); + await client.CloseAsync("", CancellationToken.None); } /// 片方向の流し込み。相手が切れたら黙って終わる @@ -158,13 +181,13 @@ static async Task Pump(Func loop) catch (Exception e) when (e is IOException or WebSocketException or OperationCanceledException or ObjectDisposedException) { } } - static async Task Fail(IRelayChannel ws, ulong httpStatus, string msg, Action log, CancellationToken ct) + static async Task Fail(IRelayChannel client, ulong httpStatus, string msg, Action log, CancellationToken ct) { log($"relay failed: {msg}"); try { - await ws.SendAsync(RdCleanPath.Error(RdCleanPath.GeneralError, httpStatus), ct); - await ws.CloseAsync("relay failed", ct); + await client.SendAsync(RdCleanPath.Error(RdCleanPath.GeneralError, httpStatus), ct); + await client.CloseAsync("relay failed", ct); } catch (Exception e) when (e is IOException or WebSocketException or ObjectDisposedException or InvalidOperationException or OperationCanceledException) { } } diff --git a/server/RelayTokens.cs b/server/RelayTokens.cs index 7950a22..1b3db57 100644 --- a/server/RelayTokens.cs +++ b/server/RelayTokens.cs @@ -10,8 +10,6 @@ internal sealed class RelayTokens(Signer signer, int ttlSeconds) { readonly ConcurrentDictionary _used = new(); - public int TtlSeconds => ttlSeconds; - public string Issue(RelayToken t) => signer.Sign(t, ApiJson.Default.RelayToken, ttlSeconds); /// 確かめて使う。sub を渡すと、トークンの sub がそれと同じことも見る(/ws は Cookie の sub)。だめなら null diff --git a/server/Updater.cs b/server/Updater.cs index a1c8623..4f45684 100644 --- a/server/Updater.cs +++ b/server/Updater.cs @@ -30,7 +30,7 @@ internal sealed record UpdateState( /// そもそも今より新しい版しか受けないし、MSI も古い版では上書きしない。入れるのは誰もつないでいないときだけ。 /// internal sealed class Updater( - string repo, string version, string dataDir, HttpClient http, Action log, + string repo, string version, string dataDir, string logDir, HttpClient http, Action log, Action? launch = null, string publicKey = Updater.PublicKey) { /// release.yml の秘密鍵と対になる公開鍵。SubjectPublicKeyInfo を base64 にしたもの @@ -142,9 +142,9 @@ string Url(string name) => log($"update: {r.TagName} を落としたが、つないでいる人がいるので後で入れる"); return false; } - var msiLog = Path.Combine(Path.GetDirectoryName(dataDir)!, "logs", $"msi-{r.TagName}.log"); + var msiLog = Path.Combine(logDir, $"msi-{r.TagName}.log"); log($"update: {version} → {r.TagName} を入れる(msiexec。ログは {msiLog})"); - Directory.CreateDirectory(Path.GetDirectoryName(msiLog)!); + Directory.CreateDirectory(logDir); launch(msi, msiLog); launched = (r.TagName, DateTimeOffset.UtcNow); return true; diff --git a/server/WebAssets.cs b/server/WebAssets.cs index 123b189..5bf9182 100644 --- a/server/WebAssets.cs +++ b/server/WebAssets.cs @@ -36,15 +36,15 @@ internal static class WebAssets _ => "application/octet-stream", }; - public static bool Has(string path) => files.ContainsKey(path == "/" ? "/index.html" : path); - - public static async Task Serve(HttpContext ctx) + /// あれば返して true。無ければ何もせず false + public static async Task TryServe(HttpContext ctx) { var path = ctx.Request.Path.Value is "/" or null ? "/index.html" : ctx.Request.Path.Value; - var (body, type) = files[path]; - ctx.Response.ContentType = type; + if (!files.TryGetValue(path, out var file)) return false; + ctx.Response.ContentType = file.Type; ctx.Response.Headers.CacheControl = path == "/index.html" ? "no-cache" : "public, max-age=31536000, immutable"; - ctx.Response.ContentLength = body.Length; - await ctx.Response.Body.WriteAsync(body); + ctx.Response.ContentLength = file.Body.Length; + await ctx.Response.Body.WriteAsync(file.Body); + return true; } } diff --git a/server/WebTransportRelay.cs b/server/WebTransportRelay.cs index e0535d9..b2c6ccc 100644 --- a/server/WebTransportRelay.cs +++ b/server/WebTransportRelay.cs @@ -97,26 +97,15 @@ public void OnBidiStream(WebTransportSession session, WebTransportStream stream) var bytes = new WebTransportByteStream(stream); _ = Task.Run(async () => { - sessions(+1); - log($"wt open: {claim.User}"); try { - await Relay.Run(new StreamChannel(bytes), auth => Authorize(claim, auth), m => log($"{claim.User}: {m}"), cts.Token); - } - catch (Exception e) when (e is IOException or OperationCanceledException or ObjectDisposedException or QuicNativeException) - { - } - catch (Exception e) - { - log($"wt relay error: {e.Message}"); + await Relay.Serve("wt", claim.User, new StreamChannel(bytes), auth => Authorize(claim, auth), log, sessions, cts.Token); } finally { - sessions(-1); await bytes.DisposeAsync(); session.Close(0, ""); cts.Dispose(); - log($"wt close: {claim.User}"); } }); } diff --git a/tests/RelayTests.cs b/tests/RelayTests.cs index 2eef697..03bb8fe 100644 --- a/tests/RelayTests.cs +++ b/tests/RelayTests.cs @@ -194,6 +194,21 @@ public async Task 要求が来なければ時間切れで_408_を返して閉じ await Assert.That(logs.Any(l => l.Contains("timeout"))).IsTrue(); } + [Test] + public async Task Serve_は数を戻して開け閉めをログに残す() + { + var ch = new FakeChannel(); + var logs = new List(); + var count = 0; + var relay = Relay.Serve("wt", "u", ch, _ => null, logs.Add, d => count += d, CancellationToken.None); + ch.Feed(Request("bad", X224Request)); + await relay.WaitAsync(TimeSpan.FromSeconds(10)); + await Assert.That(count).IsEqualTo(0); + await Assert.That(logs.First()).IsEqualTo("wt open: u"); + await Assert.That(logs.Last()).IsEqualTo("wt close: u"); + await Assert.That(logs.Any(l => l.StartsWith("u: relay failed: proxy_auth"))).IsTrue(); + } + [Test] public async Task トークンが通らなければ_401_で閉じる() { diff --git a/tests/UpdaterTests.cs b/tests/UpdaterTests.cs index e9f016e..d14d177 100644 --- a/tests/UpdaterTests.cs +++ b/tests/UpdaterTests.cs @@ -82,7 +82,7 @@ protected override Task SendAsync(HttpRequestMessage req, C var gh = new FakeGitHub("v9.0.0", msi, () => sign(Updater.Message(goodSig ? "v9.0.0" : "v8.0.0", sha))); var dir = Path.Combine(Directory.CreateTempSubdirectory("mado-update-").FullName, "data"); var launched = new List(); - var u = new Updater("x/y", "v0.5.0", dir, new HttpClient(gh), _ => { }, (msiPath, _) => launched.Add(msiPath), pub); + var u = new Updater("x/y", "v0.5.0", dir, Path.Combine(Path.GetDirectoryName(dir)!, "logs"), new HttpClient(gh), _ => { }, (msiPath, _) => launched.Add(msiPath), pub); return (u, gh, launched, dir); } @@ -127,7 +127,8 @@ public async Task 今より古いか同じ版は入れない() { var (pub, sign) = NewKey(); var gh = new FakeGitHub("v0.5.0", "MSI"u8.ToArray(), () => ""); - var u = new Updater("x/y", "v0.5.0", Directory.CreateTempSubdirectory("mado-update-").FullName, new HttpClient(gh), _ => { }, (_, _) => throw new InvalidOperationException(), pub); + var tmp = Directory.CreateTempSubdirectory("mado-update-").FullName; + var u = new Updater("x/y", "v0.5.0", tmp, Path.Combine(tmp, "logs"), new HttpClient(gh), _ => { }, (_, _) => throw new InvalidOperationException(), pub); await Assert.That((await u.Check()).Available).IsFalse(); await Assert.That(await u.Apply(() => true)).IsFalse(); } diff --git a/tray/Program.cs b/tray/Program.cs index 09a9715..40028f6 100644 --- a/tray/Program.cs +++ b/tray/Program.cs @@ -9,6 +9,7 @@ // ウィンドウはメッセージ専用にせず、見えない普通のトップレベルにする。こうすると MSI の更新のときに Restart Manager が // WM_CLOSE で閉じられる。閉じたあとは RegisterApplicationRestart で起こし直してもらう。 +using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text.Json; @@ -29,9 +30,8 @@ internal static unsafe partial class Program /// 自分の版。属性から取れなければ、起動したときの exe のファイルの版を使う。どちらも取れなければ null にして、 /// 起こし直しはしない。0.0.0 にすると、どの版も新しく見えて 5 秒ごとに起こし直し続けてしまう /// - static readonly string? ownVersion = Known(typeof(Program).Assembly - .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) - .OfType().FirstOrDefault()?.InformationalVersion) + static readonly string? ownVersion = + Known(typeof(Program).Assembly.GetCustomAttribute()?.InformationalVersion) ?? (exePath is null ? null : Known(System.Diagnostics.FileVersionInfo.GetVersionInfo(exePath).ProductVersion)); static string? Known(string? v) => string.IsNullOrEmpty(v) || VersionOf(v) == "v0.0.0" ? null : VersionOf(v); @@ -281,11 +281,8 @@ static void Open(string? url) // アイコン - static NOTIFYICONDATAW Data(uint flags) - { - var d = new NOTIFYICONDATAW { cbSize = (uint)sizeof(NOTIFYICONDATAW), hWnd = hwnd, uID = 1, uFlags = flags }; - return d; - } + static NOTIFYICONDATAW Data(uint flags) => + new() { cbSize = (uint)sizeof(NOTIFYICONDATAW), hWnd = hwnd, uID = 1, uFlags = flags }; static void AddIcon() { From efc1ae8b409136d0023f81cd043cb43bcef81e91 Mon Sep 17 00:00:00 2001 From: Ruk Doe Date: Mon, 12 Oct 2026 20:23:46 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E3=83=88=E3=83=AC=E3=82=A4=E3=81=8C?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E6=9B=B4=E6=96=B0=E3=81=AE=E6=AD=A2=E3=81=BE?= =?UTF-8?q?=E3=81=A3=E3=81=A6=E3=81=84=E3=82=8B=E3=81=A8=E3=81=8D=E3=81=AB?= =?UTF-8?q?=E7=A9=BA=E3=81=AE=E7=89=88=E3=82=92=E5=87=BA=E3=81=99=20/=20do?= =?UTF-8?q?cs:=20=E8=A6=8B=E7=9B=B4=E3=81=97=E3=81=A7=E8=A6=8B=E3=81=A4?= =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=9F=E6=8A=9C=E3=81=91=E3=82=92=E6=9B=B8?= =?UTF-8?q?=E3=81=8F=20/=20=E8=A9=A6=E9=A8=93=E7=94=A8=20mini-mado=20?= =?UTF-8?q?=E3=82=92=E4=BB=8A=E3=81=AE=20Relay=20=E3=81=AB=E5=90=88?= =?UTF-8?q?=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2BadDnB3uH3FDGjaPMP4J --- README.md | 7 ++++--- tools/hyperv-test/mini-mado.ps1 | 7 ++++++- tray/Program.cs | 2 ++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 223a525..25006ae 100644 --- a/README.md +++ b/README.md @@ -70,13 +70,14 @@ MSI を先に入れてもいい。設定が無いとサービスの起動に失 | `acme.email` | Let's Encrypt の連絡先。`acme.staging: true` で試験用の環境を使う | | `oidc` | `issuer`、`client_id`、`client_secret`(または `client_secret_file`。1 行目を読む)。`required_role` を書くと、ID トークンの `roles` にその値が無いユーザーは入れない | | `targets` | つなぐ先。`name`、`host`、`port`(既定 3389)。1 つだけなら選択欄は出ない。省略するとこのマシン(127.0.0.1:3389) | -| `listen` | 待ち受け。既定は `{ "https": 443, "http": 80, "hostname": "::" }`(IPv4 も受ける) | -| `status.port` | ローカルからだけ使える状態の API(既定 8788、`0` で無効)。`GET /status` で版、接続数、証明書の残り日数、更新の有無を返す。`POST /update`(ヘッダー `x-mado: 1`)で更新をすぐ確認する。タスクトレイが使う | +| `listen` | 待ち受け。既定は `{ "https": 443, "http": 80, "hostname": "::" }`(IPv4 も受ける)。MSI が開けるファイアウォールは TCP 80 / 443 と UDP 443 だけなので、port を変えたら自分で開ける | +| `status.port` | ローカルからだけ使える状態の API(既定 8788、`0` で無効)。`GET /status` で版、外向きの origin、起動した時刻、接続数、証明書の残り日数、更新の有無を返す。`POST /update`(ヘッダー `x-mado: 1`)で更新をすぐ確認する。タスクトレイが使う | | `update` | 自動更新。既定は `{ "enabled": true, "check_hours": 6, "repo": "danything/mado" }` | | `tls` | `"off"` で TLS と ACME を使わず http で待ち受ける。localhost で試すとき用 | | `webtransport` | WebTransport の UDP の port。既定は `tls` が `acme` のとき https と同じ port(443)。`{ "port": 8443 }` で変え、`false` か port `0` で使わない。msquic が読めないときや UDP で待ち受けられないときは、ログに書いて WebSocket だけで動く | IdP には戻り先として `https:///callback` を登録する。Entra ID ならアプリロールを作ってユーザーに割り当て、その値を `required_role` に書く。 +`/logout` は mado のセッションの Cookie を消すだけで、IdP からはログアウトしない。 ログは `C:\ProgramData\mado\logs\mado.log`。10 MB を超えると `.1` に移す。 @@ -104,7 +105,7 @@ IdP には戻り先として `https:///callback` を登録する。Entra I 秘密鍵は GitHub の secret `MADO_SIGNING_KEY`、公開鍵は exe に入っている(`server/Updater.cs` の `PublicKey`)。 タグも署名に含むので、古い版を最新と偽って入れさせることはできない。今より新しい版しか受け付けない - 誰もつないでいないときに入れる。`msiexec` でメジャー アップグレードし、サービスを止めて入れ替え、起動し直す。 - MSI は `C:\ProgramData\mado\data\updates\`、ログは `logs\msi-<タグ>.log` に置く + MSI は `C:\ProgramData\mado\data\updates\`、ログは mado.log と同じ `logs` に `msi-<タグ>.log` として置く - 署名の鍵を作り直したときは、新しい公開鍵を `PublicKey` に入れてリリースし、その版だけは手で入れる。古い mado は新しい鍵の署名を受け付けない ## 開発 diff --git a/tools/hyperv-test/mini-mado.ps1 b/tools/hyperv-test/mini-mado.ps1 index 27a059a..cf3dd28 100644 --- a/tools/hyperv-test/mini-mado.ps1 +++ b/tools/hyperv-test/mini-mado.ps1 @@ -25,9 +25,14 @@ namespace Mado public static class MiniRelay { public static Task Run(System.Net.WebSockets.WebSocket ws, string host, int port) => - Relay.Run(ws, _ => new Target("vm", host, port), m => Console.WriteLine($"{DateTime.Now:HH:mm:ss} relay: {m}"), CancellationToken.None); + Relay.Run(new WebSocketChannel(ws), _ => new Target("vm", host, port), m => Console.WriteLine($"{DateTime.Now:HH:mm:ss} relay: {m}"), CancellationToken.None); } } +// Relay.cs が QUIC の例外を名前で見るので、同じ名前のものを置く(mini-mado は QUIC を使わない) +namespace Mado.Quic +{ + internal sealed class QuicNativeException : Exception { } +} '@ | Set-Content (Join-Path $tmp 'mini.cs') -Encoding utf8 $files = @((Join-Path $tmp 'mini.cs')) + ('Der.cs', 'RdCleanPath.cs', 'Relay.cs' | ForEach-Object { Join-Path $repo "server/$_" }) # サーバーの csproj と同じく、nullable の注釈を有効にしてコンパイルする diff --git a/tray/Program.cs b/tray/Program.cs index 40028f6..fd5fe6c 100644 --- a/tray/Program.cs +++ b/tray/Program.cs @@ -494,6 +494,8 @@ internal static class Net if (error is not null) return ("mado の更新", error, true); if (st.TryGetProperty("available", out var a) && a.GetBoolean()) return ("mado の更新", $"{latest} があるが、つないでいる人がいるので後で入れる", false); + // 自動更新が止まっているときは確かめないので、最新の版が空のまま返る + if (string.IsNullOrEmpty(latest)) return ("mado の更新", "更新を確かめていない。自動更新が止まっているかもしれない", false); return ("mado の更新", $"新しい版は無い(最新は {latest})", false); } catch (Exception ex) From 626202175cb9c33a1b2cb74856b97f45b8262c48 Mon Sep 17 00:00:00 2001 From: Ruk Doe Date: Mon, 12 Oct 2026 20:27:35 +0900 Subject: [PATCH 6/6] chore: v0.8.5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M2BadDnB3uH3FDGjaPMP4J --- server/Mado.csproj | 2 +- tray/MadoTray.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Mado.csproj b/server/Mado.csproj index c198925..fc1c51f 100644 --- a/server/Mado.csproj +++ b/server/Mado.csproj @@ -5,7 +5,7 @@ enable mado Mado - 0.8.4 + 0.8.5 true true diff --git a/tray/MadoTray.csproj b/tray/MadoTray.csproj index 0934dfd..cd05c05 100644 --- a/tray/MadoTray.csproj +++ b/tray/MadoTray.csproj @@ -7,7 +7,7 @@ enable mado-tray Mado.Tray - 0.8.4 + 0.8.5 true true