From 105450f99ee5c1d7103b9999cec8d6b170fade54 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 20:53:33 +0900 Subject: [PATCH 1/2] Add Keyboard Abyss import export tab --- package-lock.json | 76 +++- package.json | 4 + src/App.tsx | 23 +- src/pages/ImportExportPage.tsx | 661 +++++++++++++++++++++++++++++++++ src/vite-env.d.ts | 9 + 5 files changed, 764 insertions(+), 9 deletions(-) create mode 100644 src/pages/ImportExportPage.tsx diff --git a/package-lock.json b/package-lock.json index 23a9aad..352ae00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,10 @@ "version": "0.0.0", "dependencies": { "@cormoran/zmk-studio-react-hook": "github:cormoran/react-zmk-studio", + "@keyboard-hub/abyss-client": "file:../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/abyss-client", + "@keyboard-hub/adapter-common": "file:../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/adapter-common", + "@keyboard-hub/adapter-zmk": "file:../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/adapter-zmk", + "@keyboard-hub/shared": "file:../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/shared", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", @@ -61,6 +65,29 @@ "vite-plugin-svgr": "^4.5.0" } }, + "../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/abyss-client": { + "name": "@keyboard-hub/abyss-client", + "version": "0.0.0", + "dependencies": { + "@keyboard-hub/shared": "0.0.0" + } + }, + "../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/adapter-common": { + "name": "@keyboard-hub/adapter-common", + "version": "0.0.0" + }, + "../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/adapter-zmk": { + "name": "@keyboard-hub/adapter-zmk", + "version": "0.0.0", + "dependencies": { + "@bufbuild/protobuf": "^2.12.0", + "@zmkfirmware/zmk-studio-ts-client": "git+https://github.com/cormoran/zmk-studio-ts-client.git#custom-studio-protocol+bluefy" + } + }, + "../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/shared": { + "name": "@keyboard-hub/shared", + "version": "0.0.0" + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -133,6 +160,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -966,6 +994,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -989,6 +1018,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2533,6 +2563,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyboard-hub/abyss-client": { + "resolved": "../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/abyss-client", + "link": true + }, + "node_modules/@keyboard-hub/adapter-common": { + "resolved": "../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/adapter-common", + "link": true + }, + "node_modules/@keyboard-hub/adapter-zmk": { + "resolved": "../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/adapter-zmk", + "link": true + }, + "node_modules/@keyboard-hub/shared": { + "resolved": "../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/shared", + "link": true + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -3777,6 +3823,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -4279,8 +4326,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4531,6 +4577,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4541,6 +4588,7 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4551,6 +4599,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4665,6 +4714,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -4942,6 +4992,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5337,6 +5388,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6049,8 +6101,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dot-case": { "version": "3.0.4", @@ -6202,6 +6253,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7231,6 +7283,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9121,6 +9174,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -9832,7 +9886,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10429,6 +10482,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10477,7 +10531,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10493,7 +10546,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10571,6 +10623,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10580,6 +10633,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10599,6 +10653,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -10744,7 +10799,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -11508,6 +11564,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11630,6 +11687,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11826,6 +11884,7 @@ "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/runtime": "0.97.0", "fdir": "^6.5.0", @@ -12184,6 +12243,7 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b3b3324..edcdcac 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ }, "dependencies": { "@cormoran/zmk-studio-react-hook": "github:cormoran/react-zmk-studio", + "@keyboard-hub/abyss-client": "file:../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/abyss-client", + "@keyboard-hub/adapter-common": "file:../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/adapter-common", + "@keyboard-hub/adapter-zmk": "file:../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/adapter-zmk", + "@keyboard-hub/shared": "file:../../../../Program/src/github.com/cormoran/keyboard-abyss/packages/shared", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", diff --git a/src/App.tsx b/src/App.tsx index f76f255..7197882 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { IconBattery2, IconBluetooth, + IconCloudDownload, IconHome, IconKeyboard, IconPointer, @@ -27,6 +28,7 @@ import { KeymapPage } from "./pages/KeymapPage"; import { TrackballPage } from "./pages/TrackballPage"; import { SettingsPage } from "./pages/SettingsPage"; import { CustomSubsystemsPage } from "./pages/CustomSubsystemsPage"; +import { ImportExportPage } from "./pages/ImportExportPage"; const tabs: TabItem[] = [ { @@ -41,6 +43,12 @@ const tabs: TabItem[] = [ icon: , content: , }, + { + id: "import-export", + label: "Import/Export", + icon: , + content: , + }, { id: "trackball", label: "Trackball", @@ -87,7 +95,17 @@ function App() { function AppContent() { const connection = useContext(ConnectionContext); - const [activeTab, setActiveTab] = useState("home"); + const [activeTab, setActiveTab] = useState(() => { + const search = new URLSearchParams(window.location.search); + const requestedTab = search.get("tab"); + if (requestedTab && tabs.some((tab) => tab.id === requestedTab)) { + return requestedTab; + } + if (search.has("code") || search.has("error")) { + return "import-export"; + } + return "home"; + }); const setActiveTabWithTracking = useCallback( (tabId: string) => { @@ -98,6 +116,9 @@ function AppContent() { page_path: `/${tabId}`, }); } + const url = new URL(window.location.href); + url.searchParams.set("tab", tabId); + window.history.replaceState({}, "", url); setActiveTab(tabId); }, [setActiveTab], diff --git a/src/pages/ImportExportPage.tsx b/src/pages/ImportExportPage.tsx new file mode 100644 index 0000000..1a94ba2 --- /dev/null +++ b/src/pages/ImportExportPage.tsx @@ -0,0 +1,661 @@ +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { ZMKAppContext } from "@cormoran/zmk-studio-react-hook"; +import { + createAbyssClient, + type AbyssKeymapSummary, + type AbyssUserInfo, +} from "@keyboard-hub/abyss-client"; +import { + compareKeyboardHubKeymaps, + isEmptyKeyboardHubKeymapDiff, + isFirmwareLockedError, + type FirmwareKeyboardHubKeymap, + type KeyboardHubKeymapDiff, +} from "@keyboard-hub/adapter-common"; +import { + zmkAdapter, + type ZmkConnection, + type ZmkLoadedConnection, +} from "@keyboard-hub/adapter-zmk"; +import { + IconAlertCircle, + IconCloudDownload, + IconCloudUpload, + IconGitCompare, + IconLoader2, + IconLock, + IconLogin, + IconLogout, + IconRefresh, +} from "@tabler/icons-react"; +import { ConnectionContext } from "../components/DeviceConnection"; + +const abyssClientId = import.meta.env.VITE_ABYSS_CLIENT_ID ?? ""; +const abyssBaseUrl = + import.meta.env.VITE_ABYSS_BASE_URL ?? + "https://keyboard-abyss.cormoran707.workers.dev"; +const abyssStorageKey = "dya-studio:keyboard-abyss:token"; +const abyssTransactionStorageKey = "dya-studio:keyboard-abyss:oauth"; + +type LoadedKeyboard = { + connection: ZmkLoadedConnection; + keymap: FirmwareKeyboardHubKeymap; + deviceName: string; +}; + +function cleanRedirectUri() { + const url = new URL(window.location.href); + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function displayError(caught: unknown) { + if (caught instanceof Error) return caught.message; + return "Unknown error"; +} + +function keymapData(keymap: AbyssKeymapSummary) { + return keymap.latestVersion?.data ?? keymap.data ?? null; +} + +function bindingLabel(binding: unknown) { + if (!binding) return "-"; + if (typeof binding === "object") { + const value = binding as { label?: unknown; zmk?: unknown; type?: unknown }; + if (typeof value.label === "string") return value.label; + if (typeof value.zmk === "string") return value.zmk; + if (typeof value.type === "string") return value.type; + } + return JSON.stringify(binding); +} + +function countDiff(diff: KeyboardHubKeymapDiff | null) { + if (!diff) return 0; + return ( + diff.bindingChanges.length + + diff.layerNameChanges.length + + diff.comboChanges.length + + diff.macroChanges.length + + diff.moduleChanges.length + ); +} + +function keymapMatchesKeyboard( + keymap: AbyssKeymapSummary, + current: FirmwareKeyboardHubKeymap | null, +) { + if (!current) return true; + const candidates = new Set( + [ + keymap.keyboardSlug, + keymap.keyboard?.slug, + keymapData(keymap)?.keyboard, + keymap.keyboardName, + keymap.keyboard?.name, + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()), + ); + return candidates.has(current.keyboard.toLowerCase()); +} + +function DiffSummary({ diff }: { diff: KeyboardHubKeymapDiff }) { + const sections = [ + { + label: "Bindings", + count: diff.bindingChanges.length, + rows: diff.bindingChanges.slice(0, 24).map((change) => ({ + key: `binding-${change.layerIndex}-${change.keyIndex}`, + title: `${change.layerName} / Key ${change.keyIndex + 1}`, + from: bindingLabel(change.from), + to: bindingLabel(change.to), + })), + }, + { + label: "Layer names", + count: diff.layerNameChanges.length, + rows: diff.layerNameChanges.map((change) => ({ + key: `layer-${change.layerIndex}`, + title: `Layer ${change.layerIndex + 1}`, + from: change.from ?? "-", + to: change.to, + })), + }, + { + label: "Modules", + count: diff.moduleChanges.length, + rows: diff.moduleChanges.slice(0, 12).map((change) => ({ + key: `module-${change.scope}-${change.layerIndex ?? "global"}-${change.moduleName}`, + title: + change.scope === "layer" + ? `Layer ${(change.layerIndex ?? 0) + 1} / ${change.moduleName}` + : `Global / ${change.moduleName}`, + from: change.from ? "Configured" : "-", + to: change.to ? "Configured" : "-", + })), + }, + { + label: "Combos", + count: diff.comboChanges.length, + rows: diff.comboChanges.slice(0, 8).map((change) => ({ + key: `combo-${change.index}`, + title: change.label, + from: change.from ? "Configured" : "-", + to: change.to ? "Configured" : "-", + })), + }, + { + label: "Macros", + count: diff.macroChanges.length, + rows: diff.macroChanges.slice(0, 8).map((change) => ({ + key: `macro-${change.index}`, + title: change.label, + from: change.from ? "Configured" : "-", + to: change.to ? "Configured" : "-", + })), + }, + ].filter((section) => section.count > 0); + + if (!sections.length) { + return ( +
+ Selected keymap already matches the connected keyboard. +
+ ); + } + + return ( +
+ {sections.map((section) => ( +
+
+

+ {section.label} +

+ + {section.count} + +
+
+ {section.rows.map((row) => ( +
+
+ {row.title} +
+
+ + From: + + {row.from} +
+
+ + To: + + {row.to} +
+
+ ))} + {section.count > section.rows.length && ( +

+ {section.count - section.rows.length} more changes +

+ )} +
+
+ ))} +
+ ); +} + +export function ImportExportPage() { + const appConnection = useContext(ConnectionContext); + const zmkApp = useContext(ZMKAppContext); + const [profile, setProfile] = useState(null); + const [keymaps, setKeymaps] = useState([]); + const [selectedKeymapId, setSelectedKeymapId] = useState(""); + const [loadedKeyboard, setLoadedKeyboard] = useState( + null, + ); + const [diff, setDiff] = useState(null); + const [isLoadingAuth, setIsLoadingAuth] = useState(false); + const [isLoadingData, setIsLoadingData] = useState(false); + const [isWriting, setIsWriting] = useState(false); + const [unlockRequired, setUnlockRequired] = useState(false); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + const abyss = useMemo(() => { + if (!abyssClientId || typeof window === "undefined") return null; + return createAbyssClient({ + clientId: abyssClientId, + redirectUri: cleanRedirectUri(), + scopes: ["profile:read", "keymap:read"], + abyssBaseUrl, + storage: window.sessionStorage, + transactionStorage: window.sessionStorage, + storageKey: abyssStorageKey, + transactionStorageKey: abyssTransactionStorageKey, + }); + }, []); + + const tokenSet = abyss?.getTokenSet() ?? null; + const selectedKeymap = useMemo( + () => keymaps.find((keymap) => keymap.id === selectedKeymapId) ?? null, + [keymaps, selectedKeymapId], + ); + const matchingKeymaps = useMemo(() => { + const matches = keymaps.filter((keymap) => + keymapMatchesKeyboard(keymap, loadedKeyboard?.keymap ?? null), + ); + return matches.length ? matches : keymaps; + }, [keymaps, loadedKeyboard?.keymap]); + + const loadKeyboard = useCallback(async (): Promise => { + if (!zmkApp?.state.connection) { + setLoadedKeyboard(null); + return null; + } + + const zmkConnection = { + method: "zmk", + transport: "usb", + deviceName: appConnection.deviceName, + rpcConnection: zmkApp.state.connection, + } as unknown as ZmkConnection; + + try { + const connection = await zmkAdapter.load(zmkConnection); + const loaded = { + connection, + keymap: connection.state.currentKeymap, + deviceName: + connection.deviceName ?? + appConnection.deviceName ?? + connection.state.preview.deviceName ?? + "Connected keyboard", + }; + setLoadedKeyboard(loaded); + setUnlockRequired(false); + return loaded; + } catch (caught) { + if (isFirmwareLockedError(caught)) { + setUnlockRequired(true); + setStatus("Unlock the keyboard in Studio, then retry."); + return null; + } + throw caught; + } + }, [appConnection.deviceName, zmkApp?.state.connection]); + + const loadAbyssData = useCallback( + async (keyboard: LoadedKeyboard | null) => { + if (!abyss?.getTokenSet()) return; + const [user, maps] = await Promise.all([ + abyss.userinfo(), + abyss.listMyKeymaps({ visibility: "all" }), + ]); + setProfile(user); + setKeymaps(maps); + const nextSelected = + maps.find((keymap) => + keymapMatchesKeyboard(keymap, keyboard?.keymap ?? null), + )?.id ?? + maps[0]?.id ?? + ""; + setSelectedKeymapId((current) => + current && maps.some((keymap) => keymap.id === current) + ? current + : nextSelected, + ); + }, + [abyss], + ); + + const refresh = useCallback(async () => { + if (!abyss?.getTokenSet()) return; + setIsLoadingData(true); + setError(null); + setStatus("Loading connected keyboard and Abyss keymaps..."); + try { + const keyboard = await loadKeyboard(); + await loadAbyssData(keyboard); + setStatus("Loaded Abyss keymaps."); + } catch (caught) { + setError(displayError(caught)); + setStatus(null); + } finally { + setIsLoadingData(false); + } + }, [abyss, loadAbyssData, loadKeyboard]); + + useEffect(() => { + if (!abyss) return; + if (!abyss.hasAuthorizationCode()) return; + let cancelled = false; + setIsLoadingAuth(true); + setError(null); + abyss + .handleRedirectCallback() + .then(async () => { + if (cancelled) return; + const cleanUrl = new URL(window.location.href); + cleanUrl.search = "?tab=import-export"; + window.history.replaceState({}, "", cleanUrl); + setStatus("Connected to Keyboard Abyss."); + await refresh(); + }) + .catch((caught) => { + if (!cancelled) setError(displayError(caught)); + }) + .finally(() => { + if (!cancelled) setIsLoadingAuth(false); + }); + return () => { + cancelled = true; + }; + }, [abyss, refresh]); + + useEffect(() => { + if (!tokenSet) return; + void refresh(); + }, [refresh, tokenSet]); + + useEffect(() => { + const target = selectedKeymap ? keymapData(selectedKeymap) : null; + if (!target || !loadedKeyboard) { + setDiff(null); + return; + } + setDiff( + compareKeyboardHubKeymaps( + target as FirmwareKeyboardHubKeymap, + loadedKeyboard.keymap, + ), + ); + }, [loadedKeyboard, selectedKeymap]); + + const login = useCallback(async () => { + if (!abyss) return; + setIsLoadingAuth(true); + setError(null); + try { + await abyss.startAuthorization(); + } catch (caught) { + setError(displayError(caught)); + setIsLoadingAuth(false); + } + }, [abyss]); + + const logout = useCallback(() => { + abyss?.clearTokenSet(); + setProfile(null); + setKeymaps([]); + setSelectedKeymapId(""); + setLoadedKeyboard(null); + setDiff(null); + setStatus("Disconnected from Keyboard Abyss."); + }, [abyss]); + + const writeDiff = useCallback(async () => { + if (!loadedKeyboard || !diff || isEmptyKeyboardHubKeymapDiff(diff)) return; + if ( + !confirm(`Write ${countDiff(diff)} changes to the connected keyboard?`) + ) { + return; + } + setIsWriting(true); + setError(null); + setStatus("Writing keymap diff..."); + try { + await zmkAdapter.writeKeymapDiff(loadedKeyboard.connection, diff); + setStatus("Keymap diff written."); + await refresh(); + } catch (caught) { + if (isFirmwareLockedError(caught)) { + setUnlockRequired(true); + setStatus("Unlock the keyboard in Studio, then retry."); + } else { + setError(displayError(caught)); + setStatus(null); + } + } finally { + setIsWriting(false); + } + }, [diff, loadedKeyboard, refresh]); + + const hasToken = Boolean(tokenSet); + const totalChanges = countDiff(diff); + + return ( +
+
+
+
+
+ +
+
+

+ Import/Export +

+

+ Sync keymaps with Keyboard Abyss +

+
+
+ +
+ {hasToken && ( + + )} + {hasToken ? ( + + ) : ( + + )} +
+
+ + {!abyss && ( +
+ +

+ Set VITE_ABYSS_CLIENT_ID to enable Keyboard Abyss login. +

+
+ )} + + {error && ( +
+ +

{error}

+
+ )} + + {unlockRequired && ( +
+ +

+ Keyboard needs Studio unlock before Abyss can read or write the + keymap. +

+ +
+ )} + + {status && ( +
+ {status} +
+ )} + +
+
+ Abyss + + {profile?.username ?? (hasToken ? "Connected" : "Not connected")} + +
+
+ Keyboard + + {loadedKeyboard?.deviceName ?? + appConnection.deviceName ?? + "Not loaded"} + +
+
+ Diff + {totalChanges} +
+
+ +
+
+
+ +

+ Abyss keymaps +

+
+ + {!hasToken ? ( +

+ Login to Keyboard Abyss to load keymaps. +

+ ) : isLoadingData && keymaps.length === 0 ? ( +
+ + Loading keymaps... +
+ ) : matchingKeymaps.length === 0 ? ( +

+ No Abyss keymaps are available. +

+ ) : ( +
+ {matchingKeymaps.map((keymap) => ( + + ))} +
+ )} +
+ +
+
+ +

+ Diff preview +

+ +
+ + {!selectedKeymap ? ( +

+ Select an Abyss keymap to preview changes. +

+ ) : !loadedKeyboard ? ( +

+ Load the connected keyboard to compare keymaps. +

+ ) : diff ? ( + + ) : ( +
+ + Preparing diff... +
+ )} +
+
+
+
+ ); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index e460e58..5091b4a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,14 @@ /// +interface ImportMetaEnv { + readonly VITE_ABYSS_CLIENT_ID?: string; + readonly VITE_ABYSS_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + declare module "*.svg?react" { import React from "react"; const SVGComponent: React.FC>; From f3cd5baea13f293bae24ef929facb8113d2f1b99 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 21:01:08 +0900 Subject: [PATCH 2/2] Move import export tab to end --- src/App.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7197882..7229ed0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,12 +43,6 @@ const tabs: TabItem[] = [ icon: , content: , }, - { - id: "import-export", - label: "Import/Export", - icon: , - content: , - }, { id: "trackball", label: "Trackball", @@ -79,6 +73,12 @@ const tabs: TabItem[] = [ icon: , content: , }, + { + id: "import-export", + label: "Import/Export", + icon: , + content: , + }, ]; function App() {