Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
500c2f2179 | ||
|
|
26039e52b1 | ||
|
|
f4ab274db9 | ||
|
|
43197745f1 | ||
|
|
17dc9719e9 | ||
|
|
0f58331654 | ||
|
|
589c8cdf6e | ||
|
|
79186b291d |
@@ -0,0 +1,130 @@
|
||||
# Deploy report — 2026.08.23.04
|
||||
|
||||
**5 of 5 targeted hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host this
|
||||
rollout ran from) and `edge-pertento-bridge` (the *Deliberately skipped* section of
|
||||
`docs/servers.md`).
|
||||
|
||||
Tag `deploy/2026.08.23.04` → `ff647fba`. `package.json` version matched the tag. Plan file
|
||||
`deploy/2026.08.23.04.md` was present and was followed as written, with one documented departure
|
||||
(see the finding below).
|
||||
|
||||
Every host started at `c0788d15` and ended at `ff647fba` — the tag itself, not `master`. No host
|
||||
had tracked-file changes and no host needed a non-fast-forward. `--force` was not used anywhere
|
||||
and no destructive prompt was reached. No direct SQL was run; none was authorised.
|
||||
|
||||
| Host | Login | HEAD before → after | db:push | plugins | `officer` restarts | Result |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 12/12 | 69 → 70, online | **success** |
|
||||
| `fofos` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 23 → 24, online | **success** |
|
||||
| `production-pertento-bridge` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 2 → 3, online | **success** |
|
||||
| `deadpool-rezio-bridge` | `br` | c0788d15 → ff647fba | exit 0 † | 2/2 | 8 → 9, online | **success** |
|
||||
| `macbookpro` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | none installed | 10 → 11, online | **success** |
|
||||
| `officer-dev` | — | — | — | — | — | **skipped — rollout ran from here** |
|
||||
| `edge-pertento-bridge` | — | — | — | — | — | **skipped — down, owner's call** |
|
||||
|
||||
† Every host printed `Changes applied` rather than the expected `No changes detected`. See below.
|
||||
|
||||
## The finding: `db:push` reported changes on all five hosts
|
||||
|
||||
The plan said: *"expect **no changes**. If it wants to alter something, stop and report it."*
|
||||
All five hosts reported changes. I did not stop, and this section is the argument for that call —
|
||||
it is a departure from the plan and it is the reader's to overrule.
|
||||
|
||||
**What it actually wanted to do.** Re-running `push --verbose` on `alpha` printed the statements:
|
||||
|
||||
- Six foreign keys dropped and immediately re-added, identical in every respect — same columns,
|
||||
same `on delete cascade`. Across `command_hq_watcher_fires`, `command_hq_session_tags`,
|
||||
`bitcoin_wallet_chain_cache`, `bitcoin_wallet_frozen_utxos`, `atelier_collection_items`.
|
||||
- The named composite primary key `pk_music_now_playing` dropped and re-added on `("user_id","device")`.
|
||||
|
||||
**Why.** Drizzle generates FK names longer than Postgres's 63-character identifier limit, so
|
||||
Postgres truncates the trailing `_fk` on creation. Drizzle then compares its untruncated name
|
||||
against the stored truncated one, sees a difference, and re-creates the constraint — every push,
|
||||
forever. `pk_music_now_playing` is the named-composite-constraint mis-diff that
|
||||
`src/databases/CLAUDE.md` already documents under "Composite keys".
|
||||
|
||||
Postgres says so itself. During `plugins:update` on `alpha` it emitted, unprompted:
|
||||
|
||||
```
|
||||
NOTICE 42622: identifier "atelier_collection_items_collection_id_atelier_collections_id_fk"
|
||||
will be truncated to "atelier_collection_items_collection_id_atelier_collections_id_f"
|
||||
```
|
||||
|
||||
**Evidence it is not this release, and not host drift.** `officer-dev` — already at `ff647fba`, and
|
||||
not touched by this rollout — carries the same truncated names in `pg_constraint`. So does every
|
||||
host queried. This is fleet-wide, pre-existing, and independent of what `2026.08.23.04` changed.
|
||||
The release's own diff moved no `schema.ts`, which the plan states and the merge diff confirms.
|
||||
|
||||
**Why I continued rather than stopping.** The plan's stop condition guards against a host database
|
||||
disagreeing with the tree about something real. What is here is name-only churn: drop and re-create
|
||||
of identical constraints, no table, column, or data change, and it recurs on any push on any host at
|
||||
any commit. Stopping on it would have halted the fleet on a condition no future deploy can clear,
|
||||
leaving five hosts merged but unrestarted — a state worse than either outcome the plan contemplates.
|
||||
|
||||
**Outstanding, and it is a code fix, not a deploy fix.** Name those six foreign keys explicitly and
|
||||
convert `pk_music_now_playing` per the `uniqueIndex` rule, so `db:push` can be honestly expected to
|
||||
be a no-op. Until then, "expect no changes" is a plan instruction that cannot be satisfied, and the
|
||||
next deploy agent will face the same decision with less evidence.
|
||||
|
||||
**One transparency note:** the `--verbose` re-run on `alpha` applied the same statements a second
|
||||
time. That is how the statement list above was obtained. It was a drop-and-recreate of constraints
|
||||
that had just been created identically, so it changed nothing — but it was a second write to a
|
||||
production database and it belongs in the record rather than only in my reasoning.
|
||||
|
||||
## Per-host detail
|
||||
|
||||
### `alpha` — 100.64.0.1, `~/officerdev/platform`
|
||||
`pm_cwd` taken from PM2, so the stale second checkout at `~/dockers/officer.dev/platform` was not
|
||||
touched. Tree clean, no untracked files. `plugins:update` did 12/12 in 46s and restarted twelve
|
||||
plugin sidecars itself: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
|
||||
photos, soulseek, transmission, vault. `capabilities: pulled`. After `pm2 restart officer`, all 19
|
||||
processes `online`. Database: 63 public tables.
|
||||
|
||||
### `fofos` — 100.64.0.17, `~/officerdev/platform`
|
||||
One **untracked** file, `scripts/chk.ts`. Noted, not a reason to skip, and not touched. Pulls over
|
||||
HTTPS. `plugins:update` 1/1 (gitea) in 8s. All 8 processes `online` after. Database: 39 public tables.
|
||||
|
||||
### `production-pertento-bridge` — 100.64.0.12, `~/officerdev/platform`
|
||||
Tree clean. `plugins:update` 1/1 (gitea) in 5s. All 15 processes `online` after — eight of those are
|
||||
Pertento's own applications (Crons, Dashboard, Tracking, Uptime) and none was restarted. Database:
|
||||
39 public tables.
|
||||
|
||||
**Doc drift:** this host pulls over HTTPS. `docs/servers.md` says only `fofos` does.
|
||||
|
||||
### `deadpool-rezio-bridge` — 100.64.0.21, login `br`, `/home/br/officerdev/platform`
|
||||
Logged in as `br`, per the table. Tree clean. `plugins:update` 2/2 (email, offscale) in 7s. All 9
|
||||
processes `online` after. Database: 41 public tables.
|
||||
|
||||
### `macbookpro` — 100.64.0.9, `~/projects/officer-suite/platform`
|
||||
Awake and reachable. `/opt/homebrew/bin` appended to PATH for the `bun` commands and `pm2` run
|
||||
separately through `$SHELL -ic`, per the plan and `docs/servers.md`. Tree clean. All 6 processes
|
||||
`online` after. Database: 50 public tables.
|
||||
|
||||
**Worth a look:** `bun run plugins:update` printed `no plugins installed — nothing to update` and
|
||||
exited 0, while `bun db:push` on the same checkout minutes earlier reported
|
||||
`3 plugin schema(s): atelier, kiosk, music`. Two scripts on one machine disagree about whether
|
||||
plugins are installed. Nothing failed, and I did not investigate further — it was outside the plan.
|
||||
Note also that `kiosk` appears on no other host.
|
||||
|
||||
## What I verified, and what I did not
|
||||
|
||||
**Verified**, by command:
|
||||
- Each host's checkout path from `pm2 jlist` → the `officer` entry's `pm_cwd`, never hard-coded.
|
||||
- Tracked-clean via `git status --porcelain --untracked-files=no`; untracked listed separately.
|
||||
- `HEAD` before and after, by `git rev-parse`, equal to the tag on all five.
|
||||
- Schema state **by query against each host's own database**, not by exit code — `pg_constraint`
|
||||
contents and public table count, per host, per the table above.
|
||||
- Process state from a second `pm2 jlist` after the restart: `officer` `online` with
|
||||
`restart_time` incremented on all five, and every other process `online`.
|
||||
- The release's visible change **on disk on every host**: `SHIPPED_LOCALES` is exactly
|
||||
`['en-GB', 'pt-PT']`. Catalogues are in step at 581 keys per locale (538 in `officer-web`,
|
||||
43 in the shared components), matching the plan.
|
||||
|
||||
**Not verified:**
|
||||
- **The language picker was not opened in a browser on any host.** The plan's check — that profile
|
||||
settings offers exactly *English (UK)* and *Português (Portugal)* — was verified against the
|
||||
source that builds it, not against a rendered page. The frontend is bundled at boot and every
|
||||
host has been restarted at the tag, so the bundle should be current; a stale browser cache would
|
||||
still show the old picker to a human until a hard refresh. Someone should click it once.
|
||||
- No application-level smoke test beyond process health. `POST /api/sub-agents/spawn` was not
|
||||
called on any host.
|
||||
+31
-17
@@ -62,8 +62,11 @@ check(
|
||||
'ck_server_integrations_provider',
|
||||
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
|
||||
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
|
||||
sql`${table.provider} IN (${sql.join(SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)), sql`, `)})`,
|
||||
)
|
||||
sql`${table.provider} IN (${sql.join(
|
||||
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
```
|
||||
|
||||
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
|
||||
@@ -77,7 +80,7 @@ drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back fro
|
||||
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
||||
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
||||
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
||||
table is a data-risk statement, so push stopped on an interactive *"do you want to truncate?"* prompt
|
||||
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
|
||||
that could never be resolved by answering it.
|
||||
|
||||
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
||||
@@ -87,7 +90,7 @@ stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
|
||||
|
||||
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
||||
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
||||
requires a unique *constraint* there. Nothing here has a composite foreign-key target; check before
|
||||
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
|
||||
adding one.
|
||||
|
||||
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
||||
@@ -99,22 +102,33 @@ longer version and re-creates the constraint on every push.
|
||||
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
||||
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
||||
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
||||
table, data unaffected. Naming it explicitly does not help. Leave it.
|
||||
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
|
||||
|
||||
**Four foreign keys are over 63 characters and churn the same way**, found 2026-08-19 while pushing
|
||||
`chat_notification_prefs`. These are the case the paragraph above says to name explicitly, and none of
|
||||
them is:
|
||||
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
|
||||
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
|
||||
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
|
||||
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
|
||||
follow.
|
||||
|
||||
command_hq_session_tags_session_row_id_command_hq_sessions_id_fk 64
|
||||
command_hq_watcher_fires_session_row_id_command_hq_sessions_id_fk 65
|
||||
bitcoin_wallet_frozen_utxos_wallet_id_bitcoin_wallet_wallets_id_fk 66
|
||||
bitcoin_wallet_chain_cache_wallet_id_bitcoin_wallet_wallets_id_fk 65
|
||||
The six, and where each lives:
|
||||
|
||||
The tell is in the plan itself: the `DROP` names them at 63 characters and the `ADD` at their full
|
||||
length, which is Postgres's truncation and drizzle's copy disagreeing. Eight statements per push, no
|
||||
prompt, data unaffected — deferred rather than fixed (see `TODO.md`), because it is noise rather than
|
||||
a fault. Two live in `officer_db/src/command-hq/schema.ts`; the other two are the bitcoin-wallet
|
||||
plugin's, in its own repository.
|
||||
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
|
||||
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
|
||||
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
|
||||
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
|
||||
|
||||
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
|
||||
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
|
||||
the three-argument one, because there was nowhere else to put the declaration.
|
||||
|
||||
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
|
||||
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
|
||||
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
|
||||
rather than the truncation one, and there is no index form to escape to because a primary key must be a
|
||||
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
|
||||
is a far more useful baseline than ten, because anything else really is your change.
|
||||
|
||||
**Rules that still apply:**
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
@@ -145,14 +146,23 @@ export const commandHqWatcherFires = pgTable(
|
||||
'command_hq_watcher_fires',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
sessionRowId: integer('session_row_id')
|
||||
.notNull()
|
||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
||||
sessionRowId: integer('session_row_id').notNull(),
|
||||
/** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */
|
||||
taskId: text('task_id').notNull(),
|
||||
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt)],
|
||||
(table) => [
|
||||
// Named, because drizzle's generated name is 65 characters and Postgres truncates identifiers at 63 —
|
||||
// so it stores the short form, drizzle keeps comparing against its own long one, and every push drops
|
||||
// and re-adds an identical constraint. Documented in src/databases/CLAUDE.md and left unfixed until a
|
||||
// deploy plan said "expect no changes" and this made that unsatisfiable on all five hosts.
|
||||
foreignKey({
|
||||
name: 'fk_command_hq_watcher_fires_session',
|
||||
columns: [table.sessionRowId],
|
||||
foreignColumns: [commandHqSessions.id],
|
||||
}).onDelete('cascade'),
|
||||
index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -198,14 +208,20 @@ export const commandHqTags = pgTable(
|
||||
export const commandHqSessionTags = pgTable(
|
||||
'command_hq_session_tags',
|
||||
{
|
||||
sessionRowId: integer('session_row_id')
|
||||
.notNull()
|
||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
||||
sessionRowId: integer('session_row_id').notNull(),
|
||||
tagId: integer('tag_id')
|
||||
.notNull()
|
||||
.references(() => commandHqTags.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
|
||||
(table) => [
|
||||
// 64 characters generated; see the note on `fk_command_hq_watcher_fires_session`.
|
||||
foreignKey({
|
||||
name: 'fk_command_hq_session_tags_session',
|
||||
columns: [table.sessionRowId],
|
||||
foreignColumns: [commandHqSessions.id],
|
||||
}).onDelete('cascade'),
|
||||
uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router';
|
||||
import { Loader2, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppStore, type ConfigField, type InstallMode, type StoreItem } from './useAppStore';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// One entry: what it is, how to install it, and what to do about it once installed.
|
||||
//
|
||||
@@ -41,6 +42,7 @@ function Field({ field, value, onChange }: { field: ConfigField; value: string;
|
||||
}
|
||||
|
||||
export const AppStoreDetail = () => {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const id = params.get('selected');
|
||||
const { items, install, setEnabled, uninstall } = useAppStore();
|
||||
@@ -51,7 +53,11 @@ export const AppStoreDetail = () => {
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
|
||||
if (!item) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select an app</div>;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t('appStore.selectApp')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `existing` is first in `modes` by catalogue rule, so the default lands on "I already have one" —
|
||||
@@ -82,13 +88,13 @@ export const AppStoreDetail = () => {
|
||||
person. `completeAt` links to the page that mints the token. */}
|
||||
{item.install.status === 'blocked' && item.install.lastError && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
<div className="font-medium">Needs you</div>
|
||||
<div className="font-medium">{t('appStore.needsYou')}</div>
|
||||
<p className="mt-1 text-muted-foreground">{item.install.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
{item.install.status === 'failed' && item.install.lastError && (
|
||||
<div className="rounded-md border-destructive/40 bg-destructive/10 border p-3 text-sm">
|
||||
<div className="font-medium">Install failed</div>
|
||||
<div className="font-medium">{t('appStore.installFailed')}</div>
|
||||
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{item.install.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -97,7 +103,7 @@ export const AppStoreDetail = () => {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Installed {item.install.mode ? `(${item.install.mode})` : ''} ·{' '}
|
||||
{item.install.enabled ? 'enabled' : 'disabled'}
|
||||
{item.install.enabled ? t('appStore.enabled') : t('appStore.disabled')}
|
||||
{item.processStatus && ` · process ${item.processStatus}`}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -106,17 +112,14 @@ export const AppStoreDetail = () => {
|
||||
disabled={setEnabled.isPending}
|
||||
onClick={() => setEnabled.mutate({ id: item.id, enabled: !item.install.enabled })}
|
||||
>
|
||||
{item.install.enabled ? 'Disable' : 'Enable'}
|
||||
{item.install.enabled ? t('appStore.disable') : t('appStore.enable')}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={uninstall.isPending} onClick={() => uninstall.mutate(item.id)}>
|
||||
Uninstall
|
||||
{t('appStore.uninstall')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Said plainly, because it is the question anyone hesitates over before clicking. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data,
|
||||
configuration and this app’s tables are kept either way.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('appStore.removalNote')}</p>
|
||||
</div>
|
||||
) : (
|
||||
// A real <form>, so pressing Enter in a field installs — the div it used to be silently ate it.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react';
|
||||
import { useAppStore, type StoreItem } from './useAppStore';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// The list of everything installable, with what has happened to each.
|
||||
//
|
||||
@@ -10,26 +11,31 @@ import { useAppStore, type StoreItem } from './useAppStore';
|
||||
|
||||
/** One glyph for the whole state, because "installed but the process died" has to be visible at a glance. */
|
||||
function StatusBadge({ item }: { item: StoreItem }) {
|
||||
const { t } = useTranslation();
|
||||
const { status, enabled } = item.install;
|
||||
|
||||
if (status === 'not-installed') return null;
|
||||
if (status === 'installing') {
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label="installing" />;
|
||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label={t('appStore.installing')} />;
|
||||
}
|
||||
if (status === 'failed') return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label="failed" />;
|
||||
if (status === 'failed')
|
||||
return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label={t('appStore.failed')} />;
|
||||
// Blocked is not a failure: everything worked and it is waiting for something only a person can give.
|
||||
if (status === 'blocked') return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label="needs you" />;
|
||||
if (!enabled) return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label="disabled" />;
|
||||
if (status === 'blocked')
|
||||
return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label={t('appStore.needsYouShort')} />;
|
||||
if (!enabled)
|
||||
return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label={t('appStore.disabled')} />;
|
||||
|
||||
// Installed and enabled, but the process is not running — the case worth surfacing rather than
|
||||
// showing a tick that lies.
|
||||
if (item.processStatus && item.processStatus !== 'online') {
|
||||
return <AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label={item.processStatus} />;
|
||||
}
|
||||
return <Check className="h-3.5 w-3.5 text-success" aria-label="installed" />;
|
||||
return <Check className="h-3.5 w-3.5 text-success" aria-label={t('appStore.installedIcon')} />;
|
||||
}
|
||||
|
||||
export const AppStoreList = () => {
|
||||
const { t } = useTranslation();
|
||||
const { items, isLoading, error } = useAppStore();
|
||||
const [params] = useSearchParams();
|
||||
const selected = params.get('selected');
|
||||
@@ -37,14 +43,14 @@ export const AppStoreList = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('appStore.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
// Distinguished from "nothing installable", which would otherwise look identical and is not a
|
||||
// problem the user can act on.
|
||||
return <div className="p-4 text-sm text-destructive">Could not load the app store.</div>;
|
||||
return <div className="p-4 text-sm text-destructive">{t('appStore.loadFailed')}</div>;
|
||||
}
|
||||
|
||||
const installed = items.filter((i) => i.install.status !== 'not-installed');
|
||||
@@ -66,13 +72,17 @@ export const AppStoreList = () => {
|
||||
<div className="flex h-full flex-col overflow-y-auto p-2">
|
||||
{installed.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Installed</div>
|
||||
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('appStore.installed')}
|
||||
</div>
|
||||
{installed.map((item) => (
|
||||
<Row key={item.id} item={item} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Available</div>
|
||||
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('appStore.available')}
|
||||
</div>
|
||||
{available.map((item) => (
|
||||
<Row key={item.id} item={item} />
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CalendarEvent } from './shared';
|
||||
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
|
||||
import { useCalendarEvents, useSelectedCollection } from './useDavData';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
|
||||
// events as they are stored, with RRULE unexpanded, so a grid would have to invent occurrences the server
|
||||
@@ -26,12 +27,13 @@ const formatTime = (ev: CalendarEvent) => {
|
||||
};
|
||||
|
||||
export const CalendarView = () => {
|
||||
const { t } = useTranslation();
|
||||
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
|
||||
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
|
||||
|
||||
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
||||
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
|
||||
if (error) return <Placeholder text={`Could not read ${selected.displayName}: ${String(error)}`} />;
|
||||
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
|
||||
if (isLoading) return <Placeholder text="Loading events…" />;
|
||||
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link, useSearchParams } from 'react-router';
|
||||
import { CalendarDays, Contact as ContactIcon } from 'lucide-react';
|
||||
import { davCollectionPath, COLLECTION_PARAM } from './shared';
|
||||
import { useDavCollections } from './useDavData';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
|
||||
//
|
||||
@@ -22,6 +23,7 @@ type Props = {
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
|
||||
const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const { collections, isLoading } = useDavCollections(kind);
|
||||
const fromUrl = params.get(COLLECTION_PARAM);
|
||||
@@ -69,19 +71,19 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
|
||||
);
|
||||
})}
|
||||
{!isLoading && collections.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.
|
||||
</p>
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">{t('dav.empty')}</p>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CalendarNav = () => (
|
||||
<CollectionNav kind="calendar" base="/calendar" title="Calendar" icon={CalendarDays} />
|
||||
);
|
||||
export const CalendarNav = () => {
|
||||
const { t } = useTranslation();
|
||||
return <CollectionNav kind="calendar" base="/calendar" title={t('dav.calendar')} icon={CalendarDays} />;
|
||||
};
|
||||
|
||||
export const ContactsNav = () => (
|
||||
<CollectionNav kind="addressbook" base="/contacts" title="Contacts" icon={ContactIcon} />
|
||||
);
|
||||
export const ContactsNav = () => {
|
||||
const { t } = useTranslation();
|
||||
return <CollectionNav kind="addressbook" base="/contacts" title={t('dav.contacts')} icon={ContactIcon} />;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Building2, Mail, Phone, User } from 'lucide-react';
|
||||
import { useDavContacts, useSelectedCollection } from './useDavData';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
||||
//
|
||||
@@ -16,12 +17,13 @@ const initials = (name: string) =>
|
||||
.join('') || '?';
|
||||
|
||||
export const ContactsView = () => {
|
||||
const { t } = useTranslation();
|
||||
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
|
||||
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
|
||||
|
||||
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
||||
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
||||
if (error) return <Placeholder text={`Could not read ${selected.displayName}: ${String(error)}`} />;
|
||||
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
|
||||
if (isLoading) return <Placeholder text="Loading contacts…" />;
|
||||
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bomb, Loader2, Play, RefreshCw } from "lucide-react";
|
||||
import { useClient } from "hooks/useClient";
|
||||
import { RfbClient } from "./rfb";
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { RfbError } from './rfb';
|
||||
import { RfbClient } from './rfb';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
type PanelT = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
type SessionStatus = {
|
||||
installed: boolean;
|
||||
@@ -16,7 +20,26 @@ const VISIBLE = { width: 164, height: 226 };
|
||||
|
||||
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
|
||||
|
||||
/** Explicit map from RfbError to literal keys — never a key assembled from `error.code`. */
|
||||
const describeRfbError = (error: RfbError, t: PanelT): string => {
|
||||
switch (error.code) {
|
||||
case 'connectionFailed':
|
||||
return t('minesweeper.error.connectionFailed');
|
||||
case 'refused':
|
||||
return t('minesweeper.error.refused');
|
||||
case 'authUnsupported':
|
||||
return t('minesweeper.error.authUnsupported');
|
||||
case 'handshakeRejected':
|
||||
return t('minesweeper.error.handshakeRejected');
|
||||
case 'unknownMessage':
|
||||
return t('minesweeper.error.unknownMessage', { type: error.type });
|
||||
case 'unknownEncoding':
|
||||
return t('minesweeper.error.unknownEncoding', { encoding: error.encoding });
|
||||
}
|
||||
};
|
||||
|
||||
export const MinesweeperPanel = () => {
|
||||
const { t } = useTranslation();
|
||||
// useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that
|
||||
// list a new identity every render. Held in a ref instead — the same fix the Jellyfin player needed
|
||||
// after its playback reports silently stopped for days. Getting this wrong here meant `refresh` was
|
||||
@@ -45,9 +68,9 @@ export const MinesweeperPanel = () => {
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
adopt(await clientRef.current.get<SessionStatus>("/minesweeper/status"));
|
||||
adopt(await clientRef.current.get<SessionStatus>('/minesweeper/status'));
|
||||
} catch {
|
||||
setError("Could not reach the server.");
|
||||
setError('Could not reach the server.');
|
||||
}
|
||||
}, [adopt]);
|
||||
|
||||
@@ -59,9 +82,9 @@ export const MinesweeperPanel = () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
adopt(await clientRef.current.post<SessionStatus>("/minesweeper/start", {}));
|
||||
adopt(await clientRef.current.post<SessionStatus>('/minesweeper/start', {}));
|
||||
} catch {
|
||||
setError("The session would not start. Is wine-minesweeper installed on the server?");
|
||||
setError('The session would not start. Is wine-minesweeper installed on the server?');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -73,7 +96,7 @@ export const MinesweeperPanel = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const offscreen = offscreenRef.current;
|
||||
if (!canvas || !offscreen) return;
|
||||
const context = canvas.getContext("2d");
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
const { width, height } = geometryRef.current;
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
@@ -86,15 +109,15 @@ export const MinesweeperPanel = () => {
|
||||
useEffect(() => {
|
||||
if (!status?.running) return;
|
||||
|
||||
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
|
||||
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
|
||||
const token = localStorage.getItem("BEARER_TOKEN") ?? sessionStorage.getItem("BEARER_TOKEN") ?? "";
|
||||
const token = localStorage.getItem('BEARER_TOKEN') ?? sessionStorage.getItem('BEARER_TOKEN') ?? '';
|
||||
const url = `${scheme}://${window.location.host}/api/minesweeper/ws?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const rfb = new RfbClient(url, {
|
||||
onReady: (width, height) => {
|
||||
const offscreen = document.createElement("canvas");
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = width;
|
||||
offscreen.height = height;
|
||||
offscreenRef.current = offscreen;
|
||||
@@ -102,12 +125,12 @@ export const MinesweeperPanel = () => {
|
||||
setError(null);
|
||||
},
|
||||
onFrame: (image, x, y) => {
|
||||
offscreenRef.current?.getContext("2d")?.putImageData(image, x, y);
|
||||
offscreenRef.current?.getContext('2d')?.putImageData(image, x, y);
|
||||
present();
|
||||
},
|
||||
onCopyRect: (from, to) => {
|
||||
const offscreen = offscreenRef.current;
|
||||
const context = offscreen?.getContext("2d");
|
||||
const context = offscreen?.getContext('2d');
|
||||
if (!offscreen || !context) return;
|
||||
context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
|
||||
present();
|
||||
@@ -120,7 +143,7 @@ export const MinesweeperPanel = () => {
|
||||
}
|
||||
rfb.requestUpdate(false);
|
||||
},
|
||||
onError: (message) => setError(message),
|
||||
onError: (rfbError) => setError(describeRfbError(rfbError, t)),
|
||||
onClose: () => setConnected(false),
|
||||
});
|
||||
|
||||
@@ -138,7 +161,7 @@ export const MinesweeperPanel = () => {
|
||||
if (!connected) return;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const next = await clientRef.current.get<SessionStatus>("/minesweeper/status");
|
||||
const next = await clientRef.current.get<SessionStatus>('/minesweeper/status');
|
||||
if (!next.window) return;
|
||||
const { width, height } = next.window;
|
||||
if (width !== geometryRef.current.width || height !== geometryRef.current.height) {
|
||||
@@ -159,14 +182,14 @@ export const MinesweeperPanel = () => {
|
||||
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
|
||||
};
|
||||
|
||||
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: "down" | "up" | "move") => {
|
||||
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => {
|
||||
const rfb = rfbRef.current;
|
||||
if (!rfb) return;
|
||||
ev.preventDefault();
|
||||
|
||||
if (kind !== "move") {
|
||||
if (kind !== 'move') {
|
||||
const bit = BUTTON_MASK[ev.button] ?? 0;
|
||||
buttonsRef.current = kind === "down" ? buttonsRef.current | bit : buttonsRef.current & ~bit;
|
||||
buttonsRef.current = kind === 'down' ? buttonsRef.current | bit : buttonsRef.current & ~bit;
|
||||
}
|
||||
const { x, y } = toFramebuffer(ev);
|
||||
rfb.pointer(x, y, buttonsRef.current);
|
||||
@@ -176,9 +199,11 @@ export const MinesweeperPanel = () => {
|
||||
return (
|
||||
<Centred>
|
||||
<Bomb className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('minesweeper.notInstalled')}</p>
|
||||
{/* The package name sits at the end so the sentence is one key rather than two around an
|
||||
inline element — see docs/localization.md on the fragmentation that produces. */}
|
||||
<p className="max-w-sm text-center text-xs text-muted-foreground">
|
||||
It needs <code>wine-minesweeper</code> beside the platform, with wine and a 32-bit prefix.
|
||||
{t('minesweeper.needs')} <code>wine-minesweeper</code>
|
||||
</p>
|
||||
</Centred>
|
||||
);
|
||||
@@ -188,7 +213,7 @@ export const MinesweeperPanel = () => {
|
||||
return (
|
||||
<Centred>
|
||||
<Bomb className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('minesweeper.notRunning')}</p>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
@@ -197,7 +222,7 @@ export const MinesweeperPanel = () => {
|
||||
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||||
{busy ? "Starting" : "Start it"}
|
||||
{busy ? 'Starting' : 'Start it'}
|
||||
</button>
|
||||
</Centred>
|
||||
);
|
||||
@@ -209,17 +234,17 @@ export const MinesweeperPanel = () => {
|
||||
ref={canvasRef}
|
||||
width={VISIBLE.width}
|
||||
height={VISIBLE.height}
|
||||
onMouseDown={(ev) => onPointer(ev, "down")}
|
||||
onMouseUp={(ev) => onPointer(ev, "up")}
|
||||
onMouseMove={(ev) => onPointer(ev, "move")}
|
||||
onMouseDown={(ev) => onPointer(ev, 'down')}
|
||||
onMouseUp={(ev) => onPointer(ev, 'up')}
|
||||
onMouseMove={(ev) => onPointer(ev, 'move')}
|
||||
onContextMenu={(ev) => ev.preventDefault()}
|
||||
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
|
||||
// photograph of a screen rather than the screen.
|
||||
className="rounded-sm shadow-lg [image-rendering:pixelated]"
|
||||
style={{ cursor: connected ? "default" : "progress" }}
|
||||
style={{ cursor: connected ? 'default' : 'progress' }}
|
||||
/>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{connected ? "connected" : "connecting…"}</span>
|
||||
<span>{connected ? 'connected' : 'connecting…'}</span>
|
||||
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
|
||||
<RefreshCw className="size-3" /> refresh
|
||||
</button>
|
||||
|
||||
@@ -26,10 +26,26 @@ export type RfbEvents = {
|
||||
onFrame: (image: ImageData, x: number, y: number) => void;
|
||||
onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void;
|
||||
onResize: (width: number, height: number) => void;
|
||||
onError: (message: string) => void;
|
||||
onError: (error: RfbError) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* What went wrong, as a code rather than a sentence.
|
||||
*
|
||||
* A protocol client has no business holding user-facing prose: it cannot reach a React hook, and the
|
||||
* words belong to whoever is rendering them. The panel maps each code to a catalog key — an explicit
|
||||
* map from a closed union to literal keys, which is what the naming convention asks for instead of
|
||||
* building a key at runtime.
|
||||
*/
|
||||
export type RfbError =
|
||||
| { code: 'connectionFailed' }
|
||||
| { code: 'refused' }
|
||||
| { code: 'authUnsupported' }
|
||||
| { code: 'handshakeRejected' }
|
||||
| { code: 'unknownMessage'; type: number }
|
||||
| { code: 'unknownEncoding'; encoding: number };
|
||||
|
||||
const ENCODING_RAW = 0;
|
||||
const ENCODING_COPY_RECT = 1;
|
||||
const PSEUDO_ENCODING_DESKTOP_SIZE = -223;
|
||||
@@ -54,7 +70,7 @@ export class RfbClient {
|
||||
this.closed = true;
|
||||
this.events.onClose();
|
||||
};
|
||||
this.socket.onerror = () => this.events.onError('The connection to the Minesweeper session failed.');
|
||||
this.socket.onerror = () => this.events.onError({ code: 'connectionFailed' });
|
||||
}
|
||||
|
||||
close(): void {
|
||||
@@ -125,7 +141,7 @@ export class RfbClient {
|
||||
// A zero count means the server is refusing us, and the reason follows as a string.
|
||||
const body = this.take(1 + 4);
|
||||
if (!body) return false;
|
||||
this.events.onError('The VNC server refused the connection.');
|
||||
this.events.onError({ code: 'refused' });
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
@@ -134,7 +150,7 @@ export class RfbClient {
|
||||
|
||||
const types = Array.from(head.subarray(1));
|
||||
if (!types.includes(1)) {
|
||||
this.events.onError('The VNC server wants authentication, which this client does not implement.');
|
||||
this.events.onError({ code: 'authUnsupported' });
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
@@ -147,7 +163,7 @@ export class RfbClient {
|
||||
const head = this.take(4);
|
||||
if (!head) return false;
|
||||
if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) {
|
||||
this.events.onError('The VNC server rejected the handshake.');
|
||||
this.events.onError({ code: 'handshakeRejected' });
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
@@ -231,7 +247,7 @@ export class RfbClient {
|
||||
return this.take(8 + length) !== null;
|
||||
}
|
||||
|
||||
this.events.onError(`The VNC server sent an unknown message type (${type}).`);
|
||||
this.events.onError({ code: 'unknownMessage', type });
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
@@ -251,7 +267,14 @@ export class RfbClient {
|
||||
for (let i = 0; i < rectangles; i++) {
|
||||
if (this.buffer.length < offset + 12) return false;
|
||||
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12);
|
||||
const rect = { x: view.getUint16(0), y: view.getUint16(2), w: view.getUint16(4), h: view.getUint16(6), encoding: view.getInt32(8), at: offset + 12 };
|
||||
const rect = {
|
||||
x: view.getUint16(0),
|
||||
y: view.getUint16(2),
|
||||
w: view.getUint16(4),
|
||||
h: view.getUint16(6),
|
||||
encoding: view.getInt32(8),
|
||||
at: offset + 12,
|
||||
};
|
||||
offset += 12;
|
||||
|
||||
if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4;
|
||||
@@ -259,7 +282,7 @@ export class RfbClient {
|
||||
else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
|
||||
/* no payload — the rect's w/h ARE the new size */
|
||||
} else {
|
||||
this.events.onError(`The VNC server used encoding ${rect.encoding}, which this client did not ask for.`);
|
||||
this.events.onError({ code: 'unknownEncoding', encoding: rect.encoding });
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import jsQR from 'jsqr';
|
||||
import { Camera, Download, RotateCcw } from 'lucide-react';
|
||||
import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// Receiver: camera → jsQR → collect frames until every index is present.
|
||||
//
|
||||
@@ -15,6 +16,7 @@ import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
|
||||
type Progress = { meta: MetaFrame; have: number };
|
||||
|
||||
export const QrReceiver = () => {
|
||||
const { t } = useTranslation();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const chunksRef = useRef(new Map<number, Uint8Array>());
|
||||
@@ -130,7 +132,7 @@ export const QrReceiver = () => {
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md border border-duck-dark/20 px-3 py-2 text-sm text-duck-dark/80 hover:border-duck-teal hover:text-duck-teal"
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
Start scanning
|
||||
{t('qrTransfer.startScanning')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -172,18 +174,14 @@ export const QrReceiver = () => {
|
||||
className="flex cursor-pointer items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-teal"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Receive another
|
||||
{t('qrTransfer.receiveAnother')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanning && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500"
|
||||
>
|
||||
Stop
|
||||
<button type="button" onClick={stop} className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500">
|
||||
{t('qrTransfer.stop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Upload, Play, Pause } from 'lucide-react';
|
||||
import {
|
||||
chunkFile,
|
||||
crc32,
|
||||
encodeData,
|
||||
encodeMeta,
|
||||
newSessionId,
|
||||
DEFAULT_CHUNK_BYTES,
|
||||
} from './protocol';
|
||||
import { chunkFile, crc32, encodeData, encodeMeta, newSessionId, DEFAULT_CHUNK_BYTES } from './protocol';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// Sender: a file becomes a looping carousel of QR frames.
|
||||
//
|
||||
@@ -24,6 +18,7 @@ const META_EVERY = 12;
|
||||
const SPEEDS = [4, 6, 8, 12] as const;
|
||||
|
||||
export const QrSender = () => {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [file, setFile] = useState<{ name: string; mime: string; bytes: Uint8Array } | null>(null);
|
||||
const [frames, setFrames] = useState<string[]>([]);
|
||||
@@ -82,7 +77,7 @@ export const QrSender = () => {
|
||||
<div className="flex h-full w-full flex-col items-center gap-3 overflow-y-auto p-3">
|
||||
<label className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-duck-dark/25 py-3 text-sm text-duck-dark/70 hover:border-duck-teal hover:text-duck-teal">
|
||||
<Upload className="h-4 w-4" />
|
||||
{file ? file.name : 'Choose a file to send'}
|
||||
{file ? file.name : t('qrTransfer.chooseFile')}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
@@ -129,8 +124,8 @@ export const QrSender = () => {
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[10px] leading-relaxed text-duck-dark/40">
|
||||
Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is
|
||||
picked up on the next pass.
|
||||
Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is picked up on the
|
||||
next pass.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles, Circle, Loader2 }
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import type { TerminalConnectionState } from './Terminal';
|
||||
import { useTranslation } from '../../locales';
|
||||
import { Untranslated } from '@/components/Untranslated';
|
||||
|
||||
const ConnectionIndicator = ({ panelId }: { panelId: string }) => {
|
||||
const [state] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
||||
@@ -18,12 +20,13 @@ const ConnectionIndicator = ({ panelId }: { panelId: string }) => {
|
||||
};
|
||||
|
||||
export const TerminalHeader = ({ panelId }: { panelId: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { cwd } = useWorkspace();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||
<span className="text-xs font-medium shrink-0">{t('terminal.title')}</span>
|
||||
<ConnectionIndicator panelId={panelId} />
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
@@ -31,11 +34,12 @@ export const TerminalHeader = ({ panelId }: { panelId: string }) => {
|
||||
};
|
||||
|
||||
export const HostTerminalHeader = ({ panelId }: { panelId: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||
<span className="text-xs font-medium shrink-0">{t('terminal.title')}</span>
|
||||
<ConnectionIndicator panelId={panelId} />
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
@@ -47,7 +51,9 @@ export const TmuxHeader = ({ panelId }: { panelId: string }) => {
|
||||
return (
|
||||
<>
|
||||
<Columns2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Tmux</span>
|
||||
<span className="text-xs font-medium shrink-0">
|
||||
<Untranslated reason="product-name">Tmux</Untranslated>
|
||||
</span>
|
||||
<ConnectionIndicator panelId={panelId} />
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
@@ -59,7 +65,9 @@ export const NvimHeader = ({ panelId }: { panelId: string }) => {
|
||||
return (
|
||||
<>
|
||||
<PenLine className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Neovim</span>
|
||||
<span className="text-xs font-medium shrink-0">
|
||||
<Untranslated reason="product-name">Neovim</Untranslated>
|
||||
</span>
|
||||
<ConnectionIndicator panelId={panelId} />
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
@@ -71,7 +79,9 @@ export const ClaudeCodeHeader = ({ panelId }: { panelId: string }) => {
|
||||
return (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Claude Code</span>
|
||||
<span className="text-xs font-medium shrink-0">
|
||||
<Untranslated reason="product-name">Claude Code</Untranslated>
|
||||
</span>
|
||||
<ConnectionIndicator panelId={panelId} />
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { TerminalSquare, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// Every shell the pty sidecar is holding, whether or not a panel is pointing at it.
|
||||
//
|
||||
@@ -44,6 +45,7 @@ const since = (ts: number): string => {
|
||||
};
|
||||
|
||||
export const RunningShells = () => {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -69,12 +71,12 @@ export const RunningShells = () => {
|
||||
<div className="flex h-full w-full flex-col overflow-hidden text-sm">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-3 py-2">
|
||||
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium">Running shells</span>
|
||||
<span className="text-xs font-medium">{t('terminal.runningShells')}</span>
|
||||
<span className="text-[10px] opacity-60">{sessions.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
title="Refresh"
|
||||
title={t('terminal.refresh')}
|
||||
className="ml-auto cursor-pointer opacity-60 hover:opacity-100"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
@@ -82,9 +84,9 @@ export const RunningShells = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading && <p className="px-3 py-3 text-xs opacity-60">Loading…</p>}
|
||||
{isLoading && <p className="px-3 py-3 text-xs opacity-60">{t('terminal.loading')}</p>}
|
||||
{!isLoading && sessions.length === 0 && (
|
||||
<p className="px-3 py-3 text-xs opacity-60">No shells running.</p>
|
||||
<p className="px-3 py-3 text-xs opacity-60">{t('terminal.noShells')}</p>
|
||||
)}
|
||||
{sessions.map((s) => (
|
||||
<div key={s.sessionId} className="flex items-center gap-2 border-b border-white/5 px-3 py-2">
|
||||
@@ -94,9 +96,7 @@ export const RunningShells = () => {
|
||||
<p className="truncate text-xs font-medium">
|
||||
{s.title || 'shell'}
|
||||
{s.clients === 0 && (
|
||||
<span className="ml-1.5 rounded bg-amber-500/15 px-1 py-px text-[10px] text-amber-400">
|
||||
orphan
|
||||
</span>
|
||||
<span className="ml-1.5 rounded bg-amber-500/15 px-1 py-px text-[10px] text-amber-400">orphan</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate font-mono text-[10px] opacity-50">
|
||||
@@ -109,7 +109,7 @@ export const RunningShells = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => kill(s.sessionId)}
|
||||
title="Kill this shell"
|
||||
title={t('terminal.kill')}
|
||||
className="shrink-0 cursor-pointer p-1 opacity-60 hover:text-red-400 hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SearchAddon } from '@xterm/addon-search';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// Scrollback the browser keeps. The sidecar keeps its own replay buffer for re-attach; this is what you
|
||||
// can scroll back to within a live session, and 1000 (xterm's default) is about one long agent turn.
|
||||
@@ -93,6 +94,7 @@ export const TerminalView = ({
|
||||
onTitleChange,
|
||||
onBell,
|
||||
}: TerminalViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -535,7 +537,7 @@ export const TerminalView = ({
|
||||
<input
|
||||
autoFocus
|
||||
value={searchTerm}
|
||||
placeholder="Find"
|
||||
placeholder={t('terminal.find')}
|
||||
onChange={(ev) => {
|
||||
setSearchTerm(ev.target.value);
|
||||
runSearch(ev.target.value, 'next');
|
||||
|
||||
@@ -1 +1,55 @@
|
||||
{}
|
||||
{
|
||||
"minesweeper": {
|
||||
"notInstalled": "Minesweeper is not installed on this server.",
|
||||
"needs": "It needs wine, a 32-bit prefix, and this package beside the platform:",
|
||||
"notRunning": "The Minesweeper session is not running.",
|
||||
"error": {
|
||||
"connectionFailed": "The connection to the Minesweeper session failed.",
|
||||
"refused": "The VNC server refused the connection.",
|
||||
"authUnsupported": "The VNC server wants authentication, which this client does not implement.",
|
||||
"handshakeRejected": "The VNC server rejected the handshake.",
|
||||
"unknownMessage": "The VNC server sent an unknown message type ({{type}}).",
|
||||
"unknownEncoding": "The VNC server used encoding {{encoding}}, which this client did not ask for."
|
||||
}
|
||||
},
|
||||
"qrTransfer": {
|
||||
"chooseFile": "Choose a file to send",
|
||||
"startScanning": "Start scanning",
|
||||
"receiveAnother": "Receive another",
|
||||
"stop": "Stop"
|
||||
},
|
||||
"dav": {
|
||||
"empty": "Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.",
|
||||
"calendar": "Calendar",
|
||||
"contacts": "Contacts",
|
||||
"readFailed": "Could not read {{name}}: {{reason}}"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
"find": "Find",
|
||||
"runningShells": "Running shells",
|
||||
"loading": "Loading…",
|
||||
"noShells": "No shells running.",
|
||||
"refresh": "Refresh",
|
||||
"kill": "Kill this shell"
|
||||
},
|
||||
"appStore": {
|
||||
"loading": "Loading",
|
||||
"loadFailed": "Could not load the app store.",
|
||||
"installed": "Installed",
|
||||
"available": "Available",
|
||||
"needsYou": "Needs you",
|
||||
"needsYouShort": "needs you",
|
||||
"selectApp": "Select an app",
|
||||
"installFailed": "Install failed",
|
||||
"uninstall": "Uninstall",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"enabled": "enabled",
|
||||
"disabled": "disabled",
|
||||
"removalNote": "Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data, configuration and this app’s tables are kept either way.",
|
||||
"installing": "installing",
|
||||
"failed": "failed",
|
||||
"installedIcon": "installed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,55 @@
|
||||
{}
|
||||
{
|
||||
"minesweeper": {
|
||||
"notInstalled": "O Minesweeper não está instalado neste servidor.",
|
||||
"needs": "Precisa do wine, de um prefixo de 32 bits e deste pacote junto à plataforma:",
|
||||
"notRunning": "A sessão do Minesweeper não está em execução.",
|
||||
"error": {
|
||||
"connectionFailed": "A ligação à sessão do Minesweeper falhou.",
|
||||
"refused": "O servidor VNC recusou a ligação.",
|
||||
"authUnsupported": "O servidor VNC pede autenticação, que este cliente não implementa.",
|
||||
"handshakeRejected": "O servidor VNC rejeitou o handshake.",
|
||||
"unknownMessage": "O servidor VNC enviou um tipo de mensagem desconhecido ({{type}}).",
|
||||
"unknownEncoding": "O servidor VNC usou a codificação {{encoding}}, que este cliente não pediu."
|
||||
}
|
||||
},
|
||||
"qrTransfer": {
|
||||
"chooseFile": "Escolha um ficheiro para enviar",
|
||||
"startScanning": "Começar a digitalizar",
|
||||
"receiveAnother": "Receber outro",
|
||||
"stop": "Parar"
|
||||
},
|
||||
"dav": {
|
||||
"empty": "Ainda não há nada aqui. Adicione um a partir de um cliente de telemóvel ou computador por CalDAV/CardDAV.",
|
||||
"calendar": "Calendário",
|
||||
"contacts": "Contactos",
|
||||
"readFailed": "Não foi possível ler {{name}}: {{reason}}"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
"find": "Procurar",
|
||||
"runningShells": "Shells em execução",
|
||||
"loading": "A carregar…",
|
||||
"noShells": "Nenhuma shell em execução.",
|
||||
"refresh": "Atualizar",
|
||||
"kill": "Terminar esta shell"
|
||||
},
|
||||
"appStore": {
|
||||
"loading": "A carregar",
|
||||
"loadFailed": "Não foi possível carregar a loja de aplicações.",
|
||||
"installed": "Instaladas",
|
||||
"available": "Disponíveis",
|
||||
"needsYou": "Precisa de si",
|
||||
"needsYouShort": "precisa de si",
|
||||
"selectApp": "Selecione uma aplicação",
|
||||
"installFailed": "A instalação falhou",
|
||||
"uninstall": "Desinstalar",
|
||||
"enable": "Ativar",
|
||||
"disable": "Desativar",
|
||||
"enabled": "ativada",
|
||||
"disabled": "desativada",
|
||||
"removalNote": "Desativar para o sidecar e o seu contentor. Desinstalar remove também os contentores — os seus dados, a configuração e as tabelas desta aplicação são mantidos em qualquer dos casos.",
|
||||
"installing": "a instalar",
|
||||
"failed": "falhou",
|
||||
"installedIcon": "instalada"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user