Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"preview": "vite preview"
},
"dependencies": {
"@openmouse/protocol": "0.23.0",
"@openmouse/protocol": "0.23.1",
"lucide-react": "^1.43.0",
"preact": "^10.29.8"
},
Expand Down
11 changes: 11 additions & 0 deletions src/app/Profiles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ export function Profiles({
>
<IconShare />
</button>
{snapshot.setupProfilesAvailable ? (
<button
id="setup-logitech-profiles"
className="icon-button profile-setup-button"
type="button"
disabled={snapshot.settingInProgress}
onClick={() => void control.setUpLogitechProfiles()}
>
{t(locale, "prof.setUp")}
</button>
) : null}
{snapshot.resetProfilesAvailable ? (
<button
id="reset-logitech-profiles"
Expand Down
1 change: 1 addition & 0 deletions src/control-profiles.css
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@

/* Square icon buttons, matching the segmented controls' border and hover. */
.icon-button { display:inline-flex;align-items:center;justify-content:center;align-self:center;padding:.34rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-raised);color:var(--text-soft);cursor:pointer;transition:border-color .16s ease,color .16s ease,background .16s ease }
.icon-button.profile-setup-button { padding:.34rem .75rem;color:var(--ui-accent);font-size:.68rem;font-weight:600;white-space:nowrap }
.icon-button:hover:not(:disabled) { border-color:var(--ghost);background:var(--surface-hover-soft);color:var(--text-input) }
.icon-button:disabled { opacity:.45;cursor:not-allowed }
.icon-button.profile-delete-button { border-color:color-mix(in srgb,var(--destructive) 46%,var(--surface-panel));background:color-mix(in srgb,var(--destructive) 14%,var(--surface-panel));color:color-mix(in srgb,var(--destructive) 76%,var(--text-hover)) }
Expand Down
51 changes: 51 additions & 0 deletions src/device/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import { setCaptureContext } from "../capture-context";
import {
decodeProfileKey, encodeProfileKey, profileKeyMatchesDevice, type ProfileKeyPayload,
} from "./profile-key";
import { hasCapturedFactoryProfiles } from "./logitech-factory";
import type { MouseLighting, MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
import type { KsnakeMacroProfile } from "@openmouse/protocol/ksnake";
import {
Expand Down Expand Up @@ -549,6 +550,11 @@ function buildSnapshot(): ControlSnapshot {
resetProfilesAvailable: logitechClient() !== null
&& latestDeviceStatus?.brand === "Logitech"
&& supportsFactoryReset(latestDeviceStatus?.onboardProfileFormat?.id ?? null),
setupProfilesAvailable: logitechClient() !== null
&& latestDeviceStatus?.brand === "Logitech"
&& hasCapturedFactoryProfiles(latestDeviceStatus?.onboardProfileFormat)
&& onboardProfiles !== null
&& onboardProfiles.length === 0,
previewMode,
previewEnabled: previewModeEnabled,
previewEntries,
Expand Down Expand Up @@ -4018,6 +4024,51 @@ export async function resetLogitechProfiles(): Promise<void> {
}
}

/**
* Gives a Logitech mouse with no stored profiles (G HUB has never linked it)
* the factory set, so G HUB is not needed first. Only offered where a complete
* factory image was captured, and only while the profile list is empty.
*/
export async function setUpLogitechProfiles(): Promise<void> {
if (blockedByGameProfileDraft()) return;
const client = logitechClient();
if (!client || settingInProgress || !hasCapturedFactoryProfiles(lastProfileFormat)) return;
if (onboardProfiles === null || onboardProfiles.length > 0) return;

const confirmed = window.confirm(
"This mouse has no onboard profiles yet.\n\n"
+ "Set them up now? OpenMouse will write the Logitech factory profiles into the mouse's memory, with profile 1 active, without G HUB having to link it first.\n\n"
+ "This replays the sequence captured from G HUB's own reset. It has not been confirmed on a mouse G HUB has never linked.",
);
if (!confirmed) return;

settingInProgress = true;
readStatus = st("ctl.resetting");
onboardStatus = st("ctl.writingDefaults");
emit();
recordDiagnosticCommand("Set up Logitech onboard profiles on a mouse with none");
try {
await client.initializeBlankOnboardProfiles();
onboardProfiles = await client.readOnboardProfiles();
editedProfile = onboardProfiles[0]?.sector ?? "host";
lastDeviceMode = "Onboard";
const status = await client.readStatus();
deviceStatuses.set(client.device, status);
applyStatus(status);
readStatus = st("ctl.resetDone");
onboardStatus = st("ctl.resetComplete");
} catch (error) {
recordDiagnosticError(error, st("ctl.unableReset"));
const message = error instanceof Error ? error.message : "Unable to set up the onboard profiles.";
readStatus = message;
onboardStatus = message;
onboardProfiles = null;
await reloadOnboardProfiles();
} finally {
endDeviceWrite();
}
}

export function bunnyHopSupported(): boolean {
return editedProfileEntry() !== null && capabilitiesForFormat(lastProfileFormat?.id).bunnyHop;
}
Expand Down
27 changes: 27 additions & 0 deletions src/device/logitech-factory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";

import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";
import { hasCapturedFactoryProfiles } from "./logitech-factory.ts";

function format(id: number): NonNullable<MouseStatus["onboardProfileFormat"]> {
return { id, name: `format ${id}`, base: "0x8100", supported: true, verified: true, writable: true };
}

test("only the captured profile format is offered a factory setup", () => {
assert.equal(hasCapturedFactoryProfiles(format(8)), true);
});

test("a format whose factory sectors were never captured is not offered one", () => {
// Format 7 passes supportsFactoryReset(), which is what the button used to be
// gated on: a PRO X Superlight 2 with no profiles offered the button and then
// threw "Factory profiles have not been captured for profile format 7" after
// the user had already confirmed a flash write.
assert.equal(hasCapturedFactoryProfiles(format(7)), false);
assert.equal(hasCapturedFactoryProfiles(format(6)), false);
});

test("an unknown or unread format is not offered a factory setup", () => {
assert.equal(hasCapturedFactoryProfiles(null), false);
assert.equal(hasCapturedFactoryProfiles(undefined), false);
});
23 changes: 23 additions & 0 deletions src/device/logitech-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { factoryDirectoryForFormat } from "@openmouse/protocol/drivers/logitech/onboard-profiles";
import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types";

/** The sector size of the only factory image captured so far (format 8). */
const CAPTURED_FACTORY_SECTOR_SIZE = 255;

/**
* Whether OpenMouse can give this mouse the factory onboard profiles. A factory
* image exists only for the profile format whose sectors were captured:
* `factoryDirectoryForFormat` returns null for every other format and sector
* size. `supportsFactoryReset` is the wrong question here because it is also true
* for profile format 7, whose factory sectors were never captured — the client
* would then refuse after the user had already confirmed a flash write.
*
* The client re-checks the mouse's own sector size before it writes, so this only
* decides whether the button is offered at all.
*/
export function hasCapturedFactoryProfiles(
format: MouseStatus["onboardProfileFormat"] | null | undefined,
): boolean {
return format != null
&& factoryDirectoryForFormat(format.id, CAPTURED_FACTORY_SECTOR_SIZE) !== null;
}
2 changes: 2 additions & 0 deletions src/device/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ export interface ControlSnapshot {
diagnosticsOpen: boolean;
captureAvailable: boolean;
resetProfilesAvailable: boolean;
/** A Logitech mouse with no stored profiles that OpenMouse can set up itself. */
setupProfilesAvailable: boolean;

previewMode: PreviewMode | null;
previewEnabled: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/i18n-ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ export const ar: Record<I18nKey, string> = {
"prof.reload": "إعادة تحميل الملفات",
"prof.deleteReset": "حذف وإعادة تعيين كل ملف مدمج",
"prof.restoreDefaults": "استعادة كل ملف مدمج نهائيًا إلى إعدادات Logitech الافتراضية",
"prof.setUp": "إعداد الملفات",
"prof.openHost": "فتح إعدادات المضيف",
"prof.hostAlready": "الماوس في وضع المضيف بالفعل",
"prof.switchHost": "تبديل الماوس إلى وضع المضيف",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ export const de: Record<I18nKey, string> = {
"prof.reload": "Profile neu laden",
"prof.deleteReset": "Alle internen Profile löschen und zurücksetzen",
"prof.restoreDefaults": "Alle internen Profile dauerhaft auf Logitech-Standardwerte zurücksetzen",
"prof.setUp": "Profile einrichten",
"prof.openHost": "Host-Einstellungen öffnen",
"prof.hostAlready": "Die Maus ist bereits im Host-Modus",
"prof.switchHost": "Maus in den Host-Modus schalten",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ export const es: Record<I18nKey, string> = {
"prof.reload": "Recargar perfiles",
"prof.deleteReset": "Eliminar y restablecer todos los perfiles integrados",
"prof.restoreDefaults": "Restaurar permanentemente todos los perfiles integrados a los valores predeterminados de Logitech",
"prof.setUp": "Configurar perfiles",
"prof.openHost": "Abrir configuración de host",
"prof.hostAlready": "El mouse ya está en modo host",
"prof.switchHost": "Cambiar el mouse a modo host",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ export const fr: Record<I18nKey, string> = {
"prof.reload": "Recharger les profils",
"prof.deleteReset": "Supprimer et réinitialiser tous les profils intégrés",
"prof.restoreDefaults": "Restaurer définitivement tous les profils intégrés aux valeurs par défaut Logitech",
"prof.setUp": "Configurer les profils",
"prof.openHost": "Ouvrir les réglages host",
"prof.hostAlready": "La souris est déjà en mode host",
"prof.switchHost": "Basculer la souris en mode host",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ export const ja: Record<I18nKey, string> = {
"prof.reload": "プロファイルを再読み込み",
"prof.deleteReset": "すべての内蔵プロファイルを削除してリセット",
"prof.restoreDefaults": "すべての内蔵プロファイルをLogitechのデフォルトに完全に復元",
"prof.setUp": "プロファイルを設定",
"prof.openHost": "ホスト設定を開く",
"prof.hostAlready": "マウスはすでにホストモードです",
"prof.switchHost": "マウスをホストモードに切り替え",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ export const ko: Record<I18nKey, string> = {
"prof.reload": "프로필 다시 불러오기",
"prof.deleteReset": "모든 내장 프로필 삭제 및 초기화",
"prof.restoreDefaults": "모든 내장 프로필을 Logitech 기본값으로 영구 복원",
"prof.setUp": "프로필 설정",
"prof.openHost": "호스트 설정 열기",
"prof.hostAlready": "마우스가 이미 호스트 모드입니다",
"prof.switchHost": "마우스를 호스트 모드로 전환",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ export const pt: Record<I18nKey, string> = {
"prof.reload": "Recarregar perfis",
"prof.deleteReset": "Apagar e redefinir todos os perfis internos",
"prof.restoreDefaults": "Restaurar permanentemente todos os perfis internos aos padrões Logitech",
"prof.setUp": "Configurar perfis",
"prof.openHost": "Abrir configurações do host",
"prof.hostAlready": "O mouse já está no modo host",
"prof.switchHost": "Trocar o mouse para o modo host",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ export const ru: Record<I18nKey, string> = {
"prof.reload": "Перезагрузить профили",
"prof.deleteReset": "Удалить и сбросить все встроенные профили",
"prof.restoreDefaults": "Безвозвратно вернуть все встроенные профили к настройкам Logitech",
"prof.setUp": "Настроить профили",
"prof.openHost": "Открыть настройки хоста",
"prof.hostAlready": "Мышь уже в режиме хоста",
"prof.switchHost": "Перевести мышь в режим хоста",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ export const vi: Record<I18nKey, string> = {
"prof.reload": "Tải lại profiles",
"prof.deleteReset": "Xóa và đặt lại mọi profile lưu trên chuột",
"prof.restoreDefaults": "Khôi phục vĩnh viễn mọi profile lưu trên chuột về mặc định Logitech",
"prof.setUp": "Thiết lập hồ sơ",
"prof.openHost": "Mở cài đặt host",
"prof.hostAlready": "Chuột đã ở host mode",
"prof.switchHost": "Chuyển chuột sang host mode",
Expand Down
1 change: 1 addition & 0 deletions src/i18n-zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ export const zh: Record<I18nKey, string> = {
"prof.reload": "重新加载配置文件",
"prof.deleteReset": "删除并重置全部板载配置文件",
"prof.restoreDefaults": "将全部板载配置文件永久恢复为罗技默认值",
"prof.setUp": "设置配置文件",
"prof.openHost": "打开主机设置",
"prof.hostAlready": "鼠标已处于主机模式",
"prof.switchHost": "将鼠标切换到主机模式",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ const en = {
"prof.reload": "Reload profiles",
"prof.deleteReset": "Delete and reset every onboard profile",
"prof.restoreDefaults": "Permanently restore every onboard profile to Logitech defaults",
"prof.setUp": "Set up profiles",
"prof.openHost": "Open host settings",
"prof.hostAlready": "The mouse is already in host mode",
"prof.switchHost": "Switch the mouse to host mode",
Expand Down Expand Up @@ -762,7 +763,7 @@ const en = {
"ctl.progProfile": "Switching profile…",
"ctl.switchingLayer": "Switching to {label}…",
"ctl.readingProfiles": "Reading onboard profiles…",
"ctl.noProfiles": "This mouse reported no onboard profiles.",
"ctl.noProfiles": "No onboard profiles are saved on this mouse yet. Logitech mice normally get them when G HUB links the mouse once: open G HUB, let it detect the mouse, then reload here. Where Set up profiles is shown, OpenMouse can create them itself.",
"ctl.noNameField": "This profile format has no name field.",
"ctl.selectedMouse": "the selected mouse",
"ctl.autoSleep": "Auto sleep {v}",
Expand Down
Loading