Files
plezy/lib/utils/device_identity.dart
edde746 53535e1678 fix(jellyfin): percent-encode the MediaBrowser auth header
Since real device names started reaching the header, an accented one
made login impossible: dart:io refuses header values above 0x7F, and
CFNetwork puts the raw code unit on the wire as a Latin-1 byte, which
Kestrel rejects as a malformed request with 400 before Jellyfin routes
POST /Users/AuthenticateByName.

Encode every field the way the official Jellyfin SDK does; the server
already reverses it with WebUtility.UrlDecode, so the wire value stays
pure ASCII while the device list shows the real name. Quotes, commas
and `=` no longer need stripping either. sanitizeHeaderValue, which
still guards the Plex headers, now folds Latin letters to their base
form instead of emitting bytes no transport accepts.

close #1685
2026-07-28 05:07:00 +02:00

153 lines
5.5 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:unorm_dart/unorm_dart.dart';
import 'app_logger.dart';
import 'platform_detector.dart';
/// What this install should call itself when talking to media servers and
/// companion peers: a real platform name, the hardware model, and the
/// user-facing device name (Plex dashboards show it as the "Player";
/// Jellyfin as the "Device").
class DeviceIdentity {
/// 'Android' | 'iOS' | 'tvOS' | 'macOS' | 'Windows' | 'Linux', falling back
/// to [Platform.operatingSystem] when detection fails.
final String platform;
/// Hardware model for `X-Plex-Device`, e.g. 'AFTKM' (Fire TV), 'iPhone',
/// 'Apple TV'. Null when unresolvable.
final String? deviceModel;
/// Friendly, usually user-assigned name (Settings > About > Device name on
/// Android, computer name on desktop). Null when unresolvable — callers
/// pick their own fallback. May contain characters that are not valid in
/// HTTP headers; pass through [sanitizeHeaderValue] before sending.
final String? deviceName;
final bool isTv;
const DeviceIdentity({required this.platform, this.deviceModel, this.deviceName, this.isTv = false});
}
/// Resolves the device identity once per process and memoizes it. Never
/// throws — platform-channel failures (tests, exotic platforms) degrade to
/// [Platform.operatingSystem] with null name/model.
class DeviceIdentityService {
DeviceIdentityService._();
static Future<DeviceIdentity>? _cached;
static Future<DeviceIdentity> resolve() => _cached ??= _resolve();
@visibleForTesting
static void debugOverride(DeviceIdentity? identity) {
_cached = identity == null ? null : Future.value(identity);
}
static Future<DeviceIdentity> _resolve() async {
final deviceInfo = DeviceInfoPlugin();
final isTv = TvDetectionService.isTVSync();
try {
if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
final assignedName = await TvDetectionService.getAndroidDeviceName();
return DeviceIdentity(
platform: 'Android',
deviceModel: androidInfo.model,
deviceName: assignedName ?? '${androidInfo.brand} ${androidInfo.model}',
isTv: isTv,
);
}
if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
if (TvDetectionService.isAppleTVSync()) {
return DeviceIdentity(platform: 'tvOS', deviceModel: 'Apple TV', deviceName: iosInfo.name, isTv: true);
}
return DeviceIdentity(platform: 'iOS', deviceModel: iosInfo.model, deviceName: iosInfo.name, isTv: isTv);
}
if (Platform.isMacOS) {
final macInfo = await deviceInfo.macOsInfo;
return DeviceIdentity(
platform: 'macOS',
deviceModel: macInfo.model,
deviceName: macInfo.computerName,
isTv: isTv,
);
}
if (Platform.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo;
return DeviceIdentity(
platform: 'Windows',
deviceModel: 'Windows',
deviceName: windowsInfo.computerName,
isTv: isTv,
);
}
if (Platform.isLinux) {
final host = Platform.localHostname.trim();
final name = (host.isNotEmpty && host != 'localhost') ? host : (await deviceInfo.linuxInfo).name;
return DeviceIdentity(platform: 'Linux', deviceModel: 'Linux', deviceName: name, isTv: isTv);
}
} catch (e) {
appLogger.w('DeviceIdentity: failed to resolve device info', error: e);
}
return DeviceIdentity(platform: Platform.operatingSystem, isTv: isTv);
}
}
/// Makes a free-form device name safe to send as an HTTP header value on
/// every transport Plezy uses: folds Latin letters to their base form
/// (`Bjørn PC` → `Bjorn PC`), drops whatever is still outside printable
/// ASCII, trims, and returns null when nothing usable remains.
///
/// The ASCII restriction is not cosmetic. `dart:io` rejects header values
/// containing anything above 0x7F with a `FormatException`, and CFNetwork
/// puts the raw code unit on the wire as a Latin-1 byte, which HTTP servers
/// decoding headers as UTF-8 (Kestrel, hosting Jellyfin) reject as a
/// malformed request. Headers with a documented percent-encoded wire format
/// carry the name intact instead — see `buildJellyfinAuthHeader`.
String? sanitizeHeaderValue(String? value) {
if (value == null) return null;
final buffer = StringBuffer();
for (final unit in nfd(_foldNonDecomposableLatin(value)).codeUnits) {
if (unit >= 0x20 && unit < 0x7F) buffer.writeCharCode(unit);
}
final trimmed = buffer.toString().trim();
return trimmed.isEmpty ? null : trimmed;
}
/// Latin letters NFD leaves alone because they are single code points rather
/// than base + combining mark. Without this, Nordic and Central European
/// device names lose whole letters instead of being transliterated.
const Map<String, String> _nonDecomposableLatin = {
'æ': 'ae',
'Æ': 'AE',
'œ': 'oe',
'Œ': 'OE',
'ø': 'o',
'Ø': 'O',
'ß': 'ss',
'đ': 'd',
'Đ': 'D',
'ð': 'd',
'Ð': 'D',
'þ': 'th',
'Þ': 'Th',
'ł': 'l',
'Ł': 'L',
'ħ': 'h',
'Ħ': 'H',
'ı': 'i',
'ŧ': 't',
'Ŧ': 'T',
};
final RegExp _nonDecomposableLatinPattern = RegExp('[${_nonDecomposableLatin.keys.join()}]');
String _foldNonDecomposableLatin(String value) =>
value.replaceAllMapped(_nonDecomposableLatinPattern, (match) => _nonDecomposableLatin[match[0]]!);