16 Commits

Author SHA1 Message Date
d9183f952a 2.0.2 2024-11-08 15:48:26 +01:00
913a833824 fix: build 2024-11-08 15:47:20 +01:00
0d6ef4d011 2.0.1 2024-11-08 15:43:23 +01:00
232045964c fix: firmware updates 2024-11-08 15:42:58 +01:00
3659b80e41 fix: firmware cannot be linked 2024-11-08 15:21:53 +01:00
3a02caeb6d fix: pre-production devices are not recognized by the device manager 2024-11-07 21:53:59 +01:00
259fd3a989 fix: stable pipeline 2024-11-05 02:51:21 +01:00
dcf1d89fa0 2.0.0 2024-11-05 02:39:46 +01:00
c79237ce22 move matrix init 2024-11-05 02:38:34 +01:00
d68f1b19fa update dependencies 2024-11-05 02:34:40 +01:00
9cb36662b3 polish 2024-11-05 02:03:08 +01:00
b4605fe84d feat: improve UF2 flow 2024-11-03 14:39:35 +01:00
06d122b5d6 feat: add changed pids 2024-10-18 12:18:49 +02:00
3d25b030c6 feat: explicit reboot after ota update 2024-10-16 19:34:16 +02:00
bf490ba823 fix: swapped pid/vid 2024-10-04 18:08:26 +02:00
397f4bb6a9 update compatibility list 2024-10-03 21:32:58 +02:00
36 changed files with 1673 additions and 1572 deletions

View File

@@ -41,7 +41,7 @@ jobs:
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Publish Stable
if: ${{ github.ref == 'refs/heads/v*' }}
if: ${{ github.ref == 'refs/tags/v*' }}
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/
- name: Publish Branch

View File

