forked from unom/punktfunk
Merge branch 'chore/windows-rerender-semantics' into main
Windows 11 tray theming + per-connect device-name announcement, and the pairing approve button no longer escapes the canvas on portrait phones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
-3
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.20.0"
|
||||
"version": "0.21.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -1558,7 +1558,7 @@
|
||||
"host"
|
||||
],
|
||||
"summary": "Local status summary for the tray icon",
|
||||
"description": "Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device\nnames). Unauthenticated, but served to loopback peers only.",
|
||||
"description": "Non-sensitive status (counts, booleans, and the streaming client's display name — no PIN\nvalues, no fingerprints). Unauthenticated, but served to loopback peers only.",
|
||||
"operationId": "getLocalSummary",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -5686,7 +5686,7 @@
|
||||
},
|
||||
"LocalSummary": {
|
||||
"type": "object",
|
||||
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
|
||||
"description": "Non-sensitive host status for the local tray icon: counts and booleans — no PIN values, no\nfingerprints. The ONE name exposed is `client_name`, the streaming client's display label\n(deliberate loosening for the tray's \"client connected\" toast: it tells the local user who is\non their machine, which is disclosure in the user's favor — and any local process could\nalready infer a session exists from the booleans here). Served unauthenticated to LOOPBACK\npeers only (see `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on\nWindows, so the per-user tray process cannot authenticate — this narrow read-only route is\nits status source.",
|
||||
"required": [
|
||||
"version",
|
||||
"video_streaming",
|
||||
@@ -5702,6 +5702,13 @@
|
||||
"type": "boolean",
|
||||
"description": "True while audio is streaming on either plane (same rule as `video_streaming`)."
|
||||
},
|
||||
"client_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Display name of the (first) streaming native client — the trust store's name for it, else\nthe name the device sent at connect. `null` when idle, for a nameless client, or for a\nGameStream session (that plane carries no device name)."
|
||||
},
|
||||
"conflicts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
@@ -53,6 +54,9 @@ suspend fun connectToHost(
|
||||
// the user's soft codec preference — the host resolves the emitted codec from both.
|
||||
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
|
||||
launch,
|
||||
// The host's approval-list / trust-store label for this device — the same
|
||||
// Build.MODEL convention the pairing dialogs use for nativePair.
|
||||
Build.MODEL ?: "Android",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ object NativeBridge {
|
||||
/** Store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game,
|
||||
* or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */
|
||||
launch: String?,
|
||||
/** This device's display name (rides the Hello as `name`) — what the host's pending-approval
|
||||
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
|
||||
* the host falls back to a fingerprint-derived "device abcd1234" label. */
|
||||
deviceName: String?,
|
||||
): Long
|
||||
|
||||
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
|
||||
|
||||
@@ -84,8 +84,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLaten
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`.
|
||||
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch,
|
||||
/// deviceName): Long`.
|
||||
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
|
||||
/// `deviceName` (empty ⇒ none) rides the Hello as `name` — what the host's pending-approval list
|
||||
/// and trust store show for this device (Kotlin passes `Build.MODEL`, its `nativePair` convention).
|
||||
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
|
||||
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
|
||||
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
|
||||
@@ -117,6 +120,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
preferred_codec: jint,
|
||||
timeout_ms: jint,
|
||||
launch: JString<'local>,
|
||||
device_name: JString<'local>,
|
||||
) -> jlong {
|
||||
let host: String = match env.get_string(&host) {
|
||||
Ok(s) => s.into(),
|
||||
@@ -135,6 +139,14 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
.map(Into::into)
|
||||
.ok()
|
||||
.filter(|s: &String| !s.is_empty());
|
||||
// The host's approval-list / trust-store label for this device; null / blank ⇒ None (the host
|
||||
// falls back to its fingerprint-derived "device abcd1234" placeholder).
|
||||
let device_name: Option<String> = env
|
||||
.get_string(&device_name)
|
||||
.map(Into::into)
|
||||
.ok()
|
||||
.map(|s: String| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
|
||||
None
|
||||
@@ -204,8 +216,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
|
||||
// No non-video caps: this client does not render the host cursor locally (no shape/state
|
||||
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
|
||||
0,
|
||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
launch, // a store-qualified library id to boot into a game, or None for the desktop
|
||||
device_name, // Kotlin's Build.MODEL — the host's approval-list / trust-store label
|
||||
pin, // Some → Crypto on host-fp mismatch
|
||||
identity, // owned (cert, key) PEM, or None (anonymous)
|
||||
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
|
||||
// (the host parks the connection until the operator approves the device — see ConnectScreen).
|
||||
|
||||
@@ -657,6 +657,7 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
None, // display_hdr
|
||||
0, // client_caps: nothing renders a cursor
|
||||
None, // launch
|
||||
Some(punktfunk_core::client::device_name()),
|
||||
Some(pin),
|
||||
Some(identity),
|
||||
Duration::from_secs(15),
|
||||
|
||||
@@ -699,6 +699,9 @@ impl AppModel {
|
||||
None, // display_hdr: probe connect, nothing presents
|
||||
0, // client_caps: probe connect, nothing renders a cursor
|
||||
None, // launch: probe connect, no game
|
||||
// Knock under this device's name, not a fingerprint placeholder, when the
|
||||
// probed host doesn't know us yet.
|
||||
Some(pf_client_core::trust::device_name()),
|
||||
pin,
|
||||
Some(identity),
|
||||
std::time::Duration::from_secs(15),
|
||||
|
||||
@@ -699,6 +699,9 @@ impl AppModel {
|
||||
None, // display_hdr: probe connect, nothing presents
|
||||
0, // client_caps: probe connect, nothing renders a cursor
|
||||
None, // launch: probe connect, no game
|
||||
// Knock under this device's name, not a fingerprint placeholder, when the
|
||||
// probed host doesn't know us yet.
|
||||
Some(pf_client_core::trust::device_name()),
|
||||
pin,
|
||||
Some(identity),
|
||||
std::time::Duration::from_secs(15),
|
||||
|
||||
@@ -58,6 +58,9 @@ pub fn run_speed_probe(
|
||||
None, // display_hdr: probe connect, nothing presents
|
||||
0, // client_caps: probe connect, nothing renders a cursor
|
||||
None, // launch: no game
|
||||
// Same label a real session sends — a speed test against a host that doesn't know us yet
|
||||
// should knock under this device's name, not a fingerprint placeholder.
|
||||
Some(punktfunk_core::client::device_name()),
|
||||
pin,
|
||||
Some(identity),
|
||||
Duration::from_secs(15),
|
||||
|
||||
@@ -273,6 +273,9 @@ fn pump(
|
||||
0
|
||||
},
|
||||
params.launch.clone(),
|
||||
// The host's approval-list / trust-store label for this client. Without it every no-PIN
|
||||
// "request access" knock showed up as the fingerprint placeholder "device abcd1234".
|
||||
Some(crate::trust::device_name()),
|
||||
params.pin,
|
||||
Some(params.identity),
|
||||
params.connect_timeout,
|
||||
|
||||
@@ -344,21 +344,10 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
|
||||
}
|
||||
|
||||
/// This machine's name — the label a host files this client under in its paired-devices list.
|
||||
/// `/etc/hostname` first (the answer on any Linux box, and the only one available in a minimal
|
||||
/// build with no GTK to ask), then the usual environment fallbacks.
|
||||
/// Now owned by punktfunk-core (`client::device_name`) so the connect path and the C ABI share
|
||||
/// the same default; re-exported here for the existing pairing-path callers.
|
||||
pub fn device_name() -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
std::env::var("COMPUTERNAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "This device".into())
|
||||
punktfunk_core::client::device_name()
|
||||
}
|
||||
|
||||
/// Drop an fp-less placeholder entry for `addr:port`. A host added by address before any
|
||||
|
||||
@@ -1872,6 +1872,11 @@ unsafe fn connect_ex_impl(
|
||||
// ([`punktfunk_connection_next_cursor_shape`]/`_state`) may set it. ex7/ex8 pass 0.
|
||||
client_caps,
|
||||
launch,
|
||||
// The C ABI has no device-name parameter (only `punktfunk_pair` takes one), so every
|
||||
// embedder gets the OS hostname default — this is what the host's pending-approval
|
||||
// list shows when an unpaired embedder knocks. An `ex10` variant can make it explicit
|
||||
// if an embedder ever wants a custom label (e.g. the platform's marketing name).
|
||||
Some(crate::client::device_name()),
|
||||
pin,
|
||||
identity,
|
||||
std::time::Duration::from_millis(timeout_ms as u64),
|
||||
|
||||
@@ -292,6 +292,26 @@ fn register_hot_tid(reg: &Mutex<Vec<i32>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine's name — the default value for [`NativeClient::connect`]'s `name` parameter
|
||||
/// (what a host shows in its pending-approval list and files this client under when approved).
|
||||
/// `/etc/hostname` first (the answer on any Linux box, and available in a minimal build with no
|
||||
/// desktop toolkit to ask), then the usual environment fallbacks. Lives here (not in a client
|
||||
/// shell crate) so the C ABI's `punktfunk_connect` can share the same default.
|
||||
pub fn device_name() -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
std::env::var("COMPUTERNAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "This device".into())
|
||||
}
|
||||
|
||||
impl NativeClient {
|
||||
/// Connect to a `punktfunk/1` host and start the session at (up to) `mode`. Blocks until the
|
||||
/// handshake completes or `timeout` elapses.
|
||||
@@ -335,6 +355,11 @@ impl NativeClient {
|
||||
// streams with NO visible cursor at all. `0` = today's composited behavior.
|
||||
client_caps: u8,
|
||||
launch: Option<String>,
|
||||
// This device's display name, carried in [`crate::quic::Hello::name`]: what the host's
|
||||
// pending-approval list shows when an unpaired client knocks, and what its trust store
|
||||
// files the device under on delegated approval. `None` = the host falls back to a
|
||||
// fingerprint-derived "device abcd1234" label. Embedders usually pass [`device_name`].
|
||||
name: Option<String>,
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
timeout: Duration,
|
||||
@@ -414,6 +439,7 @@ impl NativeClient {
|
||||
display_hdr,
|
||||
client_caps,
|
||||
launch,
|
||||
name,
|
||||
pin,
|
||||
identity,
|
||||
connect_timeout: timeout,
|
||||
|
||||
@@ -120,9 +120,10 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
// No device name yet: the connect ABI has no name parameter (pairing does). The
|
||||
// host falls back to a fingerprint-derived label in its pending-approval list.
|
||||
name: None,
|
||||
// The embedder's device name — what the host's pending-approval list and paired-
|
||||
// devices store show for this client. `None` makes the host fall back to a
|
||||
// fingerprint-derived "device abcd1234" label.
|
||||
name: args.name.clone(),
|
||||
// Library id to launch this session, if the embedder asked for one.
|
||||
launch: launch.clone(),
|
||||
// The embedder's decode/present caps (e.g. the Windows client advertises
|
||||
|
||||
@@ -24,6 +24,8 @@ pub(crate) struct WorkerArgs {
|
||||
pub(crate) display_hdr: Option<HdrMeta>,
|
||||
pub(crate) client_caps: u8,
|
||||
pub(crate) launch: Option<String>,
|
||||
/// This device's display name, sent in `Hello` (the host's approval list / trust store label).
|
||||
pub(crate) name: Option<String>,
|
||||
pub(crate) pin: Option<[u8; 32]>,
|
||||
pub(crate) identity: Option<(String, String)>,
|
||||
/// The embedder's connect budget (the same value `connect` bounds `ready_rx` with): the
|
||||
|
||||
@@ -187,10 +187,14 @@ pub(crate) struct StreamInfo {
|
||||
last_resize_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,
|
||||
/// no fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see
|
||||
/// `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the
|
||||
/// per-user tray process cannot authenticate — this narrow read-only route is its status source.
|
||||
/// Non-sensitive host status for the local tray icon: counts and booleans — no PIN values, no
|
||||
/// fingerprints. The ONE name exposed is `client_name`, the streaming client's display label
|
||||
/// (deliberate loosening for the tray's "client connected" toast: it tells the local user who is
|
||||
/// on their machine, which is disclosure in the user's favor — and any local process could
|
||||
/// already infer a session exists from the booleans here). Served unauthenticated to LOOPBACK
|
||||
/// peers only (see `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on
|
||||
/// Windows, so the per-user tray process cannot authenticate — this narrow read-only route is
|
||||
/// its status source.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct LocalSummary {
|
||||
/// Host version (mirrors `/health`).
|
||||
@@ -203,6 +207,11 @@ pub(crate) struct LocalSummary {
|
||||
/// The active session: GameStream's launch (Moonlight `/launch`) when present, else the first
|
||||
/// live native session. `null` when nothing is streaming.
|
||||
session: Option<SessionInfo>,
|
||||
/// Display name of the (first) streaming native client — the trust store's name for it, else
|
||||
/// the name the device sent at connect. `null` when idle, for a nameless client, or for a
|
||||
/// GameStream session (that plane carries no device name).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
client_name: Option<String>,
|
||||
/// Number of pinned (paired) GameStream client certificates.
|
||||
paired_clients: u32,
|
||||
/// Number of paired native (punktfunk/1) devices.
|
||||
@@ -457,8 +466,8 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
|
||||
|
||||
/// Local status summary for the tray icon
|
||||
///
|
||||
/// Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
|
||||
/// names). Unauthenticated, but served to loopback peers only.
|
||||
/// Non-sensitive status (counts, booleans, and the streaming client's display name — no PIN
|
||||
/// values, no fingerprints). Unauthenticated, but served to loopback peers only.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/local/summary",
|
||||
@@ -506,6 +515,9 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
|
||||
video_streaming: st.app.streaming.load(Ordering::SeqCst) || !native.is_empty(),
|
||||
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst) || !native.is_empty(),
|
||||
session,
|
||||
// The first native session's display name (matches the `session` fallback order — a
|
||||
// GameStream launch carries no device name, so the field stays absent there).
|
||||
client_name: native.first().and_then(|s| s.client_name.clone()),
|
||||
paired_clients: st
|
||||
.app
|
||||
.paired
|
||||
|
||||
@@ -316,6 +316,7 @@ fn fake_native_session(
|
||||
quit: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
force_idr: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
client: "test-client".into(),
|
||||
client_name: Some("studio-deck".into()),
|
||||
hdr: false,
|
||||
ttff_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||
last_resize_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||
@@ -345,17 +346,28 @@ async fn local_summary_reports_a_native_session_as_streaming() {
|
||||
assert_eq!(body["session"]["width"], 3840);
|
||||
assert_eq!(body["session"]["height"], 2160);
|
||||
assert_eq!(body["session"]["fps"], 120);
|
||||
// The STREAMING client's display name rides along (the tray's connect toast); see the
|
||||
// non-sensitive test below for the idle-side guarantee.
|
||||
assert_eq!(body["client_name"], "studio-deck");
|
||||
|
||||
// Session over → back to idle.
|
||||
// Session over → back to idle, and the name goes with it.
|
||||
drop(session);
|
||||
let (_, body) = send(&app, summary_req()).await;
|
||||
assert_eq!(body["video_streaming"], false);
|
||||
assert_eq!(body["session"], serde_json::Value::Null);
|
||||
assert_eq!(
|
||||
body["client_name"],
|
||||
serde_json::Value::Null,
|
||||
"no live session → no client name in the summary"
|
||||
);
|
||||
}
|
||||
|
||||
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
|
||||
/// rejected even though the route needs no bearer token, and the body never carries secret
|
||||
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
|
||||
/// material (no PIN values, no fingerprints). The ONE name it may carry is the *streaming*
|
||||
/// client's display name (`client_name`, for the tray's connect toast) — a paired-but-idle
|
||||
/// device's name must still never appear, which is what this test pins (it pairs a device and
|
||||
/// registers NO session).
|
||||
#[tokio::test]
|
||||
async fn local_summary_is_loopback_only_and_non_sensitive() {
|
||||
let _serial = SESSION_REGISTRY_LOCK.lock().await;
|
||||
|
||||
@@ -1460,6 +1460,23 @@ async fn serve_session(
|
||||
let client_label = endpoint::peer_fingerprint(&conn)
|
||||
.map(|fp| fingerprint_hex(&fp)[..12].to_string())
|
||||
.unwrap_or_else(|| conn.remote_address().ip().to_string());
|
||||
// The client's DISPLAY name for the status surface (local summary → the tray's connect
|
||||
// toast): the trust store's operator-curated name for this fingerprint first (a rename at
|
||||
// approval time wins over whatever the device calls itself), else the sanitized Hello name.
|
||||
// `None` (nameless knock from an old client / Android) keeps the summary name-free.
|
||||
let client_name = endpoint::peer_fingerprint(&conn)
|
||||
.map(|fp| fingerprint_hex(&fp))
|
||||
.and_then(|fp_hex| {
|
||||
np.list()
|
||||
.into_iter()
|
||||
.find(|c| c.fingerprint == fp_hex)
|
||||
.map(|c| c.name)
|
||||
.or_else(|| {
|
||||
let raw = hello.name.as_deref().unwrap_or("").trim();
|
||||
(!raw.is_empty())
|
||||
.then(|| crate::native_pairing::sanitize_device_name(raw, &fp_hex))
|
||||
})
|
||||
});
|
||||
// Transition-trace handles for the data plane (P0.1): the punch stamp + the virtual-stream
|
||||
// stages ride the same per-session trace; resizes write their totals into the shared slot.
|
||||
let bringup_dp = bringup.clone();
|
||||
@@ -1556,6 +1573,7 @@ async fn serve_session(
|
||||
streamed_au,
|
||||
stats: stats_dp,
|
||||
client_label,
|
||||
client_name,
|
||||
launch: launch_for_dp,
|
||||
launch_target,
|
||||
client_hdr,
|
||||
@@ -2260,6 +2278,7 @@ mod tests {
|
||||
None, // display_hdr
|
||||
0, // client_caps
|
||||
None, // launch
|
||||
None, // name
|
||||
None, // pin (TOFU)
|
||||
None, // identity (host doesn't require pairing)
|
||||
std::time::Duration::from_secs(10),
|
||||
@@ -2431,6 +2450,7 @@ mod tests {
|
||||
None, // display_hdr
|
||||
0, // client_caps
|
||||
None, // launch
|
||||
None, // name: absent on purpose — this test asserts the fingerprint-derived label
|
||||
None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client
|
||||
Some((cert, key)),
|
||||
std::time::Duration::from_secs(15),
|
||||
@@ -2499,6 +2519,7 @@ mod tests {
|
||||
None, // display_hdr
|
||||
0, // client_caps
|
||||
None, // launch
|
||||
None, // name
|
||||
None,
|
||||
None,
|
||||
timeout
|
||||
@@ -2529,6 +2550,7 @@ mod tests {
|
||||
None, // display_hdr
|
||||
0, // client_caps
|
||||
None, // launch
|
||||
None, // name
|
||||
Some(host_fp),
|
||||
Some((cert.clone(), key.clone())),
|
||||
timeout,
|
||||
|
||||
@@ -999,6 +999,9 @@ pub(super) struct SessionContext {
|
||||
/// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on
|
||||
/// the first armed stats registration.
|
||||
pub(super) client_label: String,
|
||||
/// The client's display name (trust-store name, else sanitized Hello name; `None` = nameless
|
||||
/// knock) — published to the live-session registry for the local summary's connect toast.
|
||||
pub(super) client_name: Option<String>,
|
||||
/// The session's requested launch, `None` = none. On Windows the store-qualified library id
|
||||
/// (spawned into the interactive user session once capture is live); on other hosts the shell
|
||||
/// command already resolved against the host's own library — nested into gamescope's bare spawn
|
||||
@@ -1140,6 +1143,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
streamed_au,
|
||||
stats,
|
||||
client_label,
|
||||
client_name,
|
||||
launch,
|
||||
launch_target,
|
||||
client_hdr,
|
||||
@@ -1480,6 +1484,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
quit: quit.clone(),
|
||||
force_idr: force_idr.clone(),
|
||||
client: client_label,
|
||||
client_name,
|
||||
hdr: plan.hdr,
|
||||
ttff_ms: bringup.total_slot(),
|
||||
last_resize_ms: resize_ms.clone(),
|
||||
|
||||
@@ -42,6 +42,9 @@ struct LiveSession {
|
||||
force_idr: Arc<AtomicBool>,
|
||||
/// Short client label (cert-fingerprint prefix / peer IP) — carried on the lifecycle events.
|
||||
client: String,
|
||||
/// The client's display name (trust-store name, else its sanitized Hello name) — what the
|
||||
/// local summary's connect toast shows. `None` for a nameless knock (old client / Android).
|
||||
client_name: Option<String>,
|
||||
/// Whether the session negotiated HDR — carried on the lifecycle events.
|
||||
hdr: bool,
|
||||
/// Completed bring-up total (hello → first packet), ms; 0 until the first packet left. Written
|
||||
@@ -55,13 +58,16 @@ struct LiveSession {
|
||||
}
|
||||
|
||||
/// A resolved read of one live session, for the `/status` view.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone)]
|
||||
pub struct SessionSnapshot {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub bitrate_kbps: u32,
|
||||
pub codec: Codec,
|
||||
/// The client's display name (trust-store name, else its sanitized Hello name); `None` for a
|
||||
/// nameless client.
|
||||
pub client_name: Option<String>,
|
||||
/// Bring-up total (hello → first packet), ms; 0 while still bringing up (latency plan P0.1).
|
||||
pub time_to_first_frame_ms: u32,
|
||||
/// Most recent mid-stream resize total, ms; 0 = no resize this session.
|
||||
@@ -106,6 +112,8 @@ pub struct Registration {
|
||||
pub force_idr: Arc<AtomicBool>,
|
||||
/// Short client label (cert-fingerprint prefix / peer IP).
|
||||
pub client: String,
|
||||
/// The client's display name, when it has one (trust-store name, else sanitized Hello name).
|
||||
pub client_name: Option<String>,
|
||||
pub hdr: bool,
|
||||
/// Bring-up total slot (hello → first packet), ms.
|
||||
pub ttff_ms: Arc<AtomicU32>,
|
||||
@@ -127,6 +135,7 @@ pub fn register(reg: Registration) -> LiveSessionGuard {
|
||||
quit,
|
||||
force_idr,
|
||||
client,
|
||||
client_name,
|
||||
hdr,
|
||||
ttff_ms,
|
||||
last_resize_ms,
|
||||
@@ -142,6 +151,7 @@ pub fn register(reg: Registration) -> LiveSessionGuard {
|
||||
quit,
|
||||
force_idr,
|
||||
client,
|
||||
client_name,
|
||||
hdr,
|
||||
ttff_ms,
|
||||
last_resize_ms,
|
||||
@@ -197,6 +207,7 @@ pub fn snapshot() -> Vec<SessionSnapshot> {
|
||||
fps,
|
||||
bitrate_kbps: s.bitrate_kbps.load(Ordering::Relaxed),
|
||||
codec: s.codec,
|
||||
client_name: s.client_name.clone(),
|
||||
time_to_first_frame_ms: s.ttff_ms.load(Ordering::Relaxed),
|
||||
last_resize_ms: s.last_resize_ms.load(Ordering::Relaxed),
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Security", # CreateMutexW's SECURITY_ATTRIBUTES parameter type
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Registry", # AppsUseLightTheme — glyph color must match the themed menu
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
@@ -25,6 +25,24 @@ fn main() {
|
||||
// Task Manager / Explorer identity (matches the host's "Punktfunk Host").
|
||||
res.set("FileDescription", "Punktfunk Tray");
|
||||
res.set("ProductName", "Punktfunk");
|
||||
// PerMonitorV2: without a DPI manifest the process is virtualized and its menu
|
||||
// GDI-stretched — visibly blurry on any scaled display (most Windows 11 laptops).
|
||||
res.set_manifest(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10/11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>"#,
|
||||
);
|
||||
res.compile().expect("embed windows icon resources");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ mod linux;
|
||||
mod status;
|
||||
#[cfg(windows)]
|
||||
mod win;
|
||||
#[cfg(windows)]
|
||||
mod win_theme;
|
||||
|
||||
/// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the
|
||||
/// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is
|
||||
|
||||
@@ -29,6 +29,10 @@ pub struct Summary {
|
||||
pub video_streaming: bool,
|
||||
pub audio_streaming: bool,
|
||||
pub session: Option<SessionInfo>,
|
||||
/// Display name of the streaming client (trust-store name, else the device's own), for the
|
||||
/// connect toast. `#[serde(default)]`: absent when idle, nameless, or from an older host.
|
||||
#[serde(default)]
|
||||
pub client_name: Option<String>,
|
||||
pub paired_clients: u32,
|
||||
pub native_paired_clients: u32,
|
||||
pub pin_pending: bool,
|
||||
@@ -404,6 +408,7 @@ mod tests {
|
||||
height: 1440,
|
||||
fps: 120,
|
||||
}),
|
||||
client_name: streaming.then(|| "studio-deck".into()),
|
||||
paired_clients: 1,
|
||||
native_paired_clients: 2,
|
||||
pin_pending: false,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! left admin-gated rather than DACL-opened to every local user.
|
||||
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU8, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use windows::core::{w, PCWSTR};
|
||||
@@ -17,21 +17,25 @@ use windows::Win32::Foundation::{
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::System::Threading::CreateMutexW;
|
||||
use windows::Win32::UI::HiDpi::GetSystemMetricsForDpi;
|
||||
use windows::Win32::UI::Shell::{
|
||||
ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIM_ADD,
|
||||
NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, NOTIFYICON_VERSION_4,
|
||||
SetCurrentProcessExplicitAppUserModelID, ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_INFO,
|
||||
NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIIF_LARGE_ICON, NIIF_RESPECT_QUIET_TIME, NIIF_USER,
|
||||
NIM_ADD, NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW,
|
||||
NOTIFYICON_VERSION_4,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow,
|
||||
DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadIconW, PostMessageW,
|
||||
DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadImageW, PostMessageW,
|
||||
PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow,
|
||||
SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, MF_GRAYED, MF_SEPARATOR,
|
||||
MF_STRING, MSG, SW_HIDE, SW_SHOWNORMAL, TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE,
|
||||
WM_APP, WM_CLOSE, WM_COMMAND, WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WNDCLASSW,
|
||||
WS_OVERLAPPED,
|
||||
SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, IMAGE_ICON, LR_SHARED,
|
||||
MF_GRAYED, MF_SEPARATOR, MF_STRING, MSG, SM_CXICON, SM_CXSMICON, SW_HIDE, SW_SHOWNORMAL,
|
||||
TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE, WM_APP, WM_CLOSE, WM_COMMAND,
|
||||
WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WM_SETTINGCHANGE, WNDCLASSW, WS_OVERLAPPED,
|
||||
};
|
||||
|
||||
use crate::status::{Poller, TrayStatus};
|
||||
use crate::win_theme;
|
||||
|
||||
/// Keyboard "select" on the icon (Enter/Space) — `NIN_SELECT | NINF_KEY`; the windows crate
|
||||
/// exports only NIN_SELECT.
|
||||
@@ -79,6 +83,10 @@ struct App {
|
||||
/// or falls back to showing the menu.
|
||||
web_console: AtomicBool,
|
||||
web_port: u16,
|
||||
/// Streaming edge tracker for the connect toast: 0 = no status seen yet, 1 = not streaming,
|
||||
/// 2 = streaming. The "no status yet" state keeps a tray started mid-session (sign-in while a
|
||||
/// client already streams) from firing a stale toast.
|
||||
streaming_seen: AtomicU8,
|
||||
}
|
||||
|
||||
static APP: OnceLock<App> = OnceLock::new();
|
||||
@@ -128,6 +136,19 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Toast identity: the installer registers this AUMID under Classes\AppUserModelId with
|
||||
// DisplayName "Punktfunk" + the brand IconUri (punktfunk-host.iss [Registry] — keep in sync),
|
||||
// so the connect toast is attributed to "Punktfunk" with the logo instead of a generic entry.
|
||||
// Must run before the notify icon exists. Unregistered (dev run) it degrades to the default
|
||||
// attribution, never an error.
|
||||
// SAFETY: static nul-terminated literal.
|
||||
unsafe {
|
||||
let _ = SetCurrentProcessExplicitAppUserModelID(w!("unom.punktfunk.tray"));
|
||||
}
|
||||
|
||||
// Before the first menu: opt this process's popup menus into the system dark mode.
|
||||
win_theme::init_dark_mode();
|
||||
|
||||
let host_exe = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("punktfunk-host.exe")))
|
||||
@@ -143,6 +164,7 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
||||
host_exe,
|
||||
web_console: AtomicBool::new(false), // live-probed by the poller within its first cycle
|
||||
web_port: args.web_port,
|
||||
streaming_seen: AtomicU8::new(0),
|
||||
})
|
||||
.ok()
|
||||
.expect("run() is called once");
|
||||
@@ -247,14 +269,27 @@ fn update_icon(hwnd: HWND, add: bool) -> bool {
|
||||
uCallbackMessage: WMAPP_NOTIFYCALLBACK,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: LoadIconW by ordinal from this exe's embedded resources (build.rs); the ordinal is
|
||||
// one of the ids compiled in, and a failure falls back to a null icon rather than UB.
|
||||
// Ask for the shell's small-icon size at this DPI, so LoadImageW serves the best frame of the
|
||||
// multi-size .ico instead of the 32 px default the shell then downscales (soft at 125 %+).
|
||||
// SAFETY: plain metric query; 0 (failure) falls back to the classic 16 px.
|
||||
let sm = match unsafe { GetSystemMetricsForDpi(SM_CXSMICON, win_theme::window_dpi(hwnd)) } {
|
||||
0 => 16,
|
||||
n => n,
|
||||
};
|
||||
// SAFETY: LoadImageW by ordinal from this exe's embedded resources (build.rs); the ordinal is
|
||||
// one of the ids compiled in, LR_SHARED handles are system-cached (never destroyed by us),
|
||||
// and a failure falls back to a null icon rather than UB.
|
||||
nid.hIcon = unsafe {
|
||||
LoadIconW(
|
||||
LoadImageW(
|
||||
Some(GetModuleHandleW(None).unwrap_or_default().into()),
|
||||
PCWSTR(icon_ordinal(&status) as usize as *const u16),
|
||||
IMAGE_ICON,
|
||||
sm,
|
||||
sm,
|
||||
LR_SHARED,
|
||||
)
|
||||
}
|
||||
.map(|h| HICON(h.0))
|
||||
.unwrap_or(HICON(std::ptr::null_mut()));
|
||||
// Tooltip: truncate to the szTip capacity (127 UTF-16 units + nul).
|
||||
let tip = to_wide(&status.headline());
|
||||
@@ -281,6 +316,77 @@ fn update_icon(hwnd: HWND, add: bool) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toast when a client connects (the idle → streaming edge, as seen by the poller). Windows 11
|
||||
/// renders `NIF_INFO` balloons as native toasts under the app's name — no WinRT/AUMID
|
||||
/// registration needed for a plain exe. Fired from the UI thread on WMAPP_STATUS.
|
||||
fn notify_on_connect(hwnd: HWND) {
|
||||
let status = app().status.lock().unwrap().clone();
|
||||
let now: u8 = if status.is_streaming() { 2 } else { 1 };
|
||||
// 0 = first status since launch: record only. A tray started mid-session (sign-in while a
|
||||
// client already streams) must not fire a stale toast.
|
||||
let was = app().streaming_seen.swap(now, Ordering::SeqCst);
|
||||
if !(was == 1 && now == 2) {
|
||||
return;
|
||||
}
|
||||
let (title, body) = match &status {
|
||||
TrayStatus::Running(s) => (
|
||||
// The host resolves the name from its trust store, else the device's own Hello name;
|
||||
// absent (older host / nameless client) the toast stays generic.
|
||||
match &s.client_name {
|
||||
Some(name) => format!("{name} connected"),
|
||||
None => "Client connected".to_string(),
|
||||
},
|
||||
match &s.session {
|
||||
Some(sess) => format!(
|
||||
"Streaming {}×{} @ {} fps",
|
||||
sess.width, sess.height, sess.fps
|
||||
),
|
||||
None => "A client is streaming from this host.".to_string(),
|
||||
},
|
||||
),
|
||||
_ => return, // is_streaming() implies Running; stay defensive
|
||||
};
|
||||
let mut nid = NOTIFYICONDATAW {
|
||||
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
|
||||
hWnd: hwnd,
|
||||
uID: 1,
|
||||
uFlags: NIF_INFO, // NIM_MODIFY touches only the balloon fields; icon/tip stay as-is
|
||||
dwInfoFlags: NIIF_USER | NIIF_LARGE_ICON | NIIF_RESPECT_QUIET_TIME,
|
||||
..Default::default()
|
||||
};
|
||||
let title = to_wide(&title);
|
||||
let n = title.len().min(nid.szInfoTitle.len() - 1);
|
||||
nid.szInfoTitle[..n].copy_from_slice(&title[..n]);
|
||||
let body = to_wide(&body);
|
||||
let n = body.len().min(nid.szInfo.len() - 1);
|
||||
nid.szInfo[..n].copy_from_slice(&body[..n]);
|
||||
// SAFETY: plain metric query; 0 (failure) falls back to the classic 32 px.
|
||||
let sm = match unsafe { GetSystemMetricsForDpi(SM_CXICON, win_theme::window_dpi(hwnd)) } {
|
||||
0 => 32,
|
||||
n => n,
|
||||
};
|
||||
// The brand logo (ordinal 1, punktfunk.ico) at full toast size — the toast is Punktfunk
|
||||
// speaking, not a status glyph. SAFETY: LoadImageW by ordinal from this exe's embedded
|
||||
// resources; LR_SHARED handles are system-cached (never destroyed by us), and on failure the
|
||||
// toast just shows no image.
|
||||
nid.hBalloonIcon = unsafe {
|
||||
LoadImageW(
|
||||
Some(GetModuleHandleW(None).unwrap_or_default().into()),
|
||||
PCWSTR(1usize as *const u16),
|
||||
IMAGE_ICON,
|
||||
sm,
|
||||
sm,
|
||||
LR_SHARED,
|
||||
)
|
||||
}
|
||||
.map(|h| HICON(h.0))
|
||||
.unwrap_or(HICON(std::ptr::null_mut()));
|
||||
// SAFETY: nid fully initialized with a correct cbSize; NIM_MODIFY only reads it.
|
||||
unsafe {
|
||||
let _ = Shell_NotifyIconW(NIM_MODIFY, &nid);
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-click menu, rebuilt from the live status each time.
|
||||
fn show_menu(hwnd: HWND) {
|
||||
let status = app().status.lock().unwrap().clone();
|
||||
@@ -296,7 +402,10 @@ fn show_menu(hwnd: HWND) {
|
||||
// below (SetForegroundWindow before, WM_NULL after) per the Shell_NotifyIcon docs.
|
||||
unsafe {
|
||||
let Ok(menu) = CreatePopupMenu() else { return };
|
||||
let add = |id: usize, text: &str, grayed: bool| {
|
||||
// Glyph bitmaps: the menu references but does not own them; the guard deletes them after
|
||||
// DestroyMenu below.
|
||||
let mut glyphs = win_theme::MenuGlyphs::new(hwnd);
|
||||
let mut add = |id: usize, text: &str, grayed: bool, glyph: Option<u16>| {
|
||||
let wide = to_wide(text);
|
||||
let flags = if grayed {
|
||||
MF_STRING | MF_GRAYED
|
||||
@@ -304,41 +413,91 @@ fn show_menu(hwnd: HWND) {
|
||||
MF_STRING
|
||||
};
|
||||
let _ = AppendMenuW(menu, flags, id, PCWSTR(wide.as_ptr()));
|
||||
if let Some(g) = glyph {
|
||||
glyphs.set(menu, id, g);
|
||||
}
|
||||
};
|
||||
add(IDM_HEADER, &status.headline(), true);
|
||||
add(IDM_HEADER, &status.headline(), true, None);
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
// The console entry is ALWAYS here — it is the reason most people open this menu, and
|
||||
// left-clicking the icon is not a discoverable substitute. When the loopback probe says
|
||||
// the console isn't answering the label says so, rather than the entry vanishing.
|
||||
if app().web_console.load(Ordering::SeqCst) {
|
||||
add(IDM_OPEN_WEB, "Open web console", false);
|
||||
add(
|
||||
IDM_OPEN_WEB,
|
||||
"Open web console",
|
||||
false,
|
||||
Some(win_theme::GLYPH_GLOBE),
|
||||
);
|
||||
} else {
|
||||
add(IDM_OPEN_WEB, "Open web console (not responding)", false);
|
||||
add(
|
||||
IDM_OPEN_WEB,
|
||||
"Open web console (not responding)",
|
||||
false,
|
||||
Some(win_theme::GLYPH_GLOBE),
|
||||
);
|
||||
}
|
||||
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
||||
if status.pairing_attention() {
|
||||
add(IDM_PAIRING, "Approve pairing request…", false);
|
||||
add(
|
||||
IDM_PAIRING,
|
||||
"Approve pairing request…",
|
||||
false,
|
||||
Some(win_theme::GLYPH_APPROVE),
|
||||
);
|
||||
}
|
||||
match status.kept_displays() {
|
||||
0 => {}
|
||||
1 => add(IDM_DISPLAYS, "Release kept display…", false),
|
||||
n => add(IDM_DISPLAYS, &format!("Release {n} kept displays…"), false),
|
||||
1 => add(
|
||||
IDM_DISPLAYS,
|
||||
"Release kept display…",
|
||||
false,
|
||||
Some(win_theme::GLYPH_DISPLAY),
|
||||
),
|
||||
n => add(
|
||||
IDM_DISPLAYS,
|
||||
&format!("Release {n} kept displays…"),
|
||||
false,
|
||||
Some(win_theme::GLYPH_DISPLAY),
|
||||
),
|
||||
}
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
// The service actions all carry the shield: Explorer's convention for "selecting this
|
||||
// opens a UAC prompt" (each runs `punktfunk-host.exe service …` elevated).
|
||||
if can_control {
|
||||
if startable {
|
||||
add(IDM_START, "Start host", false);
|
||||
add(
|
||||
IDM_START,
|
||||
"Start host",
|
||||
false,
|
||||
Some(win_theme::GLYPH_SHIELD),
|
||||
);
|
||||
}
|
||||
if running {
|
||||
add(IDM_STOP, "Stop host", false);
|
||||
add(IDM_RESTART, "Restart host", false);
|
||||
add(IDM_STOP, "Stop host", false, Some(win_theme::GLYPH_SHIELD));
|
||||
add(
|
||||
IDM_RESTART,
|
||||
"Restart host",
|
||||
false,
|
||||
Some(win_theme::GLYPH_SHIELD),
|
||||
);
|
||||
} else if matches!(status, TrayStatus::Error(_)) {
|
||||
add(IDM_RESTART, "Restart host", false);
|
||||
add(
|
||||
IDM_RESTART,
|
||||
"Restart host",
|
||||
false,
|
||||
Some(win_theme::GLYPH_SHIELD),
|
||||
);
|
||||
}
|
||||
}
|
||||
add(IDM_LOGS, "Open logs folder", false);
|
||||
add(
|
||||
IDM_LOGS,
|
||||
"Open logs folder",
|
||||
false,
|
||||
Some(win_theme::GLYPH_FOLDER),
|
||||
);
|
||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||
add(IDM_EXIT, "Exit tray", false);
|
||||
add(IDM_EXIT, "Exit tray", false, Some(win_theme::GLYPH_POWER));
|
||||
|
||||
let mut pt = Default::default();
|
||||
let _ = GetCursorPos(&mut pt);
|
||||
@@ -425,8 +584,18 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
||||
match msg {
|
||||
WMAPP_STATUS => {
|
||||
update_icon(hwnd, false);
|
||||
notify_on_connect(hwnd);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_SETTINGCHANGE => {
|
||||
// Light/dark flipped while running: drop the cached menu theme so the next popup
|
||||
// renders in the new mode.
|
||||
if win_theme::is_color_scheme_change(lparam) {
|
||||
win_theme::on_color_scheme_changed();
|
||||
}
|
||||
// SAFETY: setting broadcasts still get default processing.
|
||||
unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
|
||||
}
|
||||
WMAPP_NOTIFYCALLBACK => {
|
||||
// NOTIFYICON_VERSION_4: LOWORD(lParam) is the event.
|
||||
match (lparam.0 as u32) & 0xffff {
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Windows 11 fit-and-finish for the tray's Win32 UI.
|
||||
//!
|
||||
//! Three gaps make a stock Win32 tray read as "Windows 10 app" on Windows 11, and this module
|
||||
//! closes them without pulling a UI framework into a ~2 MB always-resident helper (there is no
|
||||
//! WinUI/App-SDK tray API — even fully modern apps register the icon via `Shell_NotifyIconW`;
|
||||
//! only the popup differs):
|
||||
//!
|
||||
//! * **Dark mode.** Popup menus never got a documented dark-mode opt-in; every app that ships
|
||||
//! one (Explorer, PowerToys, Notepad++) calls the same undocumented uxtheme ordinals —
|
||||
//! `SetPreferredAppMode` (135) and `FlushMenuThemes` (136), stable since Windows 10 1809.
|
||||
//! Every call here degrades to the classic light menu when an ordinal is missing.
|
||||
//! * **Glyphs.** Menu items get "Segoe Fluent Icons" glyphs ("Segoe MDL2 Assets" on Windows 10 —
|
||||
//! the codepoints are shared), rendered into premultiplied ARGB bitmaps that the themed menu
|
||||
//! composites correctly in both light and dark. Rounded corners come free — Windows 11 rounds
|
||||
//! every popup menu.
|
||||
//! * **DPI.** Sizing helpers for the PerMonitorV2 manifest build.rs embeds — an unmanifested exe
|
||||
//! is DPI-virtualized and its menu GDI-stretched (visibly blurry on any scaled display).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use windows::core::{w, PCSTR, PCWSTR};
|
||||
use windows::Win32::Foundation::{COLORREF, HWND, LPARAM, RECT};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
CreateCompatibleDC, CreateDIBSection, CreateFontIndirectW, DeleteDC, DeleteObject, DrawTextW,
|
||||
GdiFlush, GetTextFaceW, SelectObject, SetBkMode, SetTextColor, ANTIALIASED_QUALITY, BITMAPINFO,
|
||||
BITMAPINFOHEADER, BI_RGB, DEFAULT_CHARSET, DIB_RGB_COLORS, DT_CENTER, DT_NOCLIP, DT_SINGLELINE,
|
||||
DT_VCENTER, HBITMAP, HFONT, LOGFONTW, TRANSPARENT,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::{
|
||||
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||
};
|
||||
use windows::Win32::System::Registry::{RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD};
|
||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
SetMenuItemInfoW, HMENU, MENUITEMINFOW, MIIM_BITMAP,
|
||||
};
|
||||
|
||||
// Glyph codepoints, identical in Segoe Fluent Icons and Segoe MDL2 Assets.
|
||||
pub const GLYPH_GLOBE: u16 = 0xE774; // Globe — open web console
|
||||
pub const GLYPH_APPROVE: u16 = 0xE73E; // CheckMark — approve pairing
|
||||
pub const GLYPH_DISPLAY: u16 = 0xE7F4; // TVMonitor — kept displays
|
||||
pub const GLYPH_SHIELD: u16 = 0xE7EF; // Admin — marks the UAC-elevated service actions
|
||||
pub const GLYPH_FOLDER: u16 = 0xE8B7; // Folder — open logs
|
||||
pub const GLYPH_POWER: u16 = 0xE7E8; // PowerButton — exit tray
|
||||
|
||||
type FnSetPreferredAppMode = unsafe extern "system" fn(mode: i32) -> i32;
|
||||
type FnVoid = unsafe extern "system" fn();
|
||||
|
||||
/// The undocumented uxtheme dark-mode entry points, resolved once by ordinal. Any of them may be
|
||||
/// absent (pre-1809, or a future Windows that removes them) — each caller checks.
|
||||
struct UxTheme {
|
||||
set_preferred_app_mode: Option<FnSetPreferredAppMode>,
|
||||
refresh_immersive_colors: Option<FnVoid>,
|
||||
flush_menu_themes: Option<FnVoid>,
|
||||
}
|
||||
|
||||
static UXTHEME: OnceLock<UxTheme> = OnceLock::new();
|
||||
|
||||
fn uxtheme() -> &'static UxTheme {
|
||||
UXTHEME.get_or_init(|| {
|
||||
let none = UxTheme {
|
||||
set_preferred_app_mode: None,
|
||||
refresh_immersive_colors: None,
|
||||
flush_menu_themes: None,
|
||||
};
|
||||
// SAFETY: system32-only load of a Windows-supplied DLL, then ordinal lookups that return
|
||||
// None when absent. The transmutes assert the known signatures of ordinals 135/104/136
|
||||
// (unchanged since 1809; on 1809 ordinal 135 is `AllowDarkModeForApp(bool)`, for which
|
||||
// the later `SetPreferredAppMode(1)` call means the same "allow dark").
|
||||
unsafe {
|
||||
let Ok(lib) = LoadLibraryExW(w!("uxtheme.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32)
|
||||
else {
|
||||
return none;
|
||||
};
|
||||
let ord = |n: u16| GetProcAddress(lib, PCSTR(n as usize as *const u8));
|
||||
UxTheme {
|
||||
set_preferred_app_mode: ord(135).map(|f| std::mem::transmute(f)),
|
||||
refresh_immersive_colors: ord(104).map(|f| std::mem::transmute(f)),
|
||||
flush_menu_themes: ord(136).map(|f| std::mem::transmute(f)),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Opt this process's menus into the system app theme ("allow dark", not "force dark" — the user
|
||||
/// setting decides). Call once before the first menu.
|
||||
pub fn init_dark_mode() {
|
||||
let ux = uxtheme();
|
||||
if let Some(set) = ux.set_preferred_app_mode {
|
||||
// SAFETY: resolved above with this signature; 1 = AllowDark.
|
||||
unsafe { set(1) };
|
||||
}
|
||||
if let Some(refresh) = ux.refresh_immersive_colors {
|
||||
// SAFETY: resolved above; takes no arguments.
|
||||
unsafe { refresh() };
|
||||
}
|
||||
}
|
||||
|
||||
/// The system theme flipped while we're running — re-read the immersive colors and drop the
|
||||
/// cached menu theme so the next popup renders in the new mode.
|
||||
pub fn on_color_scheme_changed() {
|
||||
let ux = uxtheme();
|
||||
if let Some(refresh) = ux.refresh_immersive_colors {
|
||||
// SAFETY: resolved in uxtheme() with this signature.
|
||||
unsafe { refresh() };
|
||||
}
|
||||
if let Some(flush) = ux.flush_menu_themes {
|
||||
// SAFETY: resolved in uxtheme() with this signature.
|
||||
unsafe { flush() };
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this WM_SETTINGCHANGE the "ImmersiveColorSet" broadcast (light/dark toggled)?
|
||||
pub fn is_color_scheme_change(lparam: LPARAM) -> bool {
|
||||
const NAME: &[u16] = &[
|
||||
b'I' as u16,
|
||||
b'm' as u16,
|
||||
b'm' as u16,
|
||||
b'e' as u16,
|
||||
b'r' as u16,
|
||||
b's' as u16,
|
||||
b'i' as u16,
|
||||
b'v' as u16,
|
||||
b'e' as u16,
|
||||
b'C' as u16,
|
||||
b'o' as u16,
|
||||
b'l' as u16,
|
||||
b'o' as u16,
|
||||
b'r' as u16,
|
||||
b'S' as u16,
|
||||
b'e' as u16,
|
||||
b't' as u16,
|
||||
];
|
||||
let p = lparam.0 as *const u16;
|
||||
if p.is_null() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: WM_SETTINGCHANGE documents lParam as a nul-terminated string (or null, handled
|
||||
// above); the read is bounded to the compared length + terminator.
|
||||
unsafe {
|
||||
for (i, &want) in NAME.iter().enumerate() {
|
||||
if *p.add(i) != want {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
*p.add(NAME.len()) == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Apps dark theme active? (`AppsUseLightTheme` = 0). Menus under "allow dark" follow this same
|
||||
/// value, so glyph colors chosen from it always match the menu background.
|
||||
pub fn apps_use_dark() -> bool {
|
||||
let mut data: u32 = 1;
|
||||
let mut size = std::mem::size_of::<u32>() as u32;
|
||||
// SAFETY: RegGetValueW writes at most `size` bytes into `data`; missing value is an error we
|
||||
// treat as light (the OS default).
|
||||
let r = unsafe {
|
||||
RegGetValueW(
|
||||
HKEY_CURRENT_USER,
|
||||
w!(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"),
|
||||
w!("AppsUseLightTheme"),
|
||||
RRF_RT_REG_DWORD,
|
||||
None,
|
||||
Some(&mut data as *mut u32 as *mut _),
|
||||
Some(&mut size),
|
||||
)
|
||||
};
|
||||
r.is_ok() && data == 0
|
||||
}
|
||||
|
||||
/// The window's monitor DPI (96 fallback for an invalid handle).
|
||||
pub fn window_dpi(hwnd: HWND) -> u32 {
|
||||
// SAFETY: valid on any window handle; returns 0 only for an invalid one.
|
||||
match unsafe { GetDpiForWindow(hwnd) } {
|
||||
0 => 96,
|
||||
d => d,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-popup glyph bitmaps: rendered at the menu's DPI in the current theme's text color,
|
||||
/// attached via `hbmpItem`, and deleted on drop — `DestroyMenu` does not free item bitmaps.
|
||||
pub struct MenuGlyphs {
|
||||
size: i32,
|
||||
color: u32, // 0x00RRGGBB
|
||||
face: Option<PCWSTR>,
|
||||
bitmaps: Vec<HBITMAP>,
|
||||
}
|
||||
|
||||
impl MenuGlyphs {
|
||||
pub fn new(hwnd: HWND) -> Self {
|
||||
let dpi = window_dpi(hwnd) as i32;
|
||||
MenuGlyphs {
|
||||
size: (16 * dpi + 48) / 96,
|
||||
// Match the themed menu's text: near-white on dark, near-black on light.
|
||||
color: if apps_use_dark() {
|
||||
0x00EBEBEB
|
||||
} else {
|
||||
0x00202020
|
||||
},
|
||||
face: resolve_icon_font(),
|
||||
bitmaps: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `glyph` and attach it to menu item `id`. Silently a no-op when the icon font is
|
||||
/// missing or GDI fails — the menu is fully usable without bitmaps.
|
||||
pub fn set(&mut self, menu: HMENU, id: usize, glyph: u16) {
|
||||
let Some(face) = self.face else { return };
|
||||
let Some(bmp) = glyph_bitmap(face, glyph, self.size, self.color) else {
|
||||
return;
|
||||
};
|
||||
let mii = MENUITEMINFOW {
|
||||
cbSize: std::mem::size_of::<MENUITEMINFOW>() as u32,
|
||||
fMask: MIIM_BITMAP,
|
||||
hbmpItem: bmp,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: mii is fully initialized with a correct cbSize; menu/id belong to the caller.
|
||||
if unsafe { SetMenuItemInfoW(menu, id as u32, false, &mii) }.is_ok() {
|
||||
self.bitmaps.push(bmp);
|
||||
} else {
|
||||
// SAFETY: created above, attached nowhere.
|
||||
unsafe {
|
||||
let _ = DeleteObject(bmp.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MenuGlyphs {
|
||||
fn drop(&mut self) {
|
||||
for bmp in self.bitmaps.drain(..) {
|
||||
// SAFETY: bitmaps created by glyph_bitmap; the menu that referenced them is destroyed
|
||||
// before the guard goes out of scope in show_menu.
|
||||
unsafe {
|
||||
let _ = DeleteObject(bmp.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed system icon font: Fluent (Windows 11) → MDL2 (Windows 10) → None (skip glyphs).
|
||||
fn resolve_icon_font() -> Option<PCWSTR> {
|
||||
[w!("Segoe Fluent Icons"), w!("Segoe MDL2 Assets")]
|
||||
.into_iter()
|
||||
.find(|f| font_exists(*f))
|
||||
}
|
||||
|
||||
/// GDI never fails font creation — it substitutes. Detect a missing face by asking the DC what
|
||||
/// it actually selected.
|
||||
fn font_exists(face: PCWSTR) -> bool {
|
||||
// SAFETY: local DC/font pair created and freed here; GetTextFaceW writes a bounded,
|
||||
// nul-terminated name into buf.
|
||||
unsafe {
|
||||
let dc = CreateCompatibleDC(None);
|
||||
if dc.is_invalid() {
|
||||
return false;
|
||||
}
|
||||
let font = create_font(face, 16);
|
||||
let old = SelectObject(dc, font.into());
|
||||
let mut buf = [0u16; 64];
|
||||
let n = GetTextFaceW(dc, Some(&mut buf)) as usize;
|
||||
SelectObject(dc, old);
|
||||
let _ = DeleteObject(font.into());
|
||||
let _ = DeleteDC(dc);
|
||||
let got = &buf[..n.min(buf.len())];
|
||||
let got = got.strip_suffix(&[0]).unwrap_or(got);
|
||||
n > 0 && got == face.as_wide()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_font(face: PCWSTR, height: i32) -> HFONT {
|
||||
let mut lf = LOGFONTW {
|
||||
lfHeight: -height, // negative = character height; MDL2/Fluent glyphs fill the em square
|
||||
lfWeight: 400,
|
||||
lfCharSet: DEFAULT_CHARSET,
|
||||
// Grayscale AA, not ClearType — the alpha pass below reads coverage from one channel and
|
||||
// subpixel rendering would leave color fringes.
|
||||
lfQuality: ANTIALIASED_QUALITY,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: both candidate face literals fit LF_FACESIZE (32) incl. nul; CreateFontIndirectW
|
||||
// copies the struct.
|
||||
unsafe {
|
||||
let name = face.as_wide();
|
||||
lf.lfFaceName[..name.len()].copy_from_slice(name);
|
||||
CreateFontIndirectW(&lf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one glyph, white-on-black, into a 32-bit top-down DIB, then convert coverage to a
|
||||
/// premultiplied-alpha bitmap in `color` — the format themed menus composite without artifacts.
|
||||
fn glyph_bitmap(face: PCWSTR, glyph: u16, size: i32, color: u32) -> Option<HBITMAP> {
|
||||
// SAFETY: standard GDI render-to-memory-DIB. Every handle created here is released here (the
|
||||
// returned bitmap by MenuGlyphs::drop), GdiFlush completes pending GDI writes before the bits
|
||||
// are read, and the pixel loop stays inside the size*size allocation CreateDIBSection made.
|
||||
unsafe {
|
||||
let dc = CreateCompatibleDC(None);
|
||||
if dc.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
let bi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: size,
|
||||
biHeight: -size, // top-down
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB.0,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut bits: *mut core::ffi::c_void = std::ptr::null_mut();
|
||||
let Ok(bmp) = CreateDIBSection(Some(dc), &bi, DIB_RGB_COLORS, &mut bits, None, 0) else {
|
||||
let _ = DeleteDC(dc);
|
||||
return None;
|
||||
};
|
||||
let font = create_font(face, size);
|
||||
let old_bmp = SelectObject(dc, bmp.into());
|
||||
let old_font = SelectObject(dc, font.into());
|
||||
|
||||
std::ptr::write_bytes(bits as *mut u8, 0, (size * size * 4) as usize);
|
||||
SetTextColor(dc, COLORREF(0x00FF_FFFF));
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
let mut text = [glyph];
|
||||
let mut rect = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: size,
|
||||
bottom: size,
|
||||
};
|
||||
DrawTextW(
|
||||
dc,
|
||||
&mut text,
|
||||
&mut rect,
|
||||
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP,
|
||||
);
|
||||
let _ = GdiFlush();
|
||||
|
||||
let px = bits as *mut u32;
|
||||
let (r, g, b) = ((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF);
|
||||
for i in 0..(size * size) as usize {
|
||||
let a = *px.add(i) & 0xFF; // white-on-black: any channel is the coverage
|
||||
*px.add(i) = (a << 24) | ((r * a / 255) << 16) | ((g * a / 255) << 8) | (b * a / 255);
|
||||
}
|
||||
|
||||
SelectObject(dc, old_font);
|
||||
SelectObject(dc, old_bmp);
|
||||
let _ = DeleteObject(font.into());
|
||||
let _ = DeleteDC(dc);
|
||||
Some(bmp)
|
||||
}
|
||||
}
|
||||
+5476
-62
File diff suppressed because it is too large
Load Diff
@@ -252,6 +252,16 @@ Source: "{#VkLayerDir}\pf_vkhdr_layer.json"; DestDir: "{app}\vklayer"; Flags: ig
|
||||
; with the app). Operators who moved --mgmt-bind can append --mgmt-addr/--mgmt-port here.
|
||||
Root: HKLM64; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
|
||||
ValueName: "PunktfunkTray"; ValueData: """{app}\punktfunk-tray.exe"""; Flags: uninsdeletevalue; Tasks: trayicon
|
||||
; Toast identity for the tray's notifications ("client connected"). The tray process tags itself
|
||||
; with this AppUserModelID (win.rs TRAY_AUMID — keep in sync), and this registration is what makes
|
||||
; Windows 11 attribute its toasts as "Punktfunk" with the brand icon instead of a generic entry —
|
||||
; the same Classes\AppUserModelId mechanism the Windows App SDK uses for unpackaged apps. No Start
|
||||
; menu shortcut needed. Installed unconditionally (like the tray exe itself): the keys are inert
|
||||
; without the tray running.
|
||||
Root: HKLM64; Subkey: "SOFTWARE\Classes\AppUserModelId\unom.punktfunk.tray"; ValueType: string; \
|
||||
ValueName: "DisplayName"; ValueData: "Punktfunk"; Flags: uninsdeletekey
|
||||
Root: HKLM64; Subkey: "SOFTWARE\Classes\AppUserModelId\unom.punktfunk.tray"; ValueType: string; \
|
||||
ValueName: "IconUri"; ValueData: "{app}\punktfunk.ico"
|
||||
; Put {app} on the MACHINE PATH so `punktfunk-host plugins add …` / `punktfunk-host service …` are
|
||||
; runnable by name. Appended to the existing value ({olddata}) and guarded by PathNeedsAdd so a
|
||||
; repair/upgrade never appends a duplicate. Deliberately NOT `uninsdeletevalue` — that would delete
|
||||
|
||||
@@ -63,8 +63,8 @@ export type LaunchSpec = { readonly "kind": string, readonly "value": string }
|
||||
export const LaunchSpec = Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session." })
|
||||
export type LayoutMode = "auto-row" | "manual"
|
||||
export const LayoutMode = Schema.Literals(["auto-row", "manual"]).annotate({ "description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage." })
|
||||
export type LocalSummary = { readonly "audio_streaming": boolean, readonly "conflicts"?: ReadonlyArray<string>, readonly "games"?: ReadonlyArray<string>, readonly "kept_displays": number, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pending_approvals": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "version": string, readonly "video_streaming": boolean }
|
||||
export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." }), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "games": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).\n\nThe countdown form is the one that matters: it means the game's client is gone and the host\nwill end the game when the window closes — something a user at the machine should be able to\nsee (and stop) without opening the console. Empty when nothing was launched." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source." })
|
||||
export type LocalSummary = { readonly "audio_streaming": boolean, readonly "client_name"?: string | null, readonly "conflicts"?: ReadonlyArray<string>, readonly "games"?: ReadonlyArray<string>, readonly "kept_displays": number, readonly "native_paired_clients": number, readonly "paired_clients": number, readonly "pending_approvals": number, readonly "pin_pending": boolean, readonly "session"?: null | { readonly "fps": number, readonly "height": number, readonly "width": number }, readonly "version": string, readonly "video_streaming": boolean }
|
||||
export const LocalSummary = Schema.Struct({ "audio_streaming": Schema.Boolean.annotate({ "description": "True while audio is streaming on either plane (same rule as `video_streaming`)." }), "client_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Display name of the (first) streaming native client — the trust store's name for it, else\nthe name the device sent at connect. `null` when idle, for a nameless client, or for a\nGameStream session (that plane carries no device name)." })), "conflicts": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." })), "games": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).\n\nThe countdown form is the one that matters: it means the game's client is gone and the host\nwill end the game when the window closes — something a user at the machine should be able to\nsee (and stop) without opening the console. Empty when nothing was launched." })), "kept_displays": Schema.Number.annotate({ "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "native_paired_clients": Schema.Number.annotate({ "description": "Number of paired native (punktfunk/1) devices.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) GameStream client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pending_approvals": Schema.Number.annotate({ "description": "Native pairing knocks awaiting the operator's approval (count only).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a GameStream pairing handshake is parked waiting for the user's PIN." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active session: GameStream's launch (Moonlight `/launch`) when present, else the first\nlive native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "version": Schema.String.annotate({ "description": "Host version (mirrors `/health`)." }), "video_streaming": Schema.Boolean.annotate({ "description": "True while video is streaming on EITHER plane: the GameStream media pipeline, or a live\nnative (punktfunk/1) session — the default plane, invisible in the GameStream flag alone." }) }).annotate({ "description": "Non-sensitive host status for the local tray icon: counts and booleans — no PIN values, no\nfingerprints. The ONE name exposed is `client_name`, the streaming client's display label\n(deliberate loosening for the tray's \"client connected\" toast: it tells the local user who is\non their machine, which is disclosure in the user's favor — and any local process could\nalready infer a session exists from the booleans here). Served unauthenticated to LOOPBACK\npeers only (see `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on\nWindows, so the per-user tray process cannot authenticate — this narrow read-only route is\nits status source." })
|
||||
export type LogEntry = { readonly "level": string, readonly "msg": string, readonly "seq": number, readonly "target": string, readonly "ts_ms": number }
|
||||
export const LogEntry = Schema.Struct({ "level": Schema.String.annotate({ "description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`." }), "msg": Schema.String.annotate({ "description": "The formatted message, structured fields appended as `key=value`." }), "seq": Schema.Number.annotate({ "description": "Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "target": Schema.String.annotate({ "description": "The emitting module path (tracing target)." }), "ts_ms": Schema.Number.annotate({ "description": "Unix timestamp in milliseconds.", "format": "int64" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "One captured log event." })
|
||||
export type ModeConflict = "separate" | "steal" | "join" | "reject"
|
||||
@@ -1524,8 +1524,8 @@ readonly "listLibraryScanners": <Config extends OperationConfig>(options: { read
|
||||
*/
|
||||
readonly "setLibraryScanner": <Config extends OperationConfig>(id: string, options: { readonly payload: typeof SetLibraryScannerRequestJson.Encoded; readonly config?: Config | undefined }) => Effect.Effect<WithOptionalResponse<typeof SetLibraryScanner200.Type, Config>, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"SetLibraryScanner401", typeof SetLibraryScanner401.Type> | PunktfunkError<"SetLibraryScanner404", typeof SetLibraryScanner404.Type> | PunktfunkError<"SetLibraryScanner500", typeof SetLibraryScanner500.Type>>
|
||||
/**
|
||||
* Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
|
||||
* names). Unauthenticated, but served to loopback peers only.
|
||||
* Non-sensitive status (counts, booleans, and the streaming client's display name — no PIN
|
||||
* values, no fingerprints). Unauthenticated, but served to loopback peers only.
|
||||
*/
|
||||
readonly "getLocalSummary": <Config extends OperationConfig>(options: { readonly config?: Config | undefined } | undefined) => Effect.Effect<WithOptionalResponse<typeof GetLocalSummary200.Type, Config>, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLocalSummary401", typeof GetLocalSummary401.Type>>
|
||||
/**
|
||||
|
||||
@@ -100,14 +100,28 @@ export const PendingDevices: FC<{
|
||||
<TableBody>
|
||||
{rows.map((p) => (
|
||||
<TableRow className="h-18" key={p.id}>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{/* The row must keep the actions on-canvas in a portrait phone
|
||||
viewport: the name flexes and truncates (w-full + max-w-0),
|
||||
and the fingerprint/age columns collapse into a sub-line
|
||||
here below md/sm instead of widening the row past the
|
||||
screen (the table wrapper scrolls, the page doesn't — an
|
||||
off-canvas Approve button is unreachable on mobile). */}
|
||||
<TableCell className="w-full max-w-0 font-medium">
|
||||
<div className="truncate">{p.name}</div>
|
||||
<div className="truncate font-mono text-xs font-normal text-muted-foreground md:hidden">
|
||||
{p.fingerprint.slice(0, 16)}…
|
||||
<span className="ml-2 font-sans sm:hidden">
|
||||
{fmtAge(p.age_secs)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden font-mono text-xs text-muted-foreground md:table-cell">
|
||||
{p.fingerprint.slice(0, 16)}…
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<TableCell className="hidden text-xs text-muted-foreground sm:table-cell">
|
||||
{fmtAge(p.age_secs)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="whitespace-nowrap text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user