diff --git a/core/systems/performance/fan_profile_model.gd b/core/systems/performance/fan_profile_model.gd new file mode 100644 index 000000000..629014065 --- /dev/null +++ b/core/systems/performance/fan_profile_model.gd @@ -0,0 +1,173 @@ +extends Resource +class_name FanProfileModel +## Drafts are application preferences. PowerStation owns hardware validation and safety. +signal updated + +const API_VERSION := 2 +const MAX_PROFILE_BYTES := 65536 +var backend: Object +var snapshot: Dictionary = {} +var draft: Dictionary = {} +var error := "" +var _loaded_preferences := false + +func start(collection: Object) -> void: + backend = collection + refresh() + +func refresh() -> void: + if not backend: + snapshot = {"available": false, "fans": [], "sensors": [], "error": "PowerStation is unavailable."} + else: + var state: Variant = JSON.parse_string(backend.get_snapshot_json()) + snapshot = state if state is Dictionary else {"available": false, "fans": [], "sensors": [], "error": "Invalid fan service response."} + if compatible() and not _loaded_preferences: + _loaded_preferences = true + draft = automatic_profile(snapshot.fans) + var saved: Variant = JSON.parse_string(backend.get_saved_selection_json()) + if saved is Dictionary and saved.get("kind") == "json" and saved.get("profile") is Dictionary: + if validate_profile(saved.profile, snapshot).is_empty(): + draft = _canonical_profile(saved.profile) + else: + error = "Saved fan profile no longer matches the hardware. Select or edit a complete profile." + var saved_error: String = backend.get_last_error() + if not saved_error.is_empty(): + error = saved_error + updated.emit() + +func compatible() -> bool: + return snapshot.get("available", false) and snapshot.get("api_version", 0) == API_VERSION + +static func automatic_profile(fans: Array) -> Dictionary: + var entries: Array = [] + for fan in fans: + entries.append({"fan": fan.id, "mode": "automatic"}) + return {"name": "OpenGamepadUI", "fans": entries} + +func set_json(raw: String) -> bool: + if raw.to_utf8_buffer().size() > MAX_PROFILE_BYTES: + error = "Fan profiles must be at most 64 KiB." + updated.emit() + return false + var profile: Variant = JSON.parse_string(raw) + error = validate_profile(profile, snapshot) + if error.is_empty(): + draft = _canonical_profile(profile) + updated.emit() + return error.is_empty() + +func apply() -> bool: + error = validate_profile(draft, snapshot) + if not compatible(): + error = "PowerStation fan API v2 is required." + if not error.is_empty(): + updated.emit() + return false + var success: bool = backend.load_profile_json(JSON.stringify(draft), snapshot.get("owner", "")) + error = "" if success else str(backend.get_last_error()) + refresh() + return success + +func automatic() -> bool: + if not compatible(): + return false + # An explicit Automatic action never depends on sensor validity or a saved draft. + var previous := draft.duplicate(true) + draft = automatic_profile(snapshot.fans) + if apply(): + return true + draft = previous + updated.emit() + return false + +func load_file(file: String) -> bool: + if not compatible(): + return false + var success: bool = backend.load_profile(file, snapshot.get("owner", "")) + error = "" if success else str(backend.get_last_error()) + refresh() + return success + +func edit_fan(id: String, mode: String, percent: int = 60) -> void: + if not draft.get("fans") is Array: + return + var index := -1 + for candidate in draft.fans.size(): + if draft.fans[candidate].fan == id: + index = candidate + break + if index < 0: + return + var entry: Dictionary = {"fan": draft.fans[index].fan, "mode": mode} + if mode == "manual": + entry.percent = percent + if mode in ["software_curve", "firmware_curve"]: + # The user supplies sensor IDs and the complete curve in the JSON editor. + # Native point counts and trusted safety policy are never guessed here. + var previous: Dictionary = draft.fans[index] + entry.sensors = previous.get("sensors", []).duplicate() + entry.curve = previous.get("curve", []).duplicate(true) + draft.fans[index] = entry + error = "" + updated.emit() + +static func validate_profile(profile: Variant, state: Dictionary) -> String: + if not profile is Dictionary or profile.size() != 2 or not profile.get("name") is String or str(profile.name).strip_edges().is_empty() or not profile.get("fans") is Array: + return "Use a profile name and a fans array." + var fans: Array = state.get("fans", []) + if fans.is_empty() or profile.fans.size() != fans.size(): + return "A profile must contain every available fan exactly once." + var known: Dictionary = {} + for fan in fans: + known[fan.id] = fan + var sensors: Array = [] + for sensor in state.get("sensors", []): + sensors.append(sensor.id) + var seen: Array = [] + for entry in profile.fans: + if not entry is Dictionary or not entry.get("fan") is String or not known.has(entry.fan) or entry.fan in seen: + return "Unknown or duplicate fan ID." + seen.append(entry.fan) + if not entry.get("mode") is String or entry.mode not in known[entry.fan].available_modes: + return "Select a mode advertised by this fan." + var fields: Array = ["fan", "mode"] + match entry.mode: + "manual": + fields.append("percent") + if not _percent(entry.get("percent")): + return "Manual percentage must be an integer from 0 to 100." + "software_curve", "firmware_curve": + fields.append_array(["sensors", "curve"]) + if not entry.get("sensors") is Array or entry.sensors.is_empty(): + return "A curve needs sensor IDs from the available sensor list." + var seen_sensors: Array = [] + for id in entry.sensors: + if id not in sensors or id in seen_sensors: + return "Unknown or duplicate sensor ID." + seen_sensors.append(id) + if not entry.get("curve") is Array or entry.curve.size() < 2: + return "Supply the complete temperature/percentage curve with at least two points." + var previous_temperature := -INF + var previous_percent := -1 + for point in entry.curve: + if not point is Array or point.size() != 2 or not (point[0] is int or point[0] is float) or not is_finite(float(point[0])) or float(point[0]) <= previous_temperature or not _percent(point[1]) or int(point[1]) < previous_percent: + return "Curve temperatures must increase and percentages must not decrease." + previous_temperature = float(point[0]) + previous_percent = int(point[1]) + for key in entry: + if key not in fields: + return "Profile fields cannot contain hardware paths or controller policy." + return "" + +static func _percent(value: Variant) -> bool: + return (value is int or value is float) and is_finite(float(value)) and float(value) == floor(float(value)) and float(value) >= 0 and float(value) <= 100 + +static func _canonical_profile(profile: Dictionary) -> Dictionary: + var result := profile.duplicate(true) + for entry in result.fans: + if entry.mode == "manual": + entry.percent = int(entry.percent) + if entry.mode in ["software_curve", "firmware_curve"]: + for point in entry.curve: + point[1] = int(point[1]) + return result diff --git a/core/systems/performance/fan_profile_model.gd.uid b/core/systems/performance/fan_profile_model.gd.uid new file mode 100644 index 000000000..4b96a9a1f --- /dev/null +++ b/core/systems/performance/fan_profile_model.gd.uid @@ -0,0 +1 @@ +uid://bhc3re8kul408 diff --git a/core/systems/performance/fan_profile_model_test.gd b/core/systems/performance/fan_profile_model_test.gd new file mode 100644 index 000000000..b669ec383 --- /dev/null +++ b/core/systems/performance/fan_profile_model_test.gd @@ -0,0 +1,128 @@ +extends GutTest +const Model := preload("res://core/systems/performance/fan_profile_model.gd") + +class Backend extends RefCounted: + var state: Dictionary = {"available": true, "api_version": 2, "owner": ":1.8", "profile_name": "", "profile_path": "", + "fans": [{"id": "test/fan", "name": "Fan", "mode": "automatic", "available_modes": ["automatic", "manual", "software_curve"], "percent": 0, "percent_valid": false, "fault": ""}], + "sensors": [{"id": "test/fan/cpu", "name": "CPU", "temperature_c": 50.0, "telemetry_valid": true}]} + var calls: Array = [] + var saved: Dictionary = {} + var error := "" + var denied := false + func get_snapshot_json() -> String: + return JSON.stringify(state) + func get_saved_selection_json() -> String: + return JSON.stringify(saved) + func get_last_error() -> String: + return error + func load_profile_json(raw: String, owner: String) -> bool: + calls.append({"profile": JSON.parse_string(raw), "owner": owner}) + if denied: + error = "Not applied; sensor unavailable" + return false + saved = {"kind": "json", "profile": JSON.parse_string(raw)} + state.profile_name = saved.profile.name + error = "" + return true + func load_profile(file: String, owner: String) -> bool: + calls.append({"file": file, "owner": owner}) + return not denied + +var backend: Backend +var model: FanProfileModel +func before_each() -> void: + backend = Backend.new() + model = Model.new() + model.start(backend) + +func test_first_use_and_refresh_never_send_a_profile() -> void: + model.refresh() + assert_eq(backend.calls.size(), 0) + assert_eq(model.draft.fans[0].mode, "automatic") + assert_true(model.compatible()) + +func test_explicit_manual_uses_complete_profile_and_displayed_owner() -> void: + model.edit_fan("test/fan", "manual", 60) + assert_eq(backend.calls.size(), 0) + assert_true(model.apply()) + assert_eq(backend.calls[0].owner, ":1.8") + assert_eq(backend.calls[0].profile.fans, [{"fan": "test/fan", "mode": "manual", "percent": 60.0}]) + +func test_automatic_works_without_telemetry_and_with_an_invalid_draft() -> void: + backend.state.sensors[0].telemetry_valid = false + model.draft = {"bad": "input"} + assert_true(model.automatic()) + assert_eq(backend.calls[0].profile.fans[0], {"fan": "test/fan", "mode": "automatic"}) + +func test_failed_apply_preserves_draft_and_reports_error() -> void: + model.edit_fan("test/fan", "manual", 60) + backend.denied = true + assert_false(model.apply()) + assert_eq(model.draft.fans[0].percent, 60) + assert_string_contains(model.error, "sensor unavailable") + assert_true(backend.saved.is_empty()) + +func test_service_restart_refresh_does_not_replay_the_draft() -> void: + model.edit_fan("test/fan", "manual", 60) + backend.state.owner = ":1.9" + model.refresh() + assert_eq(backend.calls.size(), 0) + assert_eq(model.draft.fans[0].percent, 60) + +func test_complete_sensor_curve_is_accepted_without_inventing_native_limits() -> void: + var profile := {"name": "Custom", "fans": [{"fan": "test/fan", "mode": "software_curve", "sensors": ["test/fan/cpu"], "curve": [[45, 20], [95, 100]]}]} + assert_true(model.set_json(JSON.stringify(profile))) + assert_true(model.apply()) + assert_eq(backend.calls[0].profile.fans[0].sensors, ["test/fan/cpu"]) + +func test_unknown_duplicate_unsupported_and_incomplete_profiles_are_rejected() -> void: + var profile := Model.automatic_profile(backend.state.fans) + profile.fans[0].fan = "unknown" + assert_ne(Model.validate_profile(profile, backend.state), "") + profile = Model.automatic_profile(backend.state.fans) + profile.fans.append(profile.fans[0].duplicate()) + assert_ne(Model.validate_profile(profile, backend.state), "") + profile.fans = [] + assert_ne(Model.validate_profile(profile, backend.state), "") + profile = Model.automatic_profile(backend.state.fans) + profile.fans[0].mode = "firmware_curve" + assert_ne(Model.validate_profile(profile, backend.state), "") + +func test_untrusted_paths_policy_invalid_percentages_and_curves_never_write() -> void: + for percent in [-1, 101, 12.5, "60"]: + assert_false(model.set_json(JSON.stringify({"name": "Bad", "fans": [{"fan": "test/fan", "mode": "manual", "percent": percent}]}))) + for curve in [[[50, 20], [50, 100]], [[50, 60], [95, 20]], [[50, 20]], [[50, 20], [95, 101]]]: + assert_false(model.set_json(JSON.stringify({"name": "Bad", "fans": [{"fan": "test/fan", "mode": "software_curve", "sensors": ["test/fan/cpu"], "curve": curve}]}))) + assert_false(model.set_json(JSON.stringify({"name": "Bad", "fans": [{"fan": "test/fan", "mode": "automatic", "sysfs": "/sys/fake"}]}))) + assert_eq(backend.calls.size(), 0) + +func test_unknown_duplicate_sensors_and_bad_json_leave_the_draft_intact() -> void: + var previous := model.draft.duplicate(true) + for sensors in [["missing"], ["test/fan/cpu", "test/fan/cpu"], []]: + assert_false(model.set_json(JSON.stringify({"name": "Bad", "fans": [{"fan": "test/fan", "mode": "software_curve", "sensors": sensors, "curve": [[45, 20], [95, 100]]}]}))) + assert_false(model.set_json("{")) + assert_eq(model.draft, previous) + +func test_v1_future_and_disconnected_services_cannot_apply() -> void: + for version in [1, 3]: + backend.state.api_version = version + model.refresh() + assert_false(model.compatible()) + assert_false(model.apply()) + assert_eq(backend.calls.size(), 0) + +func test_fan_edits_use_ids_when_profile_order_differs() -> void: + backend.state.fans.append({"id": "test/other", "available_modes": ["automatic", "manual"]}) + model.draft = {"name": "Two", "fans": [{"fan": "test/other", "mode": "automatic"}, {"fan": "test/fan", "mode": "automatic"}]} + model.edit_fan("test/fan", "manual", 65) + assert_eq(model.draft.fans[0].mode, "automatic") + assert_eq(model.draft.fans[1].percent, 65) + +func test_saved_json_loads_as_an_editable_draft_without_reapplying() -> void: + backend.saved = {"kind": "json", "profile": {"name": "Saved", "fans": [{"fan": "test/fan", "mode": "manual", "percent": 60}]}} + model = Model.new() + model.start(backend) + assert_eq(backend.calls.size(), 0) + assert_eq(model.draft.name, "Saved") + assert_true(model.draft.fans[0].percent is int) + assert_true(model.apply()) diff --git a/core/systems/performance/fan_profile_model_test.gd.uid b/core/systems/performance/fan_profile_model_test.gd.uid new file mode 100644 index 000000000..4507ab58c --- /dev/null +++ b/core/systems/performance/fan_profile_model_test.gd.uid @@ -0,0 +1 @@ +uid://b87vwbuprp0dw diff --git a/core/systems/performance/power_station.gd b/core/systems/performance/power_station.gd index e714a78b9..b0d5482e7 100644 --- a/core/systems/performance/power_station.gd +++ b/core/systems/performance/power_station.gd @@ -12,12 +12,24 @@ class_name PowerStation # Keep a reference to instances so they are not cleaned up automatically var _cpu: Cpu var _gpu: Gpu +var _fan: Fan var logger := Log.get_logger("PowerStation") func _ready() -> void: _cpu = instance.get_cpu() _gpu = instance.get_gpu() + _fan = instance.get_fan() + if instance.is_running(): + _restore_fan_profile() + else: + instance.started.connect(_restore_fan_profile, CONNECT_ONE_SHOT) + + +func _restore_fan_profile() -> void: + _fan = instance.get_fan() + if _fan: + _fan.restore_saved() func _process(_delta: float) -> void: diff --git a/core/ui/card_ui/quick_bar/fan_controls_card.tscn b/core/ui/card_ui/quick_bar/fan_controls_card.tscn new file mode 100644 index 000000000..cc51da950 --- /dev/null +++ b/core/ui/card_ui/quick_bar/fan_controls_card.tscn @@ -0,0 +1,15 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="PackedScene" path="res://core/ui/card_ui/quick_bar/qb_card.tscn" id="1_card"] +[ext_resource type="PackedScene" path="res://core/ui/common/quick_bar/fan_controls_menu.tscn" id="2_menu"] + +[node name="FanControlsCard" instance=ExtResource("1_card")] +visible = false +title = "Fan Settings" + +[node name="SectionLabel" parent="MarginContainer/CardVBoxContainer" index="0"] +text = "Fan Settings" + +[node name="FanControls" parent="MarginContainer/CardVBoxContainer/ContentContainer" index="0" instance=ExtResource("2_menu")] +layout_mode = 2 +size_flags_vertical = 0 diff --git a/core/ui/card_ui/quick_bar/quick_bar_menu.tscn b/core/ui/card_ui/quick_bar/quick_bar_menu.tscn index 5d00aec59..1edf5e26c 100644 --- a/core/ui/card_ui/quick_bar/quick_bar_menu.tscn +++ b/core/ui/card_ui/quick_bar/quick_bar_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=30 format=3 uid="uid://hroo3ll4inrb"] +[gd_scene load_steps=31 format=3 uid="uid://hroo3ll4inrb"] [ext_resource type="Script" uid="uid://3sik128fffg7" path="res://core/ui/card_ui/quick_bar/quick_bar_menu.gd" id="1_56jo7"] [ext_resource type="PackedScene" uid="uid://shvyhrv5sx3v" path="res://core/systems/state/state_watcher.tscn" id="2_6rvrx"] @@ -29,6 +29,7 @@ [ext_resource type="PackedScene" uid="uid://bjy50kdrebgre" path="res://core/ui/card_ui/quick_bar/notifications_card.tscn" id="19_pppbi"] [ext_resource type="PackedScene" uid="uid://dxaeufuk7ump2" path="res://core/ui/card_ui/quick_bar/quick_settings_card.tscn" id="20_17ks0"] [ext_resource type="PackedScene" uid="uid://dycb7m0oj13ly" path="res://core/ui/card_ui/quick_bar/performance_card.tscn" id="21_uw510"] +[ext_resource type="PackedScene" path="res://core/ui/card_ui/quick_bar/fan_controls_card.tscn" id="22_fan"] [node name="QuickBarMenu" type="Control" groups=["quick-bar"]] z_index = 20 @@ -235,3 +236,7 @@ layout_mode = 2 [node name="PerformanceCard" parent="MarginContainer/PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/Viewport" instance=ExtResource("21_uw510")] layout_mode = 2 + +[node name="FanControlsCard" parent="MarginContainer/PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/Viewport" instance=ExtResource("22_fan")] +unique_name_in_owner = true +layout_mode = 2 diff --git a/core/ui/common/quick_bar/fan_controls_menu.gd b/core/ui/common/quick_bar/fan_controls_menu.gd new file mode 100644 index 000000000..6b6f5a5e1 --- /dev/null +++ b/core/ui/common/quick_bar/fan_controls_menu.gd @@ -0,0 +1,131 @@ +extends VBoxContainer + +const Model := preload("res://core/systems/performance/fan_profile_model.gd") +const MODE_LABELS := {"automatic": "Automatic", "manual": "Manual", "software_curve": "Software curve", "firmware_curve": "Firmware curve"} +@export var connect_backend := true +var power_station := load("res://core/systems/performance/power_station.tres") as PowerStationInstance +var model := Model.new() +var _syncing := false +var _selected := 0 +var _modes: Array = [] + +@onready var focus_group := $%FocusGroup as FocusGroup +@onready var fan_dropdown := $%FanDropdown as Dropdown +@onready var mode_dropdown := $%ModeDropdown as Dropdown +@onready var manual_slider := $%ManualSlider as ValueSlider +@onready var telemetry_label := $%TelemetryLabel as Label +@onready var status_label := $%StatusLabel as Label +@onready var profile_label := $%ProfileLabel as Label +@onready var profile_editor := $%ProfileEditor as TextEdit +@onready var profile_file := $%ProfileFile as LineEdit +@onready var apply_button := $%ApplyButton as Button +@onready var automatic_button := $%AutomaticButton as Button + +func _ready() -> void: + model.updated.connect(_sync_view) + fan_dropdown.item_selected.connect(_select_fan) + mode_dropdown.item_selected.connect(_select_mode) + manual_slider.value_changed.connect(_set_percent) + $%ServiceTimer.timeout.connect(_refresh_devices) + $%UseJsonButton.pressed.connect(_use_json) + $%LoadFileButton.pressed.connect(func(): model.load_file(profile_file.text)) + apply_button.pressed.connect(_apply) + automatic_button.pressed.connect(_automatic) + if connect_backend: + power_station.started.connect(_refresh_devices) + power_station.stopped.connect(_refresh_devices) + _refresh_devices() + else: + $%ServiceTimer.stop() + _sync_view() + +func _refresh_devices() -> void: + if not connect_backend: + return + model.start(power_station.get_fan() if power_station else null) + +func _select_fan(index: int) -> void: + if _syncing: + return + _selected = index + _sync_view() + +func _select_mode(index: int) -> void: + if _syncing or index < 0 or index >= _modes.size(): + return + model.edit_fan(model.snapshot.fans[_selected].id, _modes[index], int(manual_slider.value)) + profile_editor.text = JSON.stringify(model.draft, " ") + +func _set_percent(value: float) -> void: + if _syncing: + return + model.edit_fan(model.snapshot.fans[_selected].id, "manual", int(value)) + profile_editor.text = JSON.stringify(model.draft, " ") + +func _use_json() -> void: + if model.set_json(profile_editor.text): + profile_editor.text = JSON.stringify(model.draft, " ") + +func _apply() -> void: + if model.set_json(profile_editor.text): + model.apply() + +func _automatic() -> void: + model.automatic() + profile_editor.text = JSON.stringify(model.draft, " ") + +func _sync_view() -> void: + if not is_node_ready(): + return + _syncing = true + var state := model.snapshot + var fans: Array = state.get("fans", []) + var available := model.compatible() and not fans.is_empty() + var card := get_parent() as Control + if card and card.name == "FanControlsCard": + card.visible = available or not str(state.get("error", "")).is_empty() + fan_dropdown.clear() + for fan in fans: + fan_dropdown.add_item(str(fan.name)) + _selected = clampi(_selected, 0, maxi(0, fans.size() - 1)) + fan_dropdown.select(_selected) + fan_dropdown.visible = fans.size() > 1 + mode_dropdown.clear() + _modes.clear() + var lines: Array[String] = [] + if available and _selected < model.draft.get("fans", []).size(): + var fan: Dictionary = fans[_selected] + var entry: Dictionary = model.draft.fans[_selected] + # Profiles may list fans in a different order to enumeration. + for candidate in model.draft.fans: + if candidate.fan == fan.id: + entry = candidate + break + for mode in fan.available_modes: + if MODE_LABELS.has(mode): + _modes.append(mode) + mode_dropdown.add_item(MODE_LABELS[mode]) + mode_dropdown.select(_modes.find(entry.mode)) + manual_slider.visible = entry.mode == "manual" + manual_slider.value = entry.get("percent", 60) + var duty := "%d%%" % int(fan.percent) if fan.percent_valid else "percentage unavailable" + lines.append("Current: %s — %s" % [MODE_LABELS.get(fan.mode, fan.mode), duty]) + if not str(fan.fault).is_empty(): + lines.append("Fan fault: " + str(fan.fault)) + else: + manual_slider.visible = false + for sensor in state.get("sensors", []): + lines.append("%s: %.1f C (%s)" % [sensor.name, float(sensor.temperature_c), sensor.id] if sensor.telemetry_valid else "%s: temperature unavailable (%s)" % [sensor.name, sensor.id]) + telemetry_label.text = "\n".join(lines) if not lines.is_empty() else "Fan telemetry unavailable" + profile_label.text = "Current profile: %s\n%s" % [state.get("profile_name", "") if not str(state.get("profile_name", "")).is_empty() else "Firmware Automatic", state.get("profile_path", "")] + status_label.text = model.error if not model.error.is_empty() else str(state.get("error", "Edit a complete profile, then Apply. New service processes start Automatic.")) + mode_dropdown.disabled = not available + manual_slider.editable = available + apply_button.disabled = not available + automatic_button.disabled = not available + $%LoadFileButton.disabled = not available + $%UseJsonButton.disabled = not available + if profile_editor.text.is_empty() and not model.draft.is_empty(): + profile_editor.text = JSON.stringify(model.draft, " ") + _syncing = false + focus_group.recalculate_focus.call_deferred() diff --git a/core/ui/common/quick_bar/fan_controls_menu.gd.uid b/core/ui/common/quick_bar/fan_controls_menu.gd.uid new file mode 100644 index 000000000..b9c3a4c20 --- /dev/null +++ b/core/ui/common/quick_bar/fan_controls_menu.gd.uid @@ -0,0 +1 @@ +uid://dmv5615ibo4jd diff --git a/core/ui/common/quick_bar/fan_controls_menu.tscn b/core/ui/common/quick_bar/fan_controls_menu.tscn new file mode 100644 index 000000000..dfc07f288 --- /dev/null +++ b/core/ui/common/quick_bar/fan_controls_menu.tscn @@ -0,0 +1,99 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Script" uid="uid://dmv5615ibo4jd" path="res://core/ui/common/quick_bar/fan_controls_menu.gd" id="1_fan"] +[ext_resource type="PackedScene" path="res://core/systems/input/focus_group.tscn" id="2_focus"] +[ext_resource type="Resource" path="res://core/ui/card_ui/quick_bar/quick_bar_menu_focus.tres" id="3_stack"] +[ext_resource type="PackedScene" path="res://core/ui/components/dropdown.tscn" id="4_drop"] +[ext_resource type="PackedScene" path="res://core/ui/components/slider.tscn" id="5_slider"] + +[node name="FanControls" type="VBoxContainer"] +anchors_preset = 10 +anchor_right = 1.0 +grow_horizontal = 2 +script = ExtResource("1_fan") + +[node name="FocusGroup" parent="." node_paths=PackedStringArray("current_focus") instance=ExtResource("2_focus")] +unique_name_in_owner = true +current_focus = NodePath("../AutomaticButton") +focus_stack = ExtResource("3_stack") + +[node name="ServiceTimer" type="Timer" parent="."] +unique_name_in_owner = true +wait_time = 2.0 +autostart = true + +[node name="ProfileLabel" type="Label" parent="."] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 3 + +[node name="TelemetryLabel" type="Label" parent="."] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 3 + +[node name="StatusLabel" type="Label" parent="."] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 3 + +[node name="AutomaticButton" type="Button" parent="."] +unique_name_in_owner = true +layout_mode = 2 +text = "Firmware Automatic" + +[node name="FanDropdown" parent="." instance=ExtResource("4_drop")] +unique_name_in_owner = true +layout_mode = 2 +title = "Fan" + +[node name="ModeDropdown" parent="." instance=ExtResource("4_drop")] +unique_name_in_owner = true +layout_mode = 2 +title = "Edit mode" +description = "Only modes advertised by PowerStation. Apply sends the complete profile." + +[node name="ManualSlider" parent="." instance=ExtResource("5_slider")] +unique_name_in_owner = true +layout_mode = 2 +text = "Manual fan (%)" +min_value = 0.0 +max_value = 100.0 +step = 1.0 +value = 60.0 + +[node name="EditorLabel" type="Label" parent="."] +layout_mode = 2 +text = "Complete profile (JSON)" + +[node name="ProfileEditor" type="TextEdit" parent="."] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 200) +layout_mode = 2 +wrap_mode = 1 + +[node name="UseJsonButton" type="Button" parent="."] +unique_name_in_owner = true +layout_mode = 2 +text = "Use edited JSON" + +[node name="ApplyButton" type="Button" parent="."] +unique_name_in_owner = true +layout_mode = 2 +text = "Apply and save profile" + +[node name="CurveHelp" type="Label" parent="."] +layout_mode = 2 +autowrap_mode = 3 +text = "Curves use sensor IDs above and temperature/percentage pairs. Native curves require every hardware point. PowerStation validates hardware constraints and owns thermal safety." + +[node name="ProfileFile" type="LineEdit" parent="."] +unique_name_in_owner = true +layout_mode = 2 +text = "quiet.yaml" +placeholder_text = "Packaged or distro profile filename" + +[node name="LoadFileButton" type="Button" parent="."] +unique_name_in_owner = true +layout_mode = 2 +text = "Load and save file profile" diff --git a/core/ui/common/quick_bar/fan_controls_menu_test.gd b/core/ui/common/quick_bar/fan_controls_menu_test.gd new file mode 100644 index 000000000..ef046bbc0 --- /dev/null +++ b/core/ui/common/quick_bar/fan_controls_menu_test.gd @@ -0,0 +1,28 @@ +extends GutTest +const MENU := preload("res://core/ui/common/quick_bar/fan_controls_menu.tscn") +const Fixtures := preload("res://core/systems/performance/fan_profile_model_test.gd") + +func test_menu_shows_available_modes_and_does_not_display_invalid_duty() -> void: + var backend := Fixtures.Backend.new() + var menu := MENU.instantiate() + menu.connect_backend = false + menu.model.start(backend) + add_child_autofree(menu) + assert_eq(menu.mode_dropdown.option_button.item_count, 3) + assert_string_contains(menu.telemetry_label.text, "percentage unavailable") + assert_string_contains(menu.telemetry_label.text, "CPU: 50.0 C") + assert_false(menu.telemetry_label.text.contains("RPM")) + assert_eq(backend.calls.size(), 0) + +func test_automatic_button_remains_available_without_sensor_telemetry() -> void: + var backend := Fixtures.Backend.new() + backend.state.sensors[0].telemetry_valid = false + var menu := MENU.instantiate() + menu.connect_backend = false + menu.model.start(backend) + add_child_autofree(menu) + assert_false(menu.automatic_button.disabled) + menu.automatic_button.pressed.emit() + assert_eq(backend.calls.size(), 1) + assert_eq(backend.calls[0].profile.fans[0].mode, "automatic") + assert_eq(menu.focus_group.current_focus.name, "AutomaticButton") diff --git a/core/ui/common/quick_bar/fan_controls_menu_test.gd.uid b/core/ui/common/quick_bar/fan_controls_menu_test.gd.uid new file mode 100644 index 000000000..04e43f5b3 --- /dev/null +++ b/core/ui/common/quick_bar/fan_controls_menu_test.gd.uid @@ -0,0 +1 @@ +uid://gv44uv1qj414 diff --git a/docs/fans.md b/docs/fans.md new file mode 100644 index 000000000..a371a2a49 --- /dev/null +++ b/docs/fans.md @@ -0,0 +1,87 @@ +# Fan profiles + +The Fan Settings quick-menu card requires PowerStation fan API v2, including +its startup-status and guarded startup-load methods. API v1 clients and +PowerStation releases without this contract cannot be used for these controls. +CPU/GPU controls retain their existing contracts. + +PowerStation enumerates stable fan and sensor IDs. The card shows each fan's +current mode, advertised editable modes and sensor temperatures. A percentage +is displayed only when `PercentValid` is true; firmware Automatic/native curves +can have valid temperature telemetry while duty is unavailable. RPM, raw PWM, +driver settings, ownership and thermal-policy values are not client controls. + +- **Automatic:** firmware controls cooling. +- **Manual:** request a fixed percentage, subject to the service's safety policy. +- **Software curve:** PowerStation interpolates the selected sensors' curve. +- **Firmware curve:** the hardware runs the complete native curve. Its current + duty is unavailable through this API. + +Choose a mode and percentage or edit the complete profile as JSON, then Apply +and save. Each enumerated fan must appear exactly once. Curve entries name +sensors and supply increasing temperature/percentage pairs. Native firmware +curves require the exact hardware point count; the service rejects incomplete +curves and enforces its own safety policy without resampling. Selecting a mode +or editing a draft does not write hardware. Firmware Automatic is a separate +explicit action that remains usable without telemetry or a valid saved draft. + +For example, after substituting the IDs advertised on the current system: + +```json +{ + "name": "My curve", + "fans": [{ + "fan": "ayaneo-3/system", + "mode": "software_curve", + "sensors": ["ayaneo-3/system/cpu"], + "curve": [[45, 20], [65, 40], [85, 70], [95, 100]] + }] +} +``` + +Load and save file profile calls `LoadProfile` with a filename, such as the +packaged `quiet.yaml`. PowerStation resolves only its approved profile +directories; a same-name distro override replaces the whole packaged profile. +A saved file selection stores that reference, so a later explicit or guarded +load uses the current distro version. The JSON editor remains a separate draft; +a file profile's curve is not guessed from telemetry. + +## Preferences and restoration + +OpenGamepadUI owns `user://data/fans/profile.json`, a versioned, bounded private +JSON preference file. Only a successful, owner-pinned service reply permits +saving a selection. Writes are synced and atomically renamed. Save errors are +reported even when the hardware request succeeded. Corrupt, oversized, linked +or insecure existing preferences are preserved and reported, never reset. +PowerStation remains the authority for all profile and hardware validation. + +Every new PowerStation process starts in firmware Automatic. At application +startup, OpenGamepadUI reads native `GetStartupStatus`, checks the kernel boot +identity and durably consumes its per-user boot attempt before reading saved +preferences or sending a restoration request. No saved selection means no +hardware write. `LoadStartupProfile`/`LoadStartupProfileJson` recheck the first +managed invocation and healthy untouched Automatic state under the native +profile transaction lock. A status read cannot authorize a stale later write. + +The client pins the unique D-Bus owner for snapshots and requests. Ineligible +startup, service replacement, faults, invalid telemetry, a previously selected +profile, malformed preferences, failed/uncertain requests and a consumed boot +attempt prevent automatic replay. Refreshing/remounting the menu never restores +anything. Application/service restarts within that boot do not retry. Successful +suspend/resume remains the native service's responsibility. A user can always +select a fresh profile explicitly after reviewing current state. + +The per-user attempt directories are preferences-side evidence, not privileged +ownership. The native transaction guard is authoritative across clients. Do not +delete startup evidence to manufacture eligibility. If repairing corrupt user +preferences manually, preserve the `restore-*` attempt directories. + +## Validation + +Run the `fan_` Rust tests with a private `dbus-daemon`, the focused GUT +`fan_profile_model_test.gd` and `fan_controls_menu_test.gd` suites, and locked +release builds for x86_64 and aarch64 in the pinned project builder. Fake +services/files prove software behavior only. Native GUI interaction, installed +session permissions and physical fan/thermal behavior on this revision require +separate device acceptance. See the PR handoff for exact tested commits and +counts. diff --git a/extensions/Cargo.lock b/extensions/Cargo.lock index cd1a5bfec..0afe7adbc 100644 --- a/extensions/Cargo.lock +++ b/extensions/Cargo.lock @@ -848,6 +848,7 @@ dependencies = [ "log", "nix 0.31.2", "once_cell", + "serde_json", "tokio", "zbus", "zvariant", diff --git a/extensions/core/Cargo.toml b/extensions/core/Cargo.toml index a37e6c4c4..6bcc6dcfc 100644 --- a/extensions/core/Cargo.toml +++ b/extensions/core/Cargo.toml @@ -22,3 +22,4 @@ inotify = "0.11.1" byte-unit = "5.2.0" log = "0.4.29" keyvalues-parser = "0.2.3" +serde_json = "=1.0.149" diff --git a/extensions/core/src/dbus/powerstation.rs b/extensions/core/src/dbus/powerstation.rs index 3ae387c03..66b0c112c 100644 --- a/extensions/core/src/dbus/powerstation.rs +++ b/extensions/core/src/dbus/powerstation.rs @@ -2,5 +2,8 @@ pub mod card; pub mod connector; pub mod core; pub mod cpu; +pub mod fan; +pub mod fan_device; +pub mod fan_sensor; pub mod gpu; pub mod tdp; diff --git a/extensions/core/src/dbus/powerstation/fan.rs b/extensions/core/src/dbus/powerstation/fan.rs new file mode 100644 index 000000000..9409aa85e --- /dev/null +++ b/extensions/core/src/dbus/powerstation/fan.rs @@ -0,0 +1,35 @@ +//! D-Bus proxy for the PowerStation fan collection. + +use zbus::proxy; + +#[proxy( + interface = "org.shadowblip.Fan", + default_service = "org.shadowblip.PowerStation", + default_path = "/org/shadowblip/Performance/Fan" +)] +pub trait Fan { + fn load_profile(&self, file: &str) -> zbus::Result<()>; + fn load_profile_json(&self, json: &str) -> zbus::Result<()>; + fn get_startup_status(&self) -> zbus::Result<(String, String, bool)>; + fn load_startup_profile_json( + &self, + boot_id: &str, + invocation_id: &str, + json: &str, + ) -> zbus::Result<()>; + fn load_startup_profile( + &self, + boot_id: &str, + invocation_id: &str, + file: &str, + ) -> zbus::Result<()>; + /// Return every fan exposed by PowerStation. + fn enumerate_fans(&self) -> zbus::Result>; + fn enumerate_sensors(&self) -> zbus::Result>; + #[zbus(property)] + fn api_version(&self) -> zbus::Result; + #[zbus(property)] + fn current_profile_name(&self) -> zbus::Result; + #[zbus(property)] + fn current_profile_path(&self) -> zbus::Result; +} diff --git a/extensions/core/src/dbus/powerstation/fan_device.rs b/extensions/core/src/dbus/powerstation/fan_device.rs new file mode 100644 index 000000000..7a373778d --- /dev/null +++ b/extensions/core/src/dbus/powerstation/fan_device.rs @@ -0,0 +1,23 @@ +//! Percentage-only API v2 fan state. Profile writes belong to the collection. +use zbus::proxy; + +#[proxy( + interface = "org.shadowblip.Fan.Device", + default_service = "org.shadowblip.PowerStation" +)] +pub trait FanDevice { + #[zbus(property)] + fn id(&self) -> zbus::Result; + #[zbus(property)] + fn name(&self) -> zbus::Result; + #[zbus(property)] + fn mode(&self) -> zbus::Result; + #[zbus(property)] + fn available_modes(&self) -> zbus::Result>; + #[zbus(property)] + fn percent(&self) -> zbus::Result; + #[zbus(property)] + fn percent_valid(&self) -> zbus::Result; + #[zbus(property)] + fn fault(&self) -> zbus::Result; +} diff --git a/extensions/core/src/dbus/powerstation/fan_sensor.rs b/extensions/core/src/dbus/powerstation/fan_sensor.rs new file mode 100644 index 000000000..6902dfc0a --- /dev/null +++ b/extensions/core/src/dbus/powerstation/fan_sensor.rs @@ -0,0 +1,16 @@ +use zbus::proxy; + +#[proxy( + interface = "org.shadowblip.Fan.Sensor", + default_service = "org.shadowblip.PowerStation" +)] +pub trait FanSensor { + #[zbus(property)] + fn id(&self) -> zbus::Result; + #[zbus(property)] + fn name(&self) -> zbus::Result; + #[zbus(property)] + fn temperature_c(&self) -> zbus::Result; + #[zbus(property)] + fn telemetry_valid(&self) -> zbus::Result; +} diff --git a/extensions/core/src/performance/powerstation.rs b/extensions/core/src/performance/powerstation.rs index 5400ea9d6..437e6f57b 100644 --- a/extensions/core/src/performance/powerstation.rs +++ b/extensions/core/src/performance/powerstation.rs @@ -1,5 +1,9 @@ pub mod cpu; pub mod cpu_core; +pub mod fan; +mod fan_client; +pub mod fan_device; +mod fan_settings; pub mod gpu; pub mod gpu_card; pub mod gpu_connector; @@ -10,6 +14,7 @@ use std::{ }; use cpu::Cpu; +use fan::Fan; use godot::{classes::Engine, prelude::*}; use gpu::Gpu; use zbus::names::BusName; @@ -19,6 +24,7 @@ use crate::{dbus::RunError, get_dbus_system, get_dbus_system_blocking, RUNTIME}; pub const POWERSTATION_BUS: &str = "org.shadowblip.PowerStation"; const POWERSTATION_CPU_PATH: &str = "/org/shadowblip/Performance/CPU"; const POWERSTATION_GPU_PATH: &str = "/org/shadowblip/Performance/GPU"; +const POWERSTATION_FAN_PATH: &str = "/org/shadowblip/Performance/Fan"; /// Signals that can be emitted #[derive(Debug)] @@ -36,6 +42,7 @@ pub struct PowerStationInstance { conn: Option, cpu_instance: Option>, gpu_instance: Option>, + fan_instance: Option>, #[allow(dead_code)] #[var(get = get_cpu)] @@ -43,6 +50,9 @@ pub struct PowerStationInstance { #[allow(dead_code)] #[var(get = get_gpu)] gpu: Option>, + #[allow(dead_code)] + #[var(get = get_fan)] + fan: Option>, } #[godot_api] @@ -81,6 +91,13 @@ impl PowerStationInstance { self.gpu_instance.clone() } + /// Returns the PowerStation fan collection. Older PowerStation versions + /// return an empty collection because they do not expose the fan interface. + #[func] + fn get_fan(&self) -> Option> { + self.fan_instance.clone() + } + /// Process UPower signals and emit them as Godot signals. This method /// should be called every frame in the "_process" loop of a node. #[func] @@ -108,6 +125,9 @@ impl PowerStationInstance { if let Some(gpu) = self.gpu_instance.as_mut() { gpu.bind_mut().process(); } + if let Some(fan) = self.fan_instance.as_mut() { + fan.bind_mut().process(); + } } /// Process and dispatch the given signal @@ -116,10 +136,14 @@ impl PowerStationInstance { Signal::Started => { // Create an instance for the CPU self.cpu_instance = Some(Cpu::new(POWERSTATION_CPU_PATH)); + self.gpu_instance = Some(Gpu::new(POWERSTATION_GPU_PATH)); + self.fan_instance = Some(Fan::new(POWERSTATION_FAN_PATH)); self.base_mut().emit_signal("started", &[]); } Signal::Stopped => { self.cpu_instance = None; + self.gpu_instance = None; + self.fan_instance = None; self.base_mut().emit_signal("stopped", &[]); } } @@ -145,8 +169,10 @@ impl IResource for PowerStationInstance { conn, cpu_instance: Default::default(), gpu_instance: Default::default(), + fan_instance: Default::default(), cpu: Default::default(), gpu: Default::default(), + fan: Default::default(), }; } @@ -161,6 +187,8 @@ impl IResource for PowerStationInstance { let cpu = Some(Cpu::new(POWERSTATION_CPU_PATH)); // Create GPU instance let gpu = Some(Gpu::new(POWERSTATION_GPU_PATH)); + // Create fan collection. It remains empty against older PowerStation. + let fan = Some(Fan::new(POWERSTATION_FAN_PATH)); // Create a new PowerStation instance Self { @@ -169,8 +197,10 @@ impl IResource for PowerStationInstance { conn, cpu_instance: cpu, gpu_instance: gpu, + fan_instance: fan, cpu: None, gpu: None, + fan: None, } } } diff --git a/extensions/core/src/performance/powerstation/fan.rs b/extensions/core/src/performance/powerstation/fan.rs new file mode 100644 index 000000000..290684efa --- /dev/null +++ b/extensions/core/src/performance/powerstation/fan.rs @@ -0,0 +1,255 @@ +use std::collections::HashMap; + +use godot::{ + classes::{ProjectSettings, ResourceLoader}, + prelude::*, +}; +use serde_json::{json, Value}; + +use crate::{dbus::powerstation::fan::FanProxyBlocking, get_dbus_system_blocking}; + +use super::{fan_client, fan_settings::FanSettings}; +use super::{fan_device::FanDevice, POWERSTATION_BUS}; +use crate::{get_dbus_system, RUNTIME}; + +/// A collection of fan devices exposed by PowerStation. +#[derive(GodotClass)] +#[class(no_init, base=Resource)] +pub struct Fan { + base: Base, + path: String, + proxy: Option>, + devices: HashMap>, + last_error: String, + restoration_attempted: bool, +} + +#[godot_api] +impl Fan { + fn from_path(path: GString) -> Gd { + Gd::from_init_fn(|base| { + let conn = get_dbus_system_blocking().ok(); + let proxy = if let Some(conn) = conn.as_ref() { + let path: String = path.clone().into(); + FanProxyBlocking::builder(conn) + .path(path) + .ok() + .and_then(|builder| builder.build().ok()) + } else { + None + }; + let mut instance = Self { + base, + proxy, + path: path.clone().into(), + devices: HashMap::new(), + last_error: String::new(), + restoration_attempted: false, + }; + instance.refresh_devices(); + instance + }) + } + + pub fn new(path: &str) -> Gd { + let res_path = format!("dbus://{POWERSTATION_BUS}{path}"); + let mut resource_loader = ResourceLoader::singleton(); + if resource_loader.exists(res_path.as_str()) { + if let Some(res) = resource_loader.load(res_path.as_str()) { + let mut fan: Gd = res.cast(); + fan.bind_mut().refresh_devices(); + return fan; + } + } + let mut device = Fan::from_path(path.into()); + device.take_over_path(res_path.as_str()); + device + } + + #[func] + pub fn get_dbus_path(&self) -> GString { + self.path.as_str().into() + } + + #[func] + pub fn get_last_error(&self) -> GString { + self.last_error.as_str().into() + } + + /// One owner-pinned snapshot, with no cached properties or raw hardware data. + #[func] + pub fn get_snapshot_json(&mut self) -> GString { + match self.snapshot() { + Ok(snapshot) => snapshot.to_string().as_str().into(), + Err(error) => json!({"available": false, "fans": [], "sensors": [], "error": error}) + .to_string() + .as_str() + .into(), + } + } + + #[func] + pub fn get_saved_selection_json(&mut self) -> GString { + match Self::settings().load() { + Ok(Some(value)) => value.to_string().as_str().into(), + Ok(None) => "{}".into(), + Err(error) => { + self.last_error = error; + "{}".into() + } + } + } + + /// Apply an approved-directory YAML profile and save its reference on success. + #[func] + pub fn load_profile(&mut self, file: GString, owner: GString) -> bool { + self.apply_selection( + json!({"kind": "file", "file": file.to_string()}), + &owner.to_string(), + None, + ) + } + + /// Apply a complete profile, then save only a confirmed selection. + #[func] + pub fn load_profile_json(&mut self, raw: GString, owner: GString) -> bool { + let raw = raw.to_string(); + if raw.len() > fan_client::MAX_PROFILE_BYTES { + self.last_error = "Fan profile exceeds 64 KiB".into(); + return false; + } + match serde_json::from_str::(&raw) { + Ok(profile) => self.apply_selection( + json!({"kind": "json", "profile": profile}), + &owner.to_string(), + None, + ), + Err(error) => { + self.last_error = format!("Invalid fan profile JSON: {error}"); + false + } + } + } + + /// Called once by the application, never by a remounted menu or recovery loop. + #[func] + pub fn restore_saved(&mut self) -> bool { + if self.restoration_attempted { + return false; + } + self.restoration_attempted = true; + let settings = Self::settings(); + let result = RUNTIME.block_on(async { + tokio::time::timeout(fan_client::TIMEOUT, async { + let connection = get_dbus_system().await.map_err(|e| e.to_string())?; + fan_client::restore(&connection, &settings).await + }) + .await + .map_err(|_| { + "Fan restoration timed out; it will not be retried automatically".to_string() + })? + }); + match result { + Ok(()) => { + self.last_error.clear(); + true + } + Err(error) => { + self.last_error = error; + false + } + } + } + + fn snapshot(&self) -> Result { + RUNTIME.block_on(async { + tokio::time::timeout(fan_client::TIMEOUT, async { + let connection = get_dbus_system().await.map_err(|e| e.to_string())?; + fan_client::snapshot(&connection).await + }) + .await + .map_err(|_| "PowerStation fan snapshot timed out".to_string())? + }) + } + + fn settings() -> FanSettings { + let path = ProjectSettings::singleton().globalize_path("user://data/fans"); + FanSettings::new(path.to_string().into()) + } + + fn apply_selection( + &mut self, + selection: Value, + owner: &str, + startup: Option<(&str, &str)>, + ) -> bool { + let result = RUNTIME.block_on(async { + tokio::time::timeout(fan_client::TIMEOUT, async { + let connection = get_dbus_system().await.map_err(|e| e.to_string())?; + fan_client::apply(&connection, owner, &selection, startup).await + }) + .await + .map_err(|_| { + "Fan profile request timed out; it will not be retried automatically".to_string() + })? + }); + if let Err(error) = result { + self.last_error = error; + return false; + } + match Self::settings().save(&selection) { + Ok(()) => { + self.last_error.clear(); + true + } + Err(error) => { + self.last_error = + format!("Profile applied, but preferences could not be saved: {error}"); + false + } + } + } + + /// Refresh fan objects after service restart or hardware rediscovery. + #[func] + pub fn refresh(&mut self) { + self.refresh_devices(); + } + + #[func] + pub fn get_fans(&self) -> Array> { + let mut result = array![]; + let mut paths: Vec<_> = self.devices.keys().collect(); + paths.sort(); + for path in paths { + if let Some(device) = self.devices.get(path) { + result.push(device); + } + } + result + } + + pub fn process(&mut self) { + for device in self.devices.values_mut() { + device.bind_mut().process(); + } + } + + fn refresh_devices(&mut self) { + let paths = self + .proxy + .as_ref() + .and_then(|proxy| proxy.enumerate_fans().ok()) + .unwrap_or_default(); + let mut devices = HashMap::new(); + for path in paths { + let key = path.to_string(); + let device = self + .devices + .remove(&key) + .unwrap_or_else(|| FanDevice::new(path.as_str())); + devices.insert(key, device); + } + self.devices = devices; + } +} diff --git a/extensions/core/src/performance/powerstation/fan_client.rs b/extensions/core/src/performance/powerstation/fan_client.rs new file mode 100644 index 000000000..befa5c604 --- /dev/null +++ b/extensions/core/src/performance/powerstation/fan_client.rs @@ -0,0 +1,182 @@ +//! Owner-pinned API v2 transactions. Saved preferences never contain hardware policy. +use super::POWERSTATION_BUS; +use crate::dbus::powerstation::{ + fan::FanProxy, fan_device::FanDeviceProxy, fan_sensor::FanSensorProxy, +}; +use serde_json::{json, Value}; +use std::time::Duration; +use zbus::{names::BusName, proxy::CacheProperties, Connection}; + +pub const TIMEOUT: Duration = Duration::from_secs(3); +pub const MAX_PROFILE_BYTES: usize = 64 * 1024; + +pub async fn restore( + connection: &Connection, + settings: &super::fan_settings::FanSettings, +) -> Result<(), String> { + let state = snapshot(connection).await?; + let startup = &state["startup"]; + if state["available"] != true || startup["eligible"] != true { + return Err("Saved fan profile was not restored: startup is not eligible. Select a profile explicitly.".into()); + } + let boot = startup["boot_id"] + .as_str() + .ok_or("Missing startup boot identity")?; + let invocation = startup["invocation_id"] + .as_str() + .ok_or("Missing startup invocation identity")?; + let current_boot = + std::fs::read_to_string("/proc/sys/kernel/random/boot_id").map_err(|e| e.to_string())?; + if boot != current_boot.trim() { + return Err("PowerStation startup belongs to another OS boot".into()); + } + // The fence precedes preferences and hardware writes. Any later error or + // uncertain result remains consumed across application restarts. + if !settings.consume_boot(boot)? { + return Err("Fan restoration was already attempted this boot".into()); + } + let Some(selection) = settings.load()? else { + return Ok(()); + }; + apply( + connection, + state["owner"].as_str().unwrap_or_default(), + &selection, + Some((boot, invocation)), + ) + .await +} + +async fn current_owner(connection: &Connection) -> Result { + let dbus = zbus::fdo::DBusProxy::new(connection) + .await + .map_err(|e| e.to_string())?; + dbus.get_name_owner(BusName::try_from(POWERSTATION_BUS).unwrap()) + .await + .map(|owner| owner.to_string()) + .map_err(|e| e.to_string()) +} + +pub async fn snapshot(connection: &Connection) -> Result { + let owner = current_owner(connection).await?; + let result = async { + let proxy = FanProxy::builder(connection).destination(owner.clone())? + .cache_properties(CacheProperties::No).build().await?; + let version = proxy.api_version().await?; + if version != 2 { return Ok(json!({"available": false, "api_version": version, + "error": "PowerStation fan API v2 is required", "fans": [], "sensors": []})); } + let mut fans = Vec::new(); + for path in proxy.enumerate_fans().await? { + let fan = FanDeviceProxy::builder(connection).destination(owner.clone())?.path(path.clone())? + .cache_properties(CacheProperties::No).build().await?; + fans.push(json!({"path": path.as_str(), "id": fan.id().await?, "name": fan.name().await?, + "mode": fan.mode().await?, "available_modes": fan.available_modes().await?, + "percent": fan.percent().await?, "percent_valid": fan.percent_valid().await?, "fault": fan.fault().await?})); + } + let mut sensors = Vec::new(); + for path in proxy.enumerate_sensors().await? { + let sensor = FanSensorProxy::builder(connection).destination(owner.clone())?.path(path.clone())? + .cache_properties(CacheProperties::No).build().await?; + sensors.push(json!({"path": path.as_str(), "id": sensor.id().await?, "name": sensor.name().await?, + "temperature_c": sensor.temperature_c().await?, "telemetry_valid": sensor.telemetry_valid().await?})); + } + let (boot, invocation, eligible) = proxy.get_startup_status().await?; + let startup = json!({"boot_id": boot, "invocation_id": invocation, "eligible": eligible}); + Ok::<_, zbus::Error>(json!({"available": true, "api_version": version, "owner": owner, + "profile_name": proxy.current_profile_name().await?, "profile_path": proxy.current_profile_path().await?, + "fans": fans, "sensors": sensors, "startup": startup})) + }.await.map_err(|e| e.to_string())?; + if current_owner(connection).await? != result["owner"].as_str().unwrap_or_default() + && result["available"] == true + { + return Err("PowerStation changed during the fan snapshot".into()); + } + Ok(result) +} + +pub fn validate_selection(selection: &Value) -> Result<(), String> { + let object = selection.as_object().ok_or("Invalid saved fan selection")?; + match selection["kind"].as_str() { + Some("file") if object.len() == 2 => { + let file = selection["file"] + .as_str() + .ok_or("Missing profile filename")?; + if file.is_empty() || file.len() > 4096 || file.contains('\0') { + return Err("Invalid profile filename".into()); + } + } + Some("json") if object.len() == 2 => { + let profile = &selection["profile"]; + let fields = profile.as_object().ok_or("Invalid profile")?; + if fields.len() != 2 + || profile["name"].as_str().is_none_or(|s| s.trim().is_empty()) + || profile["fans"] + .as_array() + .is_none_or(|fans| fans.is_empty()) + { + return Err("A complete named fan profile is required".into()); + } + } + _ => return Err("Invalid saved fan selection type".into()), + } + if selection.to_string().len() > MAX_PROFILE_BYTES { + return Err("Fan profile exceeds 64 KiB".into()); + } + Ok(()) +} + +pub async fn apply( + connection: &Connection, + expected_owner: &str, + selection: &Value, + startup: Option<(&str, &str)>, +) -> Result<(), String> { + validate_selection(selection)?; + if expected_owner.is_empty() || current_owner(connection).await? != expected_owner { + return Err("PowerStation changed; refresh before applying a profile".into()); + } + let result = async { + let proxy = FanProxy::builder(connection) + .destination(expected_owner.to_owned())? + .cache_properties(CacheProperties::No) + .build() + .await?; + if proxy.api_version().await? != 2 { + return Err(zbus::Error::Failure( + "PowerStation fan API v2 is required".into(), + )); + } + match (selection["kind"].as_str(), startup) { + (Some("file"), None) => { + proxy + .load_profile(selection["file"].as_str().unwrap()) + .await + } + (Some("file"), Some((boot, invocation))) => { + proxy + .load_startup_profile(boot, invocation, selection["file"].as_str().unwrap()) + .await + } + (_, None) => { + proxy + .load_profile_json(&selection["profile"].to_string()) + .await + } + (_, Some((boot, invocation))) => { + proxy + .load_startup_profile_json(boot, invocation, &selection["profile"].to_string()) + .await + } + } + } + .await; + result.map_err(|e| e.to_string())?; + if current_owner(connection).await? != expected_owner { + return Err("PowerStation changed after the request; application is uncertain and will not be retried".into()); + } + Ok(()) +} + +#[cfg(test)] +#[path = "fan_client_test.rs"] +mod tests; diff --git a/extensions/core/src/performance/powerstation/fan_client_test.rs b/extensions/core/src/performance/powerstation/fan_client_test.rs new file mode 100644 index 000000000..4a7c7d59b --- /dev/null +++ b/extensions/core/src/performance/powerstation/fan_client_test.rs @@ -0,0 +1,319 @@ +use super::*; +use crate::performance::powerstation::fan_settings::FanSettings; +use std::{ + io::{BufRead, BufReader}, + process::{Child, Command, Stdio}, + sync::{Arc, Mutex}, +}; +const FAN: &str = "/org/shadowblip/Performance/Fan/Fan0"; +const SENSOR: &str = "/org/shadowblip/Performance/Fan/Fan0/Sensor0"; +const ROOT: &str = "/org/shadowblip/Performance/Fan"; +const INVOCATION: &str = "11111111111111111111111111111111"; +struct Bus { + child: Child, + address: String, +} +impl Bus { + fn new() -> Self { + let mut child = Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut address = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + Self { + child, + address: address.trim().into(), + } + } + async fn connection(&self) -> Connection { + zbus::connection::Builder::address(self.address.as_str()) + .unwrap() + .build() + .await + .unwrap() + } +} +impl Drop for Bus { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} +#[derive(Default)] +struct State { + profile: String, + calls: Vec, + eligible: bool, + denied: bool, +} +struct Root { + state: Arc>, + version: u32, +} +#[zbus::interface(name = "org.shadowblip.Fan")] +impl Root { + fn enumerate_fans(&self) -> Vec { + vec![FAN.try_into().unwrap()] + } + fn enumerate_sensors(&self) -> Vec { + vec![SENSOR.try_into().unwrap()] + } + #[zbus(property)] + fn api_version(&self) -> u32 { + self.version + } + #[zbus(property)] + fn current_profile_name(&self) -> String { + self.state.lock().unwrap().profile.clone() + } + #[zbus(property)] + fn current_profile_path(&self) -> String { + String::new() + } + fn get_startup_status(&self) -> (String, String, bool) { + ( + std::fs::read_to_string("/proc/sys/kernel/random/boot_id") + .unwrap() + .trim() + .into(), + INVOCATION.into(), + self.state.lock().unwrap().eligible, + ) + } + fn load_profile_json(&self, json: &str) -> zbus::fdo::Result<()> { + self.load(json, false) + } + fn load_profile(&self, file: &str) -> zbus::fdo::Result<()> { + self.load(file, false) + } + fn load_startup_profile_json( + &self, + _boot: &str, + invocation: &str, + json: &str, + ) -> zbus::fdo::Result<()> { + if invocation != INVOCATION { + return Err(zbus::fdo::Error::Failed("Wrong invocation".into())); + } + self.load(json, true) + } + fn load_startup_profile( + &self, + _boot: &str, + invocation: &str, + file: &str, + ) -> zbus::fdo::Result<()> { + self.load_startup_profile_json(_boot, invocation, file) + } +} +impl Root { + fn load(&self, value: &str, startup: bool) -> zbus::fdo::Result<()> { + let mut state = self.state.lock().unwrap(); + if state.denied || (startup && !state.eligible) { + return Err(zbus::fdo::Error::Failed("Fan request rejected".into())); + } + state.calls.push(value.into()); + state.profile = "Confirmed".into(); + state.eligible = false; + Ok(()) + } +} +struct Device; +#[zbus::interface(name = "org.shadowblip.Fan.Device")] +impl Device { + #[zbus(property)] + fn id(&self) -> &str { + "test/fan" + } + #[zbus(property)] + fn name(&self) -> &str { + "Test fan" + } + #[zbus(property)] + fn mode(&self) -> &str { + "automatic" + } + #[zbus(property)] + fn available_modes(&self) -> Vec<&str> { + vec!["automatic", "manual", "software_curve", "firmware_curve"] + } + #[zbus(property)] + fn percent(&self) -> u8 { + 0 + } + #[zbus(property)] + fn percent_valid(&self) -> bool { + false + } + #[zbus(property)] + fn fault(&self) -> &str { + "" + } +} +struct Sensor; +#[zbus::interface(name = "org.shadowblip.Fan.Sensor")] +impl Sensor { + #[zbus(property)] + fn id(&self) -> &str { + "test/fan/cpu" + } + #[zbus(property)] + fn name(&self) -> &str { + "CPU" + } + #[zbus(property)] + fn temperature_c(&self) -> f64 { + 50.0 + } + #[zbus(property)] + fn telemetry_valid(&self) -> bool { + true + } +} +async fn server(bus: &Bus, state: Arc>, version: u32) -> Connection { + zbus::connection::Builder::address(bus.address.as_str()) + .unwrap() + .serve_at(ROOT, Root { state, version }) + .unwrap() + .serve_at(FAN, Device) + .unwrap() + .serve_at(SENSOR, Sensor) + .unwrap() + .name(POWERSTATION_BUS) + .unwrap() + .build() + .await + .unwrap() +} +fn selection() -> Value { + json!({"kind":"json", "profile":{"name":"Test", "fans":[{"fan":"test/fan", "mode":"manual", "percent":60}]}}) +} +#[tokio::test] +async fn snapshot_exposes_v2_sensors_capabilities_and_explicit_percent_validity() { + let bus = Bus::new(); + let state = Arc::new(Mutex::new(State { + eligible: true, + ..Default::default() + })); + let _server = server(&bus, state.clone(), 2).await; + let connection = bus.connection().await; + let first = snapshot(&connection).await.unwrap(); + assert_eq!(first["api_version"], 2); + assert_eq!(first["fans"][0]["percent_valid"], false); + assert_eq!(first["sensors"][0]["telemetry_valid"], true); + assert_eq!( + first["fans"][0]["available_modes"] + .as_array() + .unwrap() + .len(), + 4 + ); + assert!(first["fans"][0].get("rpm").is_none()); + state.lock().unwrap().profile = "External".into(); + assert_eq!( + snapshot(&connection).await.unwrap()["profile_name"], + "External", + "no stale property cache" + ); + assert!(state.lock().unwrap().calls.is_empty()); +} +#[tokio::test] +async fn old_and_future_api_versions_never_receive_writes() { + for version in [1, 3] { + let bus = Bus::new(); + let state = Arc::new(Mutex::new(State::default())); + let service = server(&bus, state.clone(), version).await; + let connection = bus.connection().await; + assert_eq!(snapshot(&connection).await.unwrap()["available"], false); + assert!(apply( + &connection, + service.unique_name().unwrap().as_str(), + &selection(), + None + ) + .await + .is_err()); + assert!(state.lock().unwrap().calls.is_empty()); + } +} +#[tokio::test] +async fn explicit_profiles_report_errors_and_never_retarget_a_replacement_owner() { + let bus = Bus::new(); + let state = Arc::new(Mutex::new(State::default())); + let first = server(&bus, state.clone(), 2).await; + let connection = bus.connection().await; + let owner = first.unique_name().unwrap().to_string(); + apply(&connection, &owner, &selection(), None) + .await + .unwrap(); + assert_eq!(state.lock().unwrap().calls.len(), 1); + state.lock().unwrap().denied = true; + assert!(apply(&connection, &owner, &selection(), None) + .await + .unwrap_err() + .contains("rejected")); + first.release_name(POWERSTATION_BUS).await.unwrap(); + let replacement_state = Arc::new(Mutex::new(State::default())); + let _replacement = server(&bus, replacement_state.clone(), 2).await; + assert!(apply(&connection, &owner, &selection(), None) + .await + .is_err()); + assert!(replacement_state.lock().unwrap().calls.is_empty()); + assert_eq!(state.lock().unwrap().calls.len(), 1); +} +#[tokio::test] +async fn saved_restore_consumes_attempt_before_a_denied_request_and_cannot_replay() { + let bus = Bus::new(); + let state = Arc::new(Mutex::new(State { + eligible: true, + denied: true, + ..Default::default() + })); + let service = server(&bus, state.clone(), 2).await; + let connection = bus.connection().await; + let directory = + std::env::temp_dir().join(format!("ogui-fan-restore-denied-{}", std::process::id())); + let settings = FanSettings::new(directory.clone()); + settings.save(&selection()).unwrap(); + assert!(restore(&connection, &settings).await.is_err()); + state.lock().unwrap().denied = false; + assert!(restore(&connection, &settings) + .await + .unwrap_err() + .contains("already attempted")); + assert!(state.lock().unwrap().calls.is_empty()); + service.release_name(POWERSTATION_BUS).await.unwrap(); + fs_cleanup(&directory); +} +#[tokio::test] +async fn successful_restore_is_once_per_boot_and_later_invocation_stays_automatic() { + let bus = Bus::new(); + let state = Arc::new(Mutex::new(State { + eligible: true, + ..Default::default() + })); + let service = server(&bus, state.clone(), 2).await; + let connection = bus.connection().await; + let directory = + std::env::temp_dir().join(format!("ogui-fan-restore-success-{}", std::process::id())); + let settings = FanSettings::new(directory.clone()); + settings + .save(&json!({"kind":"file", "file":"quiet.yaml"})) + .unwrap(); + restore(&connection, &settings).await.unwrap(); + assert_eq!(state.lock().unwrap().calls, vec!["quiet.yaml"]); + assert!(restore(&connection, &settings).await.is_err()); + service.release_name(POWERSTATION_BUS).await.unwrap(); + let later = Arc::new(Mutex::new(State::default())); + let _replacement = server(&bus, later.clone(), 2).await; + assert!(restore(&connection, &settings).await.is_err()); + assert!(later.lock().unwrap().calls.is_empty()); + fs_cleanup(&directory); +} +fn fs_cleanup(path: &std::path::Path) { + std::fs::remove_dir_all(path).unwrap(); +} diff --git a/extensions/core/src/performance/powerstation/fan_device.rs b/extensions/core/src/performance/powerstation/fan_device.rs new file mode 100644 index 000000000..fed150859 --- /dev/null +++ b/extensions/core/src/performance/powerstation/fan_device.rs @@ -0,0 +1,181 @@ +use std::sync::mpsc::{channel, Receiver, Sender, TryRecvError}; + +use futures_util::StreamExt; +use godot::{classes::ResourceLoader, prelude::*}; + +use crate::{ + dbus::{ + powerstation::fan_device::{FanDeviceProxy, FanDeviceProxyBlocking}, + RunError, + }, + get_dbus_system, get_dbus_system_blocking, RUNTIME, +}; + +use super::POWERSTATION_BUS; + +#[derive(Debug)] +enum Signal { + Updated, +} + +/// A single PowerStation fan and its API v2 read-only state. +#[derive(GodotClass)] +#[class(no_init, base=Resource)] +pub struct FanDevice { + base: Base, + dbus_path: String, + proxy: Option>, + rx: Receiver, +} + +#[godot_api] +impl FanDevice { + #[signal] + fn updated(); + + fn from_path(path: GString) -> Gd { + Gd::from_init_fn(|base| { + let conn = get_dbus_system_blocking().ok(); + let (tx, rx) = channel(); + let dbus_path: String = path.clone().into(); + RUNTIME.spawn(async move { + if let Err(error) = run(tx, dbus_path).await { + log::error!("Failed to run fan device task: {error:?}"); + } + }); + let proxy = if let Some(conn) = conn.as_ref() { + let dbus_path: String = path.clone().into(); + FanDeviceProxyBlocking::builder(conn) + .path(dbus_path) + .ok() + .and_then(|builder| { + builder + .cache_properties(zbus::proxy::CacheProperties::No) + .build() + .ok() + }) + } else { + None + }; + Self { + base, + proxy, + dbus_path: path.into(), + rx, + } + }) + } + + pub fn new(path: &str) -> Gd { + let res_path = format!("dbus://{POWERSTATION_BUS}{path}"); + let mut resource_loader = ResourceLoader::singleton(); + if resource_loader.exists(res_path.as_str()) { + if let Some(res) = resource_loader.load(res_path.as_str()) { + return res.cast(); + } + } + let mut device = FanDevice::from_path(path.into()); + device.take_over_path(res_path.as_str()); + device + } + + #[func] + pub fn get_dbus_path(&self) -> GString { + self.dbus_path.as_str().into() + } + + #[func] + pub fn get_id(&self) -> GString { + self.string_property(|p| p.id()) + } + + #[func] + pub fn get_name(&self) -> GString { + self.string_property(|p| p.name()) + } + + #[func] + pub fn get_mode(&self) -> GString { + self.string_property(|p| p.mode()) + } + + #[func] + pub fn get_fault(&self) -> GString { + self.string_property(|p| p.fault()) + } + + #[func] + pub fn get_percent(&self) -> i64 { + self.proxy + .as_ref() + .and_then(|p| p.percent().ok()) + .unwrap_or_default() + .into() + } + + #[func] + pub fn get_percent_valid(&self) -> bool { + self.proxy + .as_ref() + .and_then(|p| p.percent_valid().ok()) + .unwrap_or(false) + } + + #[func] + pub fn get_available_modes(&self) -> PackedStringArray { + self.string_array_property(|p| p.available_modes()) + } + + pub fn process(&mut self) { + loop { + match self.rx.try_recv() { + Ok(Signal::Updated) => { + self.base_mut().emit_signal("updated", &[]); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return, + } + } + } + + fn string_property(&self, getter: F) -> GString + where + F: FnOnce(&FanDeviceProxyBlocking<'static>) -> zbus::Result, + { + let value = self + .proxy + .as_ref() + .and_then(|p| getter(p).ok()) + .unwrap_or_default(); + value.as_str().into() + } + + fn string_array_property(&self, getter: F) -> PackedStringArray + where + F: FnOnce(&FanDeviceProxyBlocking<'static>) -> zbus::Result>, + { + let values: Vec = self + .proxy + .as_ref() + .and_then(|p| getter(p).ok()) + .unwrap_or_default() + .into_iter() + .map(|value| value.as_str().into()) + .collect(); + PackedStringArray::from(values.as_slice()) + } +} + +async fn run(tx: Sender, path: String) -> Result<(), RunError> { + let conn = get_dbus_system().await?; + let proxy = FanDeviceProxy::builder(&conn).path(path)?.build().await?; + // PowerStation emits Mode with every telemetry/configuration snapshot, so a + // single stream keeps Godot updates coalesced to one event per controller tick. + let mut changed = proxy.receive_mode_changed().await; + while changed.next().await.is_some() { + if tx.send(Signal::Updated).is_err() { + break; + } + } + Ok(()) +} diff --git a/extensions/core/src/performance/powerstation/fan_settings.rs b/extensions/core/src/performance/powerstation/fan_settings.rs new file mode 100644 index 000000000..5eb0fd283 --- /dev/null +++ b/extensions/core/src/performance/powerstation/fan_settings.rs @@ -0,0 +1,213 @@ +//! Per-user preferences and a durable, exclusive once-per-boot attempt fence. +use super::fan_client::{validate_selection, MAX_PROFILE_BYTES}; +use nix::libc; +use serde_json::{json, Value}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}, + }, + path::PathBuf, +}; + +pub struct FanSettings { + directory: PathBuf, +} +impl FanSettings { + pub fn new(directory: PathBuf) -> Self { + Self { directory } + } + fn directory(&self) -> Result { + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&self.directory) + .map_err(|e| e.to_string())?; + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&self.directory) + .map_err(|e| e.to_string())?; + let metadata = directory.metadata().map_err(|e| e.to_string())?; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o022 != 0 { + return Err("Fan preference directory is not trusted".into()); + } + Ok(directory) + } + fn read(&self, directory: &File) -> Result, String> { + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + c"profile.json".as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if descriptor < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::NotFound { + return Ok(None); + } + return Err(error.to_string()); + } + let file = unsafe { File::from_raw_fd(descriptor) }; + let metadata = file.metadata().map_err(|e| e.to_string())?; + if !metadata.is_file() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.nlink() != 1 + || metadata.mode() & 0o777 != 0o600 + || metadata.len() > MAX_PROFILE_BYTES as u64 + { + return Err("Saved fan preferences are not a bounded private regular file".into()); + } + let mut bytes = Vec::new(); + file.take(MAX_PROFILE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > MAX_PROFILE_BYTES { + return Err("Saved fan profile is too large".into()); + } + let data: Value = serde_json::from_slice(&bytes) + .map_err(|_| "Saved fan preferences are corrupt; the file was preserved")?; + if data.as_object().is_none_or(|o| o.len() != 2) || data["version"] != 1 { + return Err("Unsupported saved fan preferences; the file was preserved".into()); + } + validate_selection(&data["selection"])?; + Ok(Some(data["selection"].clone())) + } + pub fn load(&self) -> Result, String> { + if !self.directory.exists() { + return Ok(None); + } + self.read(&self.directory()?) + } + pub fn save(&self, selection: &Value) -> Result<(), String> { + validate_selection(selection)?; + let directory = self.directory()?; + // Preserve malformed existing preferences instead of silently replacing them. + self.read(&directory)?; + let bytes = json!({"version": 1, "selection": selection}).to_string(); + if bytes.len() > MAX_PROFILE_BYTES { + return Err("Saved fan preferences exceed 64 KiB".into()); + } + let name = format!(".profile-{}", std::process::id()); + let path = self.directory.join(&name); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path) + .map_err(|e| e.to_string())?; + let result = (|| { + file.write_all(bytes.as_bytes())?; + file.sync_all()?; + fs::rename(&path, self.directory.join("profile.json"))?; + directory.sync_all() + })() + .map_err(|e: std::io::Error| e.to_string()); + let _ = fs::remove_file(&path); + result + } + pub fn consume_boot(&self, boot: &str) -> Result { + if boot.len() != 36 + || !boot.bytes().enumerate().all(|(i, b)| { + if [8, 13, 18, 23].contains(&i) { + b == b'-' + } else { + b.is_ascii_hexdigit() && !b.is_ascii_uppercase() + } + }) + { + return Err("Invalid OS boot identity".into()); + } + let directory = self.directory()?; + let name = std::ffi::CString::new(format!("restore-{boot}")).unwrap(); + let first = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) } == 0; + if !first { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(error.to_string()); + } + } + // The exclusive directory itself is the attempt, even if fsync fails. + directory.sync_all().map_err(|e| e.to_string())?; + Ok(first) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + Self(std::env::temp_dir().join(format!( + "ogui-fans-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + ))) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + const BOOT: &str = "12345678-1234-1234-1234-123456789abc"; + #[test] + fn preferences_and_boot_attempt_survive_client_restart() { + let fixture = Fixture::new(); + let settings = FanSettings::new(fixture.0.clone()); + assert_eq!(settings.load().unwrap(), None); + let selection = json!({"kind":"file", "file":"quiet.yaml"}); + settings.save(&selection).unwrap(); + assert!(settings.consume_boot(BOOT).unwrap()); + let restarted = FanSettings::new(fixture.0.clone()); + assert_eq!(restarted.load().unwrap(), Some(selection)); + assert!(!restarted.consume_boot(BOOT).unwrap()); + assert_eq!( + fs::metadata(fixture.0.join("profile.json")).unwrap().mode() & 0o777, + 0o600 + ); + } + #[test] + fn malformed_oversized_insecure_and_symlink_preferences_are_preserved() { + let fixture = Fixture::new(); + let settings = FanSettings::new(fixture.0.clone()); + let selection = json!({"kind":"file", "file":"quiet.yaml"}); + settings.save(&selection).unwrap(); + let path = fixture.0.join("profile.json"); + for bytes in [ + "{".to_string(), + "x".repeat(MAX_PROFILE_BYTES + 1), + r#"{"version":2,"selection":{}}"#.into(), + ] { + fs::write(&path, &bytes).unwrap(); + assert!(settings.load().is_err()); + assert!(settings.save(&selection).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), bytes); + } + fs::remove_file(&path).unwrap(); + std::os::unix::fs::symlink("missing", &path).unwrap(); + assert!(settings.load().is_err()); + assert!(settings.save(&selection).is_err()); + fs::remove_file(&path).unwrap(); + settings.save(&selection).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(settings.load().is_err()); + fs::set_permissions(&fixture.0, fs::Permissions::from_mode(0o777)).unwrap(); + assert!(settings.consume_boot(BOOT).is_err()); + } + #[test] + fn invalid_identity_cannot_claim_an_attempt() { + let fixture = Fixture::new(); + let settings = FanSettings::new(fixture.0.clone()); + assert!(settings.consume_boot("../../escape").is_err()); + assert!(!fixture.0.exists()); + } +} diff --git a/tests/fan_client_contract_test.gd b/tests/fan_client_contract_test.gd new file mode 100644 index 000000000..b97352d7a --- /dev/null +++ b/tests/fan_client_contract_test.gd @@ -0,0 +1,56 @@ +extends SceneTree +## Run only against the private fake-hardware service described in docs/fans.md. +var checks := 0 +var failures := 0 + +func check(condition: bool, message: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error(message) + +func _initialize() -> void: + if OS.get_environment("OGUI_FAN_PRIVATE_FIXTURE") != "1" or not OS.get_environment("DBUS_SYSTEM_BUS_ADDRESS").begins_with("unix:"): + push_error("This test requires an explicitly isolated private D-Bus/fake-sysfs fixture") + quit(2) + return + var profile := {"name": "Restored", "fans": [{"fan": "ayaneo-3/system", "mode": "manual", "percent": 60}]} + var selection := {"kind": "json", "profile": profile} + if OS.get_environment("OGUI_FAN_SAVED_FILE") == "1": + selection = {"kind": "file", "file": "quiet.yaml"} + DirAccess.make_dir_recursive_absolute("user://data/fans") + var saved := FileAccess.open("user://data/fans/profile.json", FileAccess.WRITE) + saved.store_string(JSON.stringify({"version": 1, "selection": selection})) + saved.close() + check(OS.execute("chmod", ["600", ProjectSettings.globalize_path("user://data/fans/profile.json")]) == 0, "private fixture preference permissions") + var instance := PowerStationInstance.new() + var fan := instance.get_fan() + var before: Dictionary = JSON.parse_string(fan.get_snapshot_json()) + check(before.get("available", false), "API v2 service is available") + if not before.get("available", false): + quit(1) + return + check(before.startup.eligible, "first native invocation is eligible") + check(before.fans.size() == 1 and before.sensors.size() == 1, "fan/sensor discovery") + check(fan.restore_saved(), "guarded restore succeeds through Godot and real native D-Bus") + var restored: Dictionary = JSON.parse_string(fan.get_snapshot_json()) + check(not restored.startup.eligible, "native eligibility is consumed") + check(restored.fans[0].percent_valid, "software duty has explicit validity") + if selection.kind == "json": + check(restored.profile_name == "Restored" and int(restored.fans[0].percent) == 60, "saved JSON restored as Manual 60") + else: + check(restored.profile_name == "Quiet" and restored.fans[0].mode == "software_curve", "saved file restored through native override-aware loading") + check(not fan.restore_saved(), "the application does not attempt a second restoration") + check(not fan.load_profile_json(" ".repeat(65537), restored.owner), "oversized JSON is rejected before parsing or writing") + check(JSON.parse_string(fan.get_snapshot_json()).profile_name == restored.profile_name, "oversized input preserves the live native profile") + var model := FanProfileModel.new() + model.start(fan) + check(model.set_json('{"name":"Edited","fans":[{"fan":"ayaneo-3/system","mode":"manual","percent":65}]}'), "JSON editor accepts integral percentages") + check(model.apply(), "Godot JSON numeric serialization is accepted by native profile parsing") + var edited: Dictionary = JSON.parse_string(fan.get_snapshot_json()) + check(edited.profile_name == "Edited" and int(edited.fans[0].percent) == 65, "native state confirms edited profile") + check(model.automatic(), "explicit Automatic remains available") + var automatic: Dictionary = JSON.parse_string(fan.get_snapshot_json()) + check(automatic.fans[0].mode == "automatic" and not automatic.fans[0].percent_valid, "firmware duty is not invented") + print("FAN CONTRACT: %d assertions, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) diff --git a/tests/fan_client_contract_test.gd.uid b/tests/fan_client_contract_test.gd.uid new file mode 100644 index 000000000..18b693894 --- /dev/null +++ b/tests/fan_client_contract_test.gd.uid @@ -0,0 +1 @@ +uid://bfo7djtkr5ciy