@@ -4,6 +4,7 @@ const config = {
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
outputPath: "src/lib/assets/icons.min.woff2",
icons: [
"deployed_code_update",
"adjust",
"add",
"piano",

View File

@@ -1,6 +1,6 @@
{
"name": "charachorder-device-manager",
"version": "1.5.2",
"version": "2.0.2",
"license": "AGPL-3.0-or-later",
"private": true,
"engines": {
@@ -34,61 +34,62 @@
"typesafe-i18n": "typesafe-i18n"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.17.0",
"@codemirror/commands": "^6.6.0",
"@codemirror/autocomplete": "^6.18.2",
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.10.2",
"@codemirror/language": "^6.10.3",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.29.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.36",
"@fontsource-variable/noto-sans-mono": "^5.0.20",
"@lezer/highlight": "^1.2.0",
"@codemirror/view": "^6.34.1",
"@fontsource-variable/material-symbols-rounded": "^5.1.3",
"@fontsource-variable/noto-sans-mono": "^5.1.0",
"@lezer/highlight": "^1.2.1",
"@material/material-color-utilities": "^0.3.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.83.0",
"@melt-ui/svelte": "^0.86.0",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@sveltejs/adapter-static": "^3.0.2",
"@sveltejs/kit": "^2.5.18",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.7.5",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "^1.6.0",
"@types/dom-view-transitions": "^1.0.5",
"@types/flexsearch": "^0.7.6",
"@types/w3c-web-serial": "^1.0.6",
"@types/w3c-web-serial": "^1.0.7",
"@types/w3c-web-usb": "^1.0.10",
"@vite-pwa/sveltekit": "^0.6.0",
"autoprefixer": "^10.4.19",
"@types/wicg-file-system-access": "^2023.10.5",
"@vite-pwa/sveltekit": "^0.6.6",
"autoprefixer": "^10.4.20",
"codemirror": "^6.0.1",
"cypress": "^13.13.2",
"d3": "^7.9.0",
"flexsearch": "^0.7.43",
"fontkit": "^2.0.2",
"fontkit": "^2.0.4",
"glob": "^11.0.0",
"jsdom": "^24.1.1",
"matrix-js-sdk": "^34.4.0",
"jsdom": "^25.0.1",
"matrix-js-sdk": "^34.9.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-svelte": "^3.2.7",
"rxjs": "^7.8.1",
"sass": "^1.77.8",
"socket.io-client": "^4.7.5",
"stylelint": "^16.8.1",
"sass": "^1.80.6",
"socket.io-client": "^4.8.1",
"stylelint": "^16.10.0",
"stylelint-config-clean-order": "^6.1.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^14.1.0",
"stylelint-config-standard-scss": "^13.1.0",
"svelte": "5.0.0-next.221",
"svelte-check": "^3.8.5",
"svelte-preprocess": "^6.0.2",
"svelte": "5.1.9",
"svelte-check": "^4.0.5",
"svelte-preprocess": "^6.0.3",
"tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vite-plugin-mkcert": "^1.17.5",
"vite-plugin-pwa": "^0.20.1",
"vitest": "^2.0.5",
"workbox-window": "^7.1.0"
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-mkcert": "^1.17.6",
"vite-plugin-pwa": "^0.20.5",
"vitest": "^2.1.4",
"workbox-window": "^7.3.0"
},
"type": "module"
}

1830
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "app"
version = "1.5.2"
version = "2.0.2"
description = "A Tauri App"
authors = ["Thea Schöbl <dev@theaninova.de>"]
license = "AGPL-3"

View File

@@ -6,7 +6,7 @@
"devPath": "http://localhost:5173",
"distDir": "../build"
},
"package": { "productName": "amacc1ng", "version": "1.5.2" },
"package": { "productName": "amacc1ng", "version": "2.0.2" },
"tauri": {
"allowlist": { "all": false },
"bundle": {

View File

@@ -17,11 +17,11 @@ const de = {
RELOAD: "Neu laden",
},
backup: {
TITLE: "Lokale Kopie",
INDIVIDUAL: "Einzeldateien",
TITLE: "Backup",
AUTO_BACKUP: "Auto-backup",
DISCLAIMER:
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Alles herunterladen",
DOWNLOAD: "Alles",
RESTORE: "Wiederherstellen",
},
modal: {
@@ -109,7 +109,7 @@ const de = {
},
configure: {
chords: {
TITLE: "Akkorde",
TITLE: "Bibliothek",
HOLD_KEYS: "Akkord halten",
NEW_CHORD: "Neuer Akkord",
DUPLICATE: "Akkord existiert bereits",
@@ -131,7 +131,7 @@ const de = {
TITLE: "Layout",
},
settings: {
TITLE: "Einstellungen",
TITLE: "Gerät",
},
},
plugin: {

View File

@@ -13,11 +13,11 @@ const en = {
TITLE: "Update your device",
},
backup: {
TITLE: "Local backup",
INDIVIDUAL: "Individual backups",
TITLE: "Backup",
AUTO_BACKUP: "Auto-backup",
DISCLAIMER:
"A backup is made and stored in this browser, and always remains only on your computer.",
DOWNLOAD: "Download Everything",
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
DOWNLOAD: "Everything",
RESTORE: "Restore",
},
sync: {
@@ -108,7 +108,7 @@ const en = {
},
configure: {
chords: {
TITLE: "Chords",
TITLE: "Library",
HOLD_KEYS: "Hold chord",
NEW_CHORD: "New chord",
DUPLICATE: "Chord already exists",
@@ -130,7 +130,7 @@ const en = {
TITLE: "Layout",
},
settings: {
TITLE: "Settings",
TITLE: "Device",
},
},
plugin: {

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing";
import type { Snippet } from "svelte";
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } =
$props();
let inDirection = $state(0);
let outDirection = $state(0);
let outroEnd: undefined | (() => void) = $state(undefined);
let animationDone: Promise<void>;
let isNavigating = $state(false);
function routeIndex(route: string | undefined): number {
return routeOrder.findIndex((it) => route?.startsWith(it));
}
beforeNavigate((navigation) => {
const from = routeIndex(navigation.from?.url.pathname);
const to = routeIndex(navigation.to?.url.pathname);
if (from === -1 || to === -1 || from === to) return;
isNavigating = true;
inDirection = from > to ? -1 : 1;
outDirection = from > to ? 1 : -1;
animationDone = new Promise((resolve) => {
outroEnd = resolve;
});
});
afterNavigate(async () => {
await animationDone;
isNavigating = false;
});
</script>
{#if !isNavigating}
<main
in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
}
</style>

View File

@@ -17,5 +17,11 @@
<style lang="scss">
p {
margin-block: 0;
:global(kbd.icon) {
display: inline-flex;
font-size: inherit;
translate: 0 0.2em;
}
}
</style>

View File

@@ -70,7 +70,6 @@
width: 100%;
height: 100%;
margin-bottom: 96px;
}
fieldset {

View File

@@ -53,11 +53,13 @@ export interface ProgressInfo {
}
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
export async function initSerial(manual = false) {
export async function initSerial(manual = false, withSync = true) {
const device = get(serialPort) ?? new CharaDevice();
await device.init(manual);
serialPort.set(device);
await sync();
if (withSync) {
await sync();
}
}
export async function sync() {

View File

@@ -12,7 +12,8 @@ import { browser } from "$app/environment";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["TWO S3", { usbProductId: 0x0056, usbVendorId: 0x2886 }],
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 33163, usbVendorId: 12346 }],
@@ -506,11 +507,25 @@ export class CharaDevice {
return it;
});
await this.suspend();
if (result !== "OTA OK") {
throw new Error(result);
}
const writer2 = this.port.writable!.getWriter();
try {
await writer2.write(new TextEncoder().encode(`RST RESTART\r\n`));
serialLog.update((it) => {
it.push({
type: "input",
value: "RST RESTART",
});
return it;
});
} finally {
writer2.releaseLock();
}
await this.suspend();
} finally {
delete this.lock;
resolveLock!(true);

View File

@@ -29,7 +29,6 @@
import { restoreFromFile } from "$lib/backup/backup";
import { goto } from "$app/navigation";
import { hotkeys } from "$lib/title";
import { initMatrixClient } from "$lib/chat/chat";
const locale =
((browser && localStorage.getItem("locale")) as Locales) || detectLocale();
@@ -66,9 +65,6 @@
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
await initSerial();
}
if (browser) {
await initMatrixClient();
}
if (data.importFile) {
restoreFromFile(data.importFile);
@@ -111,7 +107,7 @@
</script>
<svelte:head>
<!--{@html webManifestLink}-->
{@html webManifestLink}
<title>{$LL.TITLE()}</title>
<meta name="description" content={$LL.DESCRIPTION()} />
<meta name="theme-color" content={data.themeColor} />

View File

@@ -1,97 +0,0 @@
<script lang="ts">
import { preference } from "$lib/preferences";
import LL from "$i18n/i18n-svelte";
import {
createChordBackup,
createLayoutBackup,
createSettingsBackup,
downloadBackup,
downloadFile,
restoreBackup,
} from "$lib/backup/backup";
</script>
<section>
<h2>
<label
><input
type="checkbox"
use:preference={"backup"}
/>{$LL.backup.TITLE()}</label
>
</h2>
<p class="disclaimer">
<i>{$LL.backup.DISCLAIMER()}</i>
</p>
<fieldset>
<legend>{$LL.backup.INDIVIDUAL()}</legend>
<button onclick={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
{$LL.configure.settings.TITLE()}
</button>
</fieldset>
<div class="save">
<button class="primary" onclick={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</section>
<style lang="scss">
h2 {
margin-block-end: 0;
> label {
gap: 10px;
font-size: 24px;
> input {
font-size: 12px;
}
}
}
fieldset {
display: flex;
margin-block: 16px;
border: 1px solid currentcolor;
border-radius: 16px;
}
section {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: min-content;
}
.disclaimer {
max-width: 16cm;
font-size: 12px;
opacity: 0.7;
}
input[type="file"] {
display: none;
}
.save {
display: flex;
gap: 4px;
}
</style>

View File

@@ -1,256 +0,0 @@
<script lang="ts">
import { initSerial, serialPort } from "$lib/serial/connection";
import { browser } from "$app/environment";
import { slide, fade } from "svelte/transition";
import { preference } from "$lib/preferences";
import LL from "$i18n/i18n-svelte";
import { downloadBackup } from "$lib/backup/backup";
function reboot() {
$serialPort?.reboot();
$serialPort = undefined;
powerDialog = false;
setTimeout(() => {
initSerial();
}, 1000);
}
function bootloader() {
downloadBackup();
$serialPort?.bootloader();
$serialPort = undefined;
rebootInfo = true;
powerDialog = false;
}
async function connect() {
try {
await initSerial(true);
} catch (error) {
console.error(error);
alert(
"Connection failed. Is your device maybe pre-CCOS? Refer to the doc link in the bottom left for more information on your device.",
);
}
}
let rebootInfo = $derived($serialPort !== undefined);
let terminal = $state(false);
let powerDialog = $state(false);
</script>
<section>
<div class="row">
<h2>{$LL.deviceManager.TITLE()}</h2>
<label
>{$LL.deviceManager.AUTO_CONNECT()}<input
type="checkbox"
use:preference={"autoConnect"}
/></label
>
</div>
{#if $serialPort}
<p transition:slide>
{$serialPort.company}
{$serialPort.device}
{$serialPort.chipset}
<br />
Version {$serialPort.version}
</p>
{#if $serialPort.version.toString() !== import.meta.env.VITE_LATEST_FIRMWARE}
<a
href="https://docs.charachorder.com/CharaChorder%20One.html#updating-the-firmware"
>Firmware Update Instructions</a
>
{/if}
<!--<button on:click={updateFirmware}>Update</button>-->
{/if}
{#if browser}
{#if navigator.userAgent.includes("Linux") && !$serialPort}
<div class="linux-info">
<p>{@html $LL.deviceManager.LINUX_PERMISSIONS()}</p>
<p>
In most cases you can simply follow the <a
target="_blank"
href="https://docs.arduino.cc/software/ide-v1/tutorials/Linux#please-read"
>Arduino Guide</a
> on serial port permissions.
</p>
<p>Special systems:</p>
<ul>
<li>
<a
target="_blank"
href="https://wiki.archlinux.org/title/Arduino#Accessing_serial"
>Arch and Arch-based like Manjaro or EndeavourOS</a
>
</li>
<li>
<a
target="_blank"
href="https://gist.github.com/CMCDragonkai/d00201ec143c9f749fc49533034e5009?permalink_comment_id=4670311#gistcomment-4670311"
>NixOS</a
>
</li>
<li>
<a
target="_blank"
href="https://wiki.gentoo.org/wiki/Arduino#Grant_access_to_non-root_users"
>Gentoo</a
>
</li>
</ul>
</div>
{/if}
{#if rebootInfo}
<p transition:slide>
<b>{$LL.deviceManager.bootMenu.POWER_WARNING()}</b>
</p>
{/if}
<div class="row">
{#if $serialPort}
<button
class="secondary"
onclick={() => {
$serialPort?.forget();
$serialPort = undefined;
}}
><span class="icon">usb_off</span
>{$LL.deviceManager.DISCONNECT()}</button
>
{:else}
<button class="error" onclick={connect}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
>
{/if}
<div class="row" style="justify-content: flex-end">
<a
href="/terminal"
title={$LL.deviceManager.TERMINAL()}
class="icon"
class:disabled={$serialPort === undefined}
onclick={() => (terminal = !terminal)}>terminal</a
>
<button
class="icon"
title={$LL.deviceManager.bootMenu.TITLE()}
disabled={$serialPort === undefined}
onclick={() => (powerDialog = !powerDialog)}>settings_power</button
>
</div>
</div>
{#if powerDialog}
<div
class="backdrop"
role="button"
tabindex="-1"
transition:fade={{ duration: 250 }}
onclick={() => (powerDialog = !powerDialog)}
onkeypress={(event) => {
if (event.key === "Enter") powerDialog = !powerDialog;
}}
></div>
<dialog open transition:slide={{ duration: 250 }}>
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
<button onclick={reboot}
><span class="icon">restart_alt</span
>{$LL.deviceManager.bootMenu.REBOOT()}</button
>
<button onclick={bootloader}
><span class="icon">rule_settings</span
>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
>
</dialog>
{/if}
{/if}
</section>
<style lang="scss">
h2 {
margin-block: 8px;
}
p {
margin-block: 8px;
}
.linux-info a {
display: inline;
padding-inline: 0;
text-decoration: underline;
}
section {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 300px;
}
.backdrop {
position: absolute;
z-index: 1;
inset: 0;
background: #0005;
border-radius: 40px;
}
dialog {
position: relative;
z-index: 2;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
margin: 0;
margin-block-start: 16px;
padding: 0;
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
border: none;
border-radius: 32px;
}
.row {
display: flex;
gap: 0;
justify-content: space-between;
width: 100%;
height: fit-content;
}
dialog > * {
margin-inline: 16px;
}
dialog > :first-child {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin: 0;
padding-block: 8px;
color: var(--md-sys-color-on-secondary);
background: var(--md-sys-color-secondary);
}
button:active:not(:disabled) {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
}
</style>

View File

@@ -8,11 +8,25 @@
import { loadLocaleAsync } from "$i18n/i18n-util.async";
import { tick } from "svelte";
import SyncOverlay from "./SyncOverlay.svelte";
import { serialPort } from "$lib/serial/connection";
import {
initSerial,
serialPort,
sync,
syncProgress,
syncStatus,
} from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
let locale = $state(
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
);
let currentDevice = $derived(
$serialPort
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
: undefined,
);
$effect(() => {
if (!browser) return;
localStorage.setItem("locale", locale);
@@ -33,6 +47,26 @@
}
}
async function connect() {
try {
await initSerial(true);
} catch (error) {
console.error(error);
alert(
"Connection failed. Is your device maybe pre-CCOS? Refer to the doc link in the bottom left for more information on your device.",
);
}
}
function disconnect(event: MouseEvent) {
if (event.shiftKey) {
sync();
} else {
$serialPort?.forget();
$serialPort = undefined;
}
}
let languageSelect: HTMLSelectElement;
</script>
@@ -40,39 +74,58 @@
<ul>
<li>
<a
use:action={{ title: "Branch" }}
href={import.meta.env.VITE_HOMEPAGE_URL}
rel="noreferrer"
target="_blank"><span class="icon">commit</span> v{version}</a
>
</li>
<li>
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
><span class="icon">bug_report</span> Issues</a
>
</li>
<li>
<a href={import.meta.env.VITE_DOCS_URL} rel="noreferrer" target="_blank"
><span class="icon">description</span> Docs</a
<a
href="/ccos/{currentDevice ? `${currentDevice}/` : ''}"
use:action={{ title: "Updates" }}
>
CCOS {$serialPort?.version ?? "Updates"}
</a>
</li>
</ul>
<div>
<div class="sync-box">
{#if !$serialPort}
<div class="warning">
<span class="icon">warning</span>{$LL.deviceManager.NO_DEVICE()}
</div>
<button class="warning" onclick={connect} transition:slide={{ axis: "x" }}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
>
{:else}
<button
transition:slide={{ axis: "x" }}
onclick={disconnect}
use:action={{
title: "Disconnect<br><kbd class='icon'>shift</kbd> Sync",
}}
><b
>{$serialPort.company}
{$serialPort.device}
{$serialPort.chipset}</b
><span class="icon">usb_off</span></button
>
{/if}
{#if $syncStatus !== "done"}
<progress
transition:fade
max={$syncProgress?.max ?? 1}
value={$syncProgress?.current ?? 1}
></progress>
{/if}
<SyncOverlay />
</div>
<ul>
<li>
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
><span class="icon">shopping_bag</span> Store</a
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
><span class="icon">bug_report</span> Bugs</a
>
</li>
<li>
<a href={import.meta.env.VITE_LEARN_URL} rel="noreferrer" target="_blank"
><span class="icon">school</span> Train</a
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
><span class="icon">shopping_bag</span> Store</a
>
</li>
<li class="hide-forced-colors">
@@ -101,7 +154,7 @@
</button>
{/if}
</li>
<li>
<!--<li>
<div
role="button"
class="icon"
@@ -116,7 +169,7 @@
{/each}
</select>
</div>
</li>
</li>-->
</ul>
</footer>
@@ -126,6 +179,37 @@
opacity: 0;
}
.sync-box {
display: flex;
align-items: center;
justify-content: center;
position: relative;
button {
text-wrap: nowrap;
}
}
progress {
position: absolute;
z-index: -1;
bottom: 0;
left: 16px;
right: 16px;
overflow: hidden;
width: calc(100% - 32px);
height: 8px;
border-radius: 4px;
}
progress::-webkit-progress-bar {
background: var(--md-sys-color-background);
}
progress::-webkit-progress-value {
background: var(--md-sys-color-primary);
}
.warning {
color: var(--md-sys-color-error);
gap: 8px;

View File

@@ -14,27 +14,26 @@
let isNavigating = $state(false);
const routeOrder = [
"/config/chords/",
"/config/layout/",
"/config/settings/",
"/config",
"/learn",
"/docs",
"/editor",
"/chat",
"/plugin",
];
function routeIndex(route: string | undefined): number {
return routeOrder.findIndex((it) => route?.startsWith(it));
}
beforeNavigate((navigation) => {
const from = navigation.from?.url.pathname;
const to = navigation.to?.url.pathname;
if (from === to) return;
const from = routeIndex(navigation.from?.url.pathname);
const to = routeIndex(navigation.to?.url.pathname);
if (from === -1 || to === -1 || from === to) return;
isNavigating = true;
if (!(from && to && routeOrder.includes(from) && routeOrder.includes(to))) {
inDirection = 0;
outDirection = 0;
} else {
const fromIndex = routeOrder.indexOf(from);
const toIndex = routeOrder.indexOf(to);
inDirection = fromIndex > toIndex ? -1 : 1;
outDirection = fromIndex > toIndex ? 1 : -1;
}
inDirection = from > to ? -1 : 1;
outDirection = from > to ? 1 : -1;
animationDone = new Promise((resolve) => {
outroEnd = resolve;
@@ -49,8 +48,8 @@
{#if !isNavigating}
<main
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }}
in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}

View File

@@ -1,36 +1,45 @@
<script lang="ts">
import { browser } from "$app/environment";
import { LL } from "$i18n/i18n-svelte";
import { popup } from "$lib/popup";
import { userPreferences } from "$lib/preferences";
import { serialPort, syncStatus } from "$lib/serial/connection";
import { action } from "$lib/title";
import BackupPopup from "./BackupPopup.svelte";
import ConnectionPopup from "./ConnectionPopup.svelte";
import { onMount } from "svelte";
onMount(async () => {
if (browser && !$userPreferences.autoConnect) {
connectButton.click();
}
});
import { page } from "$app/stores";
const routes = [
[
{ href: "/config/chords/", icon: "dictionary", title: "Chords" },
{
href: "/config/settings/",
icon: "cable",
title: "Device",
primary: true,
},
{ href: "/config/chords/", icon: "dictionary", title: "Library" },
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
{ href: "/config/settings/", icon: "tune", title: "Config" },
],
[
{ href: "/learn", icon: "school", title: "Learn", wip: true },
{ href: "/learn", icon: "description", title: "Docs" },
// { href: "/learn", icon: "school", title: "Learn", wip: true },
{
href: import.meta.env.VITE_LEARN_URL,
icon: "school",
title: "Learn",
external: true,
},
{
href: import.meta.env.VITE_DOCS_URL,
icon: "description",
title: "Docs",
external: true,
},
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
],
[
/*[
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
],
] satisfies { href: string; icon: string; title: string; wip?: boolean }[][];
],*/
] satisfies {
href: string;
icon: string;
title: string;
wip?: boolean;
external?: boolean;
primary?: boolean;
}[][];
let connectButton: HTMLButtonElement;
</script>
@@ -39,10 +48,18 @@
<nav>
{#each routes as group}
<ul>
{#each group as { href, icon, title, wip }}
{#each group as { href, icon, title, wip, external }}
<li>
<a class:wip {href}>
<div class="icon">{icon}</div>
<a
class:wip
{href}
rel={external ? "noreferrer" : undefined}
target={external ? "_blank" : undefined}
class:active={$page.url.pathname.startsWith(href)}
>
<div class="icon">
{icon}
</div>
<div class="content">
{title}
</div>
@@ -52,28 +69,6 @@
</ul>
{/each}
</nav>
<ul class="sidebar-footer">
<li>
<button
bind:this={connectButton}
use:action={{ title: $LL.deviceManager.TITLE() }}
use:popup={ConnectionPopup}
class="icon connect"
class:error={$serialPort === undefined}
>
cable
</button>
</li>
<li>
<button
use:action={{ title: $LL.backup.TITLE() }}
use:popup={BackupPopup}
class="icon {$syncStatus}"
>
account_circle
</button>
</li>
</ul>
</div>
<style lang="scss">
@@ -109,12 +104,30 @@
display: flex;
justify-content: center;
font-size: 24px;
padding: 8px;
border-radius: 8px;
transition: all 250ms ease;
}
> .content {
display: flex;
justify-content: center;
align-items: center;
translate: 0 -8px;
transition: all 250ms ease;
}
&.active {
> .content {
translate: 0;
}
.icon {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-radius: 50%;
}
}
}

View File

@@ -2,7 +2,7 @@
let { children } = $props();
</script>
<h1><a href="/ota-update/">Firmware Update</a></h1>
<h1><a href="/ccos">Firmware Updates</a></h1>
{@render children()}
@@ -11,6 +11,5 @@
margin-block: 1em;
padding: 0;
font-size: 3em;
font-weight: 400;
}
</style>

View File

@@ -4,15 +4,6 @@
let { data } = $props();
let files: FileList | null = $state(null);
$effect(() => {
const file = files?.[0];
if (file && $serialPort) {
$serialPort.updateFirmware(file);
}
});
let currentDevice = $derived(
$serialPort
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
@@ -34,8 +25,6 @@
<aside transition:slide>Connect your device to see which one you need</aside>
{/if}
<input type="file" accept=".bin" bind:files />
<style lang="scss">
ul {
display: flex;

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { serialPort } from "$lib/serial/connection";
import { slide } from "svelte/transition";
import { downloadBackup } from "$lib/backup/backup";
import { initSerial, serialPort } from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
let { data } = $props();
@@ -8,6 +9,8 @@
let success = $state(false);
let error = $state<Error | undefined>(undefined);
let step = $state(0);
async function update() {
working = true;
error = undefined;
@@ -59,18 +62,102 @@
return `${(value / 1024 / 1024).toFixed(2)}MB`;
}
}
async function connect() {
try {
await initSerial(true, false);
step = 1;
} catch (e) {
error = e as Error;
}
}
function backup() {
downloadBackup();
step = 2;
}
function bootloader() {
$serialPort?.bootloader();
$serialPort = undefined;
step = 3;
}
async function getFileSystem() {
if (!uf2Url) return;
const uf2Promise = fetch(uf2Url).then((it) => it.blob());
const handle = await window.showSaveFilePicker({
id: `${data.device}-update`,
suggestedName: "CURRENT.UF2",
excludeAcceptAllOption: true,
types: [
{
description: "UF2 Firmware",
accept: { "application/octet-stream": [".UF2"] },
},
],
});
const writable = await handle.createWritable();
const uf2 = await uf2Promise;
await uf2.stream().pipeTo(writable);
step = 4;
}
</script>
<div>
<div class="container">
<h2>
Update <em
class="device"
<a class="inline-link" href="/ccos">CCOS</a> /
<a
href="/ccos/{data.device}"
class="device inline-link"
class:correct-device={isCorrectDevice === true}
class:incorrect-device={isCorrectDevice === false}>{data.device}</em
class:incorrect-device={isCorrectDevice === false}>{data.device}</a
>
to <em class="version">{data.version}</em>
/ <em class="version">{data.version}</em>
</h2>
{#if data.ota && !data.device.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)}
<section>
<button
class="update-button"
class:working
class:primary={!buttonError}
class:error={buttonError}
disabled={working || $serialPort === undefined || !isCorrectDevice}
onclick={update}>Apply Update</button
>
{#if $serialPort && isCorrectDevice}
<div transition:slide>
Your
<b
>{$serialPort.company}
{$serialPort.device}
{$serialPort.chipset}</b
>
will be updated from <b class="version">{$serialPort.version}</b> to
<b class="version">{data.version}</b>
</div>
{:else if $serialPort && isCorrectDevice === false}
<div class="error" transition:slide>
Your device is incompatible with the selected update.
</div>
{:else if success}
<div class="primary" transition:slide>Update successful</div>
{:else if error}
<div class="error" transition:slide>{error.message}</div>
{:else if working}
<div class="primary" transition:slide>Updating your device...</div>
{:else}
<div class="primary" transition:slide>
Connect your device to continue
</div>
{/if}
</section>
<h3>Manual Update</h3>
{/if}
<ul class="files">
{#if data.uf2}
<li>
@@ -99,59 +186,47 @@
{/if}
<section>
<h3>OTA Upate</h3>
{#if data.ota}
{@const buttonError = error || (!success && isCorrectDevice === false)}
<button
class:working
class:primary={!buttonError}
class:error={buttonError}
disabled={working || $serialPort === undefined || !isCorrectDevice}
onclick={update}>Apply Update</button
>
{#if $serialPort && isCorrectDevice}
<div transition:slide>
Your device is ready and compatible. Click the button to perform the
update.
</div>
{:else if $serialPort && isCorrectDevice === false}
<div class="error" transition:slide>
Your device is incompatible with the selected update.
</div>
{:else if success}
<div class="primary" transition:slide>Update successful</div>
{:else if error}
<div class="error" transition:slide>{error.message}</div>
{:else if working}
<div class="primary" transition:slide>Updating your device...</div>
{:else}
<div class="primary" transition:slide>
Connect your device to continue
</div>
{/if}
{:else}
<em>There are no OTA files for this device.</em>
{/if}
</section>
<hr />
<h3>Other options</h3>
<section>
<h4>Via UF2</h4>
<h4>UF2 Instructions</h4>
<ol>
<li>Backup your device</li>
<li>Reboot to bootloader</li>
<li>Save CURRENT.UF2 to the new drive</li>
<li>Restore</li>
<li>
<button class="inline-button" onclick={connect}
><span class="icon">usb</span>Connect</button
>
your device
{#if step >= 1}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 1}>
Make a <button class="inline-button" onclick={backup}
><span class="icon">download</span>Backup</button
>
{#if step >= 2}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 2}>
Reboot to <button class="inline-button" onclick={bootloader}
><span class="icon">restart_alt</span>Bootloader</button
>
{#if step >= 3}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 3}>
Replace <button class="inline-button" onclick={getFileSystem}
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
>
on the new drive
{#if step >= 4}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
</ol>
</section>
<section>
<h4>Via Serial</h4>
<p>WIP</p>
</section>
ading 0 Chordmaps.
</div>
<style lang="scss">
@@ -160,6 +235,10 @@
transition: color 200ms ease;
}
h3 {
margin-block-start: 4em;
}
.primary {
color: var(--md-sys-color-primary);
}
@@ -168,6 +247,10 @@
color: var(--md-sys-color-error);
}
.container {
width: calc(min(100%, 16cm));
}
@keyframes rotate {
0% {
transform: rotate(120deg);
@@ -189,7 +272,32 @@
}
}
button {
button.inline-button {
display: inline;
padding: 0;
margin: 0;
height: unset;
font-size: inherit;
color: var(--md-sys-color-primary);
.icon {
font-size: 1.2em;
translate: 0 0.1em;
padding-inline-end: 0.2em;
}
}
.icon.ok {
font-size: 1.2em;
translate: 0 0.1em;
--icon-fill: 1;
}
.faded {
opacity: 0.8;
}
button.update-button {
overflow: hidden;
position: relative;
height: 42px;
@@ -250,26 +358,26 @@
display: flex;
padding: 0;
gap: 8px;
}
a {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr;
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
font-size: 0.9em;
height: auto;
a[download] {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr;
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
font-size: 0.9em;
height: auto;
.size {
font-size: 0.8em;
opacity: 0.8;
}
.size {
font-size: 0.8em;
opacity: 0.8;
}
.icon {
padding-inline-start: 0.4em;
grid-column: 2;
grid-row: 1 / span 2;
}
.icon {
padding-inline-start: 0.4em;
grid-column: 2;
grid-row: 1 / span 2;
}
}
@@ -281,6 +389,11 @@
opacity: 0.6;
}
.inline-link {
display: inline;
padding: 0;
}
.correct-device {
color: var(--md-sys-color-primary);
opacity: 1;

View File

@@ -1,8 +1,16 @@
<script lang="ts">
import { isLoggedIn, matrix } from "$lib/chat/chat";
import { initMatrixClient, isLoggedIn, matrix } from "$lib/chat/chat";
import { flip } from "svelte/animate";
import { slide } from "svelte/transition";
import Login from "./Login.svelte";
import { onMount } from "svelte";
import { browser } from "$app/environment";
onMount(async () => {
if (browser) {
await initMatrixClient();
}
});
let { children } = $props();

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Snippet } from "svelte";
import PageTransition from "./PageTransition.svelte";
import Navigation from "./Navigation.svelte";
let { children }: { children?: Snippet } = $props();
@@ -8,5 +9,9 @@
<Navigation />
{#if children}
{@render children()}
<PageTransition>
{#if children}
{@render children()}
{/if}
</PageTransition>
{/if}

View File

@@ -1,60 +0,0 @@
<script lang="ts">
import { page } from "$app/stores";
import LL from "$i18n/i18n-svelte";
import type { Snippet } from "svelte";
let { children }: { children?: Snippet } = $props();
let paths = $derived([
{
href: "/config/chords/",
title: $LL.configure.chords.TITLE(),
icon: "piano",
},
{
href: "/config/layout/",
title: $LL.configure.layout.TITLE(),
icon: "keyboard",
},
{
href: "/config/settings/",
title: $LL.configure.settings.TITLE(),
icon: "settings",
},
]);
</script>
<nav>
{#each paths as { href, title, icon }}
<a {href} class:active={$page.url.pathname.startsWith(href)}>
<span class="icon">{icon}</span>
{title}
</a>
{/each}
</nav>
{#if children}
{@render children()}
{/if}
<style lang="scss">
nav {
display: flex;
gap: 8px;
padding: 8px;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
border-radius: 32px;
}
a.active {
--icon-fill: 1;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
</style>

View File

@@ -3,8 +3,8 @@
import { canShare, triggerShare } from "$lib/share";
import { action } from "$lib/title";
import LL from "$i18n/i18n-svelte";
import ConfigTabs from "./ConfigTabs.svelte";
import EditActions from "./EditActions.svelte";
import { sync, syncStatus } from "$lib/serial/connection";
</script>
<nav>
@@ -12,8 +12,6 @@
<EditActions />
</div>
<ConfigTabs />
<div class="actions">
{#if $canShare}
<button
@@ -40,7 +38,7 @@
<style lang="scss">
nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-columns: 1fr 1fr;
width: calc(min(100%, 28cm));
margin-block: 8px;
@@ -48,6 +46,20 @@
padding-inline: 16px;
}
@keyframes syncing {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.syncing {
transform-origin: 50% 49%;
animation: syncing 1s linear infinite;
}
.title {
display: flex;
align-items: center;

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing";
import { type Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
let inDirection = $state(0);
let outDirection = $state(0);
let done: undefined | (() => void) = $state(undefined);
let animationDone: Promise<void>;
let isNavigating = $state(false);
const routeOrder = [
"/config/settings/",
"/config/chords/",
"/config/layout/",
];
function outroEnd() {
done?.();
}
beforeNavigate((navigation) => {
const from = navigation.from?.url.pathname;
const to = navigation.to?.url.pathname;
if (from === to) return;
isNavigating = true;
if (!(from && to && routeOrder.includes(from) && routeOrder.includes(to))) {
inDirection = 0;
outDirection = 0;
} else {
const fromIndex = routeOrder.indexOf(from);
const toIndex = routeOrder.indexOf(to);
inDirection = fromIndex > toIndex ? -1 : 1;
outDirection = fromIndex > toIndex ? 1 : -1;
}
animationDone = new Promise((resolve) => {
done = resolve;
});
});
afterNavigate(async () => {
await animationDone;
isNavigating = false;
});
</script>
{#if !isNavigating}
<main
in:fly={{
y: inDirection * 24,
duration: 150,
delay: 1, // flicker for some reason without this
easing: expoOut,
}}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
width: 100%;
}
</style>

View File

@@ -46,7 +46,7 @@
id.splice(id.indexOf(0), 1);
id.push(0);
while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) {
id[id.length - 1]!++;
id[id.length - 1] = id[id.length - 1]! + 1;
}
changes.update((changes) => {

View File

@@ -4,6 +4,16 @@
import { serialPort } from "$lib/serial/connection";
import { setting } from "$lib/setting";
import ResetPopup from "./ResetPopup.svelte";
import LL from "$i18n/i18n-svelte";
import {
createChordBackup,
createLayoutBackup,
createSettingsBackup,
downloadBackup,
downloadFile,
restoreBackup,
} from "$lib/backup/backup";
import { preference } from "$lib/preferences";
</script>
<svelte:head>
@@ -11,8 +21,67 @@
<meta name="description" content="Change your device's settings" />
</svelte:head>
{#if $serialPort}
<section>
<section>
<fieldset>
<legend>{$LL.backup.TITLE()}</legend>
<label
><input
type="checkbox"
use:preference={"backup"}
/>{$LL.backup.AUTO_BACKUP()}</label
>
<p class="disclaimer">
{$LL.backup.DISCLAIMER()}
</p>
<div class="row" style="margin-top: auto">
<button onclick={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
Settings
</button>
</div>
<div class="row">
<button class="primary" onclick={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</fieldset>
<fieldset>
<legend>Device</legend>
<label
>{$LL.deviceManager.AUTO_CONNECT()}<input
type="checkbox"
use:preference={"autoConnect"}
/></label
>
{#if $serialPort}
<label
>Boot message<input type="checkbox" use:setting={{ id: 0x93 }} /></label
>
<label
>GTM Realtime Feedback<input
type="checkbox"
use:setting={{ id: 0x92 }}
/></label
>
<button class="outline" use:popup={ResetPopup}>Reset...</button>
{/if}
</fieldset>
{#if $serialPort}
<fieldset>
<legend
><label
@@ -231,20 +300,6 @@
>
</fieldset>
<fieldset>
<legend>Device</legend>
<label
>Boot message<input type="checkbox" use:setting={{ id: 0x93 }} /></label
>
<label
>GTM Realtime Feedback<input
type="checkbox"
use:setting={{ id: 0x92 }}
/></label
>
<button class="outline" use:popup={ResetPopup}>Reset...</button>
</fieldset>
{#if $serialPort.device === "LITE"}
<fieldset>
<legend
@@ -275,8 +330,8 @@
</select>
</fieldset>
{/if}
</section>
{/if}
{/if}
</section>
<style lang="scss">
section {
@@ -315,18 +370,21 @@
}
input[type="checkbox"] {
font-size: 12px;
font-size: 12px !important;
}
fieldset {
display: flex;
flex-direction: column;
max-width: 400px;
border: 1px solid var(--md-sys-color-outline);
border-radius: 24px;
&:has(> legend input:not(:checked)) > :not(legend) {
/*&:has(> legend input:not(:checked)) > :not(legend) {
pointer-events: none;
opacity: 0.7;
}
}*/
> label {
position: relative;
@@ -429,4 +487,14 @@
content: "•";
}
}
.row {
display: flex;
justify-content: space-evenly;
margin-block: 8px;
}
input[type="file"] {
display: none;
}
</style>

View File

@@ -155,30 +155,31 @@
doc: examplePlugin,
});
});
let channels = $derived(
$serialPort
? ({
getVersion: async (..._args: unknown[]) => $serialPort.version,
getDevice: async (..._args: unknown[]) => $serialPort.device,
commit: async (..._args: unknown[]) => {
if (
confirm(
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
"Click OK to perform the commit anyways.",
)
) {
return $serialPort.commit();
}
},
...Object.fromEntries(
charaMethods.map(
(it) => [it, $serialPort[it].bind($serialPort)] as const,
),
),
} satisfies Record<string, Function>)
: ({} as any),
);
let channels = $derived.by(() => {
if (!$serialPort) return {} as any;
return {
getVersion: (..._args: unknown[]) => Promise.resolve($serialPort.version),
getDevice: (..._args: unknown[]) => Promise.resolve($serialPort.device),
commit: (..._args: unknown[]) => {
if (
confirm(
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
"Click OK to perform the commit anyways.",
)
) {
return Promise.resolve($serialPort.commit());
}
return Promise.resolve();
},
...Object.fromEntries(
charaMethods.map(
(it) => [it, $serialPort[it].bind($serialPort)] as const,
),
),
} satisfies Record<string, Function>;
});
async function onMessage(event: MessageEvent) {
if (event.origin !== "null" || event.source !== frame.contentWindow) return;