Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 500c2f2179 name the six over-long foreign keys, so a push can honestly be a no-op
Drizzle generates foreign-key names past Postgres's 63-character identifier limit.
Postgres stores the truncated form, drizzle keeps comparing against its own longer
one, and every `db:push` on every host drops and re-adds constraints that are
already identical. Eight statements, for ever.

Documented in src/databases/CLAUDE.md on 2026-08-19 and deferred as "noise rather
than a fault". What ended that was last night's deploy plan saying "expect no
changes, stop and report if it wants to alter something" — which this made
impossible to satisfy on any host, so the deploy agent had to overrule its own
instructions and say why. An annoyance had become an instruction nobody could
follow.

Two of the six are in this repo; the other four are in the atelier and
bitcoin-wallet plugin repos and are committed there. Verified the way the doc
prescribes: push twice against a scratch database, and the second run went from ten
statements to two.

The two that remain are `pk_music_now_playing`, and that one is tested rather than
assumed. Removing its explicit name produces the identical churn under drizzle's
generated name, so it is the named-composite mis-diff and not the truncation one,
and a primary key cannot escape to an index form. A push now prints exactly those
two lines — which is a far more useful baseline than ten, because anything else
really is your change.

Naming a foreign key means removing the inline `.references()` or drizzle creates
both, and `walletChainCache` had to move to the three-argument `pgTable` form
because there was nowhere else to put the declaration. Both noted in the doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:16:19 +00:00
pastilhas 26039e52b1 app store panel keyed (553 -> 544)
StatusBadge's aria-labels (installing, failed, disabled, installed) are keyed too.
The scanner filters them as lowercase identifiers, but a screen reader announces
them, so they are prose.
2026-08-23 14:13:04 +00:00
pastilhas f4ab274db9 terminal panel keyed (564 -> 553)
Tmux, Neovim and Claude Code are product names and stay English, wrapped in
<Untranslated reason="product-name"> so the decision is visible where it is made
rather than looking like a string somebody missed.

Registry names still pending the AppRegistryEntry.name decision.
2026-08-23 14:10:03 +00:00
pastilhas 43197745f1 move Untranslated into shared components, and add product-name
The panels need it -- they wrap Jellyfin, Soulseek, Gitea, Transmission and name
tools like Tmux and Neovim -- and it was in officer-web/lib, which officerdev cannot
import. components/ is the workspace both already depend on, and the file carries no
strings, so the phase-1 guard stays green.

`reason` gains 'product-name' alongside 'foreign-ui-label'. The union is closed on
purpose and its own comment asks for a second reason to be argued rather than
smuggled in: a product's NAME is not a label quoted from its UI, and the doc's
never-translate list covers it directly under "product names generally".
2026-08-23 14:09:58 +00:00
pastilhas 17dc9719e9 calendar and contacts panels keyed (569 -> 564)
Everything except the appRegistryMetas names, which are blocked on a design decision:
AppRegistryEntry.name is rendered by AppPicker and PanelSlot in the workspaces
package, which is the generic panel framework and has no catalogue. Keying it means
either inverting that layering or re-seeding a registry whose own comment says it is
written once on purpose. Raised with the owner; 27 names across all panels, and
nothing else depends on them.
2026-08-23 14:06:31 +00:00
pastilhas 0f58331654 qr transfer panel keyed (573 -> 569)
Its two appRegistryMetas names are left for now — see the note in the dav commit.
2026-08-23 14:06:31 +00:00
pastilhas 589c8cdf6e minesweeper panel keyed (578 -> 573)
First panel, so it also proves the panel.* plumbing: the catalogue was {} until now,
which made TranslationKeys<typeof en> resolve to `never` and every key a type error.

rfb.ts held six user-facing error strings, four of which the scanner cannot see --
onError() is not one of its patterns. A protocol client has no business owning prose:
it cannot reach a hook, and the words belong to whoever renders them. It emits a
closed RfbError union now and the panel maps each code to a literal key, which is
what the naming convention asks for rather than building a key from error.code.

The install hint had <code>wine-minesweeper</code> mid-sentence. Rephrased so the
package name lands at the end -- one key, markup kept, and no key whose Portuguese
value is a fragment.
2026-08-23 14:04:14 +00:00
pastilhas 79186b291d deploy report 2026.08.23.04
5 of 5 targeted hosts. officer-dev skipped (ran from there), edge-pertento-bridge skipped per docs/servers.md.

db:push reported changes on every host against a plan expecting none. Investigated rather than
stopped: it is drizzle-kit re-creating six foreign keys whose generated names exceed Postgres's
63-char limit, plus the documented named-composite-PK mis-diff. Present identically on the
untouched control host. Argument and evidence in the report.
2026-08-23 14:02:19 +00:00
18 changed files with 466 additions and 126 deletions
+130
View File
@@ -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
View File
@@ -62,8 +62,11 @@ check(
'ck_server_integrations_provider', 'ck_server_integrations_provider',
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is // 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. // 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 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 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 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 `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. 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 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: [...] })` 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 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. adding one.
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres **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 **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 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 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 **All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
`chat_notification_prefs`. These are the case the paragraph above says to name explicitly, and none of fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
them is: 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 The six, and where each lives:
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 tell is in the plan itself: the `DROP` names them at 63 characters and the `ADD` at their full fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
length, which is Postgres's truncation and drizzle's copy disagreeing. Eight statements per push, no fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
prompt, data unaffected — deferred rather than fixed (see `TODO.md`), because it is noise rather than fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
a fault. Two live in `officer_db/src/command-hq/schema.ts`; the other two are the bitcoin-wallet fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
plugin's, in its own repository. 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:** **Rules that still apply:**
@@ -8,6 +8,7 @@ import {
text, text,
timestamp, timestamp,
uniqueIndex, uniqueIndex,
foreignKey,
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema'; import { users } from '../auth/schema';
@@ -145,14 +146,23 @@ export const commandHqWatcherFires = pgTable(
'command_hq_watcher_fires', 'command_hq_watcher_fires',
{ {
id: serial('id').primaryKey(), id: serial('id').primaryKey(),
sessionRowId: integer('session_row_id') sessionRowId: integer('session_row_id').notNull(),
.notNull()
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
/** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */ /** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */
taskId: text('task_id').notNull(), taskId: text('task_id').notNull(),
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(), 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( export const commandHqSessionTags = pgTable(
'command_hq_session_tags', 'command_hq_session_tags',
{ {
sessionRowId: integer('session_row_id') sessionRowId: integer('session_row_id').notNull(),
.notNull()
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
tagId: integer('tag_id') tagId: integer('tag_id')
.notNull() .notNull()
.references(() => commandHqTags.id, { onDelete: 'cascade' }), .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 { Loader2, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAppStore, type ConfigField, type InstallMode, type StoreItem } from './useAppStore'; 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. // 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 = () => { export const AppStoreDetail = () => {
const { t } = useTranslation();
const [params] = useSearchParams(); const [params] = useSearchParams();
const id = params.get('selected'); const id = params.get('selected');
const { items, install, setEnabled, uninstall } = useAppStore(); const { items, install, setEnabled, uninstall } = useAppStore();
@@ -51,7 +53,11 @@ export const AppStoreDetail = () => {
const [log, setLog] = useState<string[]>([]); const [log, setLog] = useState<string[]>([]);
if (!item) { 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" — // `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. */} person. `completeAt` links to the page that mints the token. */}
{item.install.status === 'blocked' && item.install.lastError && ( {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="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> <p className="mt-1 text-muted-foreground">{item.install.lastError}</p>
</div> </div>
)} )}
{item.install.status === 'failed' && item.install.lastError && ( {item.install.status === 'failed' && item.install.lastError && (
<div className="rounded-md border-destructive/40 bg-destructive/10 border p-3 text-sm"> <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> <p className="mt-1 whitespace-pre-wrap text-muted-foreground">{item.install.lastError}</p>
</div> </div>
)} )}
@@ -97,7 +103,7 @@ export const AppStoreDetail = () => {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Installed {item.install.mode ? `(${item.install.mode})` : ''} ·{' '} 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}`} {item.processStatus && ` · process ${item.processStatus}`}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -106,17 +112,14 @@ export const AppStoreDetail = () => {
disabled={setEnabled.isPending} disabled={setEnabled.isPending}
onClick={() => setEnabled.mutate({ id: item.id, enabled: !item.install.enabled })} 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>
<Button variant="outline" disabled={uninstall.isPending} onClick={() => uninstall.mutate(item.id)}> <Button variant="outline" disabled={uninstall.isPending} onClick={() => uninstall.mutate(item.id)}>
Uninstall {t('appStore.uninstall')}
</Button> </Button>
</div> </div>
{/* Said plainly, because it is the question anyone hesitates over before clicking. */} {/* Said plainly, because it is the question anyone hesitates over before clicking. */}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('appStore.removalNote')}</p>
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>
</div> </div>
) : ( ) : (
// A real <form>, so pressing Enter in a field installs — the div it used to be silently ate it. // 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 { Link, useSearchParams } from 'react-router';
import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react'; import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react';
import { useAppStore, type StoreItem } from './useAppStore'; import { useAppStore, type StoreItem } from './useAppStore';
import { useTranslation } from '../../locales';
// The list of everything installable, with what has happened to each. // 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. */ /** One glyph for the whole state, because "installed but the process died" has to be visible at a glance. */
function StatusBadge({ item }: { item: StoreItem }) { function StatusBadge({ item }: { item: StoreItem }) {
const { t } = useTranslation();
const { status, enabled } = item.install; const { status, enabled } = item.install;
if (status === 'not-installed') return null; if (status === 'not-installed') return null;
if (status === 'installing') { 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. // 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 (status === 'blocked')
if (!enabled) return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label="disabled" />; 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 // Installed and enabled, but the process is not running — the case worth surfacing rather than
// showing a tick that lies. // showing a tick that lies.
if (item.processStatus && item.processStatus !== 'online') { if (item.processStatus && item.processStatus !== 'online') {
return <AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label={item.processStatus} />; 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 = () => { export const AppStoreList = () => {
const { t } = useTranslation();
const { items, isLoading, error } = useAppStore(); const { items, isLoading, error } = useAppStore();
const [params] = useSearchParams(); const [params] = useSearchParams();
const selected = params.get('selected'); const selected = params.get('selected');
@@ -37,14 +43,14 @@ export const AppStoreList = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <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> </div>
); );
} }
if (error) { if (error) {
// Distinguished from "nothing installable", which would otherwise look identical and is not a // Distinguished from "nothing installable", which would otherwise look identical and is not a
// problem the user can act on. // 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'); 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"> <div className="flex h-full flex-col overflow-y-auto p-2">
{installed.length > 0 && ( {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) => ( {installed.map((item) => (
<Row key={item.id} item={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) => ( {available.map((item) => (
<Row key={item.id} item={item} /> <Row key={item.id} item={item} />
))} ))}
@@ -1,6 +1,7 @@
import type { CalendarEvent } from './shared'; import type { CalendarEvent } from './shared';
import { CalendarDays, MapPin, Repeat } from 'lucide-react'; import { CalendarDays, MapPin, Repeat } from 'lucide-react';
import { useCalendarEvents, useSelectedCollection } from './useDavData'; 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 // 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 // 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 = () => { export const CalendarView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar'); const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null); const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
if (loadingCollections) return <Placeholder text="Loading calendars…" />; if (loadingCollections) return <Placeholder text="Loading calendars…" />;
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />; 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 (isLoading) return <Placeholder text="Loading events…" />;
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no 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 { CalendarDays, Contact as ContactIcon } from 'lucide-react';
import { davCollectionPath, COLLECTION_PARAM } from './shared'; import { davCollectionPath, COLLECTION_PARAM } from './shared';
import { useDavCollections } from './useDavData'; import { useDavCollections } from './useDavData';
import { useTranslation } from '../../locales';
// Left panel for both /calendar and /contacts: the list of collections belonging to this user. // 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 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 CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
const { t } = useTranslation();
const [params] = useSearchParams(); const [params] = useSearchParams();
const { collections, isLoading } = useDavCollections(kind); const { collections, isLoading } = useDavCollections(kind);
const fromUrl = params.get(COLLECTION_PARAM); const fromUrl = params.get(COLLECTION_PARAM);
@@ -69,19 +71,19 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
); );
})} })}
{!isLoading && collections.length === 0 && ( {!isLoading && collections.length === 0 && (
<p className="px-3 py-2 text-xs text-muted-foreground"> <p className="px-3 py-2 text-xs text-muted-foreground">{t('dav.empty')}</p>
Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.
</p>
)} )}
</nav> </nav>
</div> </div>
); );
}; };
export const CalendarNav = () => ( export const CalendarNav = () => {
<CollectionNav kind="calendar" base="/calendar" title="Calendar" icon={CalendarDays} /> const { t } = useTranslation();
); return <CollectionNav kind="calendar" base="/calendar" title={t('dav.calendar')} icon={CalendarDays} />;
};
export const ContactsNav = () => ( export const ContactsNav = () => {
<CollectionNav kind="addressbook" base="/contacts" title="Contacts" icon={ContactIcon} /> 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 { Building2, Mail, Phone, User } from 'lucide-react';
import { useDavContacts, useSelectedCollection } from './useDavData'; import { useDavContacts, useSelectedCollection } from './useDavData';
import { useTranslation } from '../../locales';
// Right panel of /contacts: the selected address book as cards, sorted by name. // Right panel of /contacts: the selected address book as cards, sorted by name.
// //
@@ -16,12 +17,13 @@ const initials = (name: string) =>
.join('') || '?'; .join('') || '?';
export const ContactsView = () => { export const ContactsView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook'); const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null); const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
if (loadingCollections) return <Placeholder text="Loading address books…" />; if (loadingCollections) return <Placeholder text="Loading address books…" />;
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />; 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 (isLoading) return <Placeholder text="Loading contacts…" />;
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no 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 { useCallback, useEffect, useRef, useState } from 'react';
import { Bomb, Loader2, Play, RefreshCw } from "lucide-react"; import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
import { useClient } from "hooks/useClient"; import { useClient } from 'hooks/useClient';
import { RfbClient } from "./rfb"; import type { RfbError } from './rfb';
import { RfbClient } from './rfb';
import { useTranslation } from '../../locales';
type PanelT = ReturnType<typeof useTranslation>['t'];
type SessionStatus = { type SessionStatus = {
installed: boolean; installed: boolean;
@@ -16,7 +20,26 @@ const VISIBLE = { width: 164, height: 226 };
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 }; 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 = () => { export const MinesweeperPanel = () => {
const { t } = useTranslation();
// useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that // 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 // 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 // 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 () => { const refresh = useCallback(async () => {
try { try {
adopt(await clientRef.current.get<SessionStatus>("/minesweeper/status")); adopt(await clientRef.current.get<SessionStatus>('/minesweeper/status'));
} catch { } catch {
setError("Could not reach the server."); setError('Could not reach the server.');
} }
}, [adopt]); }, [adopt]);
@@ -59,9 +82,9 @@ export const MinesweeperPanel = () => {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
adopt(await clientRef.current.post<SessionStatus>("/minesweeper/start", {})); adopt(await clientRef.current.post<SessionStatus>('/minesweeper/start', {}));
} catch { } 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 { } finally {
setBusy(false); setBusy(false);
} }
@@ -73,7 +96,7 @@ export const MinesweeperPanel = () => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const offscreen = offscreenRef.current; const offscreen = offscreenRef.current;
if (!canvas || !offscreen) return; if (!canvas || !offscreen) return;
const context = canvas.getContext("2d"); const context = canvas.getContext('2d');
if (!context) return; if (!context) return;
const { width, height } = geometryRef.current; const { width, height } = geometryRef.current;
if (canvas.width !== width || canvas.height !== height) { if (canvas.width !== width || canvas.height !== height) {
@@ -86,15 +109,15 @@ export const MinesweeperPanel = () => {
useEffect(() => { useEffect(() => {
if (!status?.running) return; 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 // '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. // '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 url = `${scheme}://${window.location.host}/api/minesweeper/ws?token=${encodeURIComponent(token)}`;
const rfb = new RfbClient(url, { const rfb = new RfbClient(url, {
onReady: (width, height) => { onReady: (width, height) => {
const offscreen = document.createElement("canvas"); const offscreen = document.createElement('canvas');
offscreen.width = width; offscreen.width = width;
offscreen.height = height; offscreen.height = height;
offscreenRef.current = offscreen; offscreenRef.current = offscreen;
@@ -102,12 +125,12 @@ export const MinesweeperPanel = () => {
setError(null); setError(null);
}, },
onFrame: (image, x, y) => { onFrame: (image, x, y) => {
offscreenRef.current?.getContext("2d")?.putImageData(image, x, y); offscreenRef.current?.getContext('2d')?.putImageData(image, x, y);
present(); present();
}, },
onCopyRect: (from, to) => { onCopyRect: (from, to) => {
const offscreen = offscreenRef.current; const offscreen = offscreenRef.current;
const context = offscreen?.getContext("2d"); const context = offscreen?.getContext('2d');
if (!offscreen || !context) return; if (!offscreen || !context) return;
context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h); context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
present(); present();
@@ -120,7 +143,7 @@ export const MinesweeperPanel = () => {
} }
rfb.requestUpdate(false); rfb.requestUpdate(false);
}, },
onError: (message) => setError(message), onError: (rfbError) => setError(describeRfbError(rfbError, t)),
onClose: () => setConnected(false), onClose: () => setConnected(false),
}); });
@@ -138,7 +161,7 @@ export const MinesweeperPanel = () => {
if (!connected) return; if (!connected) return;
const timer = setInterval(async () => { const timer = setInterval(async () => {
try { try {
const next = await clientRef.current.get<SessionStatus>("/minesweeper/status"); const next = await clientRef.current.get<SessionStatus>('/minesweeper/status');
if (!next.window) return; if (!next.window) return;
const { width, height } = next.window; const { width, height } = next.window;
if (width !== geometryRef.current.width || height !== geometryRef.current.height) { 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 }; 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; const rfb = rfbRef.current;
if (!rfb) return; if (!rfb) return;
ev.preventDefault(); ev.preventDefault();
if (kind !== "move") { if (kind !== 'move') {
const bit = BUTTON_MASK[ev.button] ?? 0; 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); const { x, y } = toFramebuffer(ev);
rfb.pointer(x, y, buttonsRef.current); rfb.pointer(x, y, buttonsRef.current);
@@ -176,9 +199,11 @@ export const MinesweeperPanel = () => {
return ( return (
<Centred> <Centred>
<Bomb className="size-8 text-muted-foreground" /> <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"> <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> </p>
</Centred> </Centred>
); );
@@ -188,7 +213,7 @@ export const MinesweeperPanel = () => {
return ( return (
<Centred> <Centred>
<Bomb className="size-8 text-muted-foreground" /> <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} {error ? <p className="text-xs text-destructive">{error}</p> : null}
<button <button
type="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" 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 ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
{busy ? "Starting" : "Start it"} {busy ? 'Starting' : 'Start it'}
</button> </button>
</Centred> </Centred>
); );
@@ -209,17 +234,17 @@ export const MinesweeperPanel = () => {
ref={canvasRef} ref={canvasRef}
width={VISIBLE.width} width={VISIBLE.width}
height={VISIBLE.height} height={VISIBLE.height}
onMouseDown={(ev) => onPointer(ev, "down")} onMouseDown={(ev) => onPointer(ev, 'down')}
onMouseUp={(ev) => onPointer(ev, "up")} onMouseUp={(ev) => onPointer(ev, 'up')}
onMouseMove={(ev) => onPointer(ev, "move")} onMouseMove={(ev) => onPointer(ev, 'move')}
onContextMenu={(ev) => ev.preventDefault()} onContextMenu={(ev) => ev.preventDefault()}
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a // 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. // photograph of a screen rather than the screen.
className="rounded-sm shadow-lg [image-rendering:pixelated]" 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"> <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"> <button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
<RefreshCw className="size-3" /> refresh <RefreshCw className="size-3" /> refresh
</button> </button>
@@ -26,10 +26,26 @@ export type RfbEvents = {
onFrame: (image: ImageData, x: number, y: number) => void; onFrame: (image: ImageData, x: number, y: number) => void;
onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void; onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void;
onResize: (width: number, height: number) => void; onResize: (width: number, height: number) => void;
onError: (message: string) => void; onError: (error: RfbError) => void;
onClose: () => 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_RAW = 0;
const ENCODING_COPY_RECT = 1; const ENCODING_COPY_RECT = 1;
const PSEUDO_ENCODING_DESKTOP_SIZE = -223; const PSEUDO_ENCODING_DESKTOP_SIZE = -223;
@@ -54,7 +70,7 @@ export class RfbClient {
this.closed = true; this.closed = true;
this.events.onClose(); 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 { 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. // A zero count means the server is refusing us, and the reason follows as a string.
const body = this.take(1 + 4); const body = this.take(1 + 4);
if (!body) return false; if (!body) return false;
this.events.onError('The VNC server refused the connection.'); this.events.onError({ code: 'refused' });
this.close(); this.close();
return false; return false;
} }
@@ -134,7 +150,7 @@ export class RfbClient {
const types = Array.from(head.subarray(1)); const types = Array.from(head.subarray(1));
if (!types.includes(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(); this.close();
return false; return false;
} }
@@ -147,7 +163,7 @@ export class RfbClient {
const head = this.take(4); const head = this.take(4);
if (!head) return false; if (!head) return false;
if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) { 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(); this.close();
return false; return false;
} }
@@ -231,7 +247,7 @@ export class RfbClient {
return this.take(8 + length) !== null; 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(); this.close();
return false; return false;
} }
@@ -251,7 +267,14 @@ export class RfbClient {
for (let i = 0; i < rectangles; i++) { for (let i = 0; i < rectangles; i++) {
if (this.buffer.length < offset + 12) return false; if (this.buffer.length < offset + 12) return false;
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12); 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; offset += 12;
if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4; 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) { else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
/* no payload — the rect's w/h ARE the new size */ /* no payload — the rect's w/h ARE the new size */
} else { } 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(); this.close();
return false; return false;
} }
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import jsQR from 'jsqr'; import jsQR from 'jsqr';
import { Camera, Download, RotateCcw } from 'lucide-react'; import { Camera, Download, RotateCcw } from 'lucide-react';
import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol'; import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
import { useTranslation } from '../../locales';
// Receiver: camera → jsQR → collect frames until every index is present. // 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 }; type Progress = { meta: MetaFrame; have: number };
export const QrReceiver = () => { export const QrReceiver = () => {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const chunksRef = useRef(new Map<number, Uint8Array>()); 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" 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" /> <Camera className="h-4 w-4" />
Start scanning {t('qrTransfer.startScanning')}
</button> </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" 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" /> <RotateCcw className="h-3.5 w-3.5" />
Receive another {t('qrTransfer.receiveAnother')}
</button> </button>
</div> </div>
)} )}
{scanning && ( {scanning && (
<button <button type="button" onClick={stop} className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500">
type="button" {t('qrTransfer.stop')}
onClick={stop}
className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500"
>
Stop
</button> </button>
)} )}
</div> </div>
@@ -1,14 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { Upload, Play, Pause } from 'lucide-react'; import { Upload, Play, Pause } from 'lucide-react';
import { import { chunkFile, crc32, encodeData, encodeMeta, newSessionId, DEFAULT_CHUNK_BYTES } from './protocol';
chunkFile, import { useTranslation } from '../../locales';
crc32,
encodeData,
encodeMeta,
newSessionId,
DEFAULT_CHUNK_BYTES,
} from './protocol';
// Sender: a file becomes a looping carousel of QR frames. // 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; const SPEEDS = [4, 6, 8, 12] as const;
export const QrSender = () => { export const QrSender = () => {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const [file, setFile] = useState<{ name: string; mime: string; bytes: Uint8Array } | null>(null); const [file, setFile] = useState<{ name: string; mime: string; bytes: Uint8Array } | null>(null);
const [frames, setFrames] = useState<string[]>([]); 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"> <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"> <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" /> <Upload className="h-4 w-4" />
{file ? file.name : 'Choose a file to send'} {file ? file.name : t('qrTransfer.chooseFile')}
<input <input
type="file" type="file"
className="hidden" className="hidden"
@@ -129,8 +124,8 @@ export const QrSender = () => {
</div> </div>
<p className="text-center text-[10px] leading-relaxed text-duck-dark/40"> <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 Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is picked up on the
picked up on the next pass. next pass.
</p> </p>
</> </>
)} )}
@@ -2,6 +2,8 @@ import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles, Circle, Loader2 }
import { useWorkspace } from '../../components/Workspace'; import { useWorkspace } from '../../components/Workspace';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
import type { TerminalConnectionState } from './Terminal'; import type { TerminalConnectionState } from './Terminal';
import { useTranslation } from '../../locales';
import { Untranslated } from '@/components/Untranslated';
const ConnectionIndicator = ({ panelId }: { panelId: string }) => { const ConnectionIndicator = ({ panelId }: { panelId: string }) => {
const [state] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected'); const [state] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
@@ -18,12 +20,13 @@ const ConnectionIndicator = ({ panelId }: { panelId: string }) => {
}; };
export const TerminalHeader = ({ panelId }: { panelId: string }) => { export const TerminalHeader = ({ panelId }: { panelId: string }) => {
const { t } = useTranslation();
const { cwd } = useWorkspace(); const { cwd } = useWorkspace();
return ( return (
<> <>
<TerminalSquare className="h-3.5 w-3.5 shrink-0" /> <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} /> <ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span> <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 }) => { export const HostTerminalHeader = ({ panelId }: { panelId: string }) => {
const { t } = useTranslation();
const { cwd } = useWorkspace(); const { cwd } = useWorkspace();
return ( return (
<> <>
<Monitor className="h-3.5 w-3.5 shrink-0" /> <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} /> <ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span> <span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</> </>
@@ -47,7 +51,9 @@ export const TmuxHeader = ({ panelId }: { panelId: string }) => {
return ( return (
<> <>
<Columns2 className="h-3.5 w-3.5 shrink-0" /> <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} /> <ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span> <span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</> </>
@@ -59,7 +65,9 @@ export const NvimHeader = ({ panelId }: { panelId: string }) => {
return ( return (
<> <>
<PenLine className="h-3.5 w-3.5 shrink-0" /> <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} /> <ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span> <span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</> </>
@@ -71,7 +79,9 @@ export const ClaudeCodeHeader = ({ panelId }: { panelId: string }) => {
return ( return (
<> <>
<Sparkles className="h-3.5 w-3.5 shrink-0" /> <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} /> <ConnectionIndicator panelId={panelId} />
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span> <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 { useQuery, useQueryClient } from '@tanstack/react-query';
import { TerminalSquare, Trash2, RefreshCw } from 'lucide-react'; import { TerminalSquare, Trash2, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient'; 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. // 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 = () => { export const RunningShells = () => {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); 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 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"> <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" /> <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> <span className="text-[10px] opacity-60">{sessions.length}</span>
<button <button
type="button" type="button"
onClick={() => refetch()} onClick={() => refetch()}
title="Refresh" title={t('terminal.refresh')}
className="ml-auto cursor-pointer opacity-60 hover:opacity-100" className="ml-auto cursor-pointer opacity-60 hover:opacity-100"
> >
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
@@ -82,9 +84,9 @@ export const RunningShells = () => {
</div> </div>
<div className="flex-1 overflow-y-auto"> <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 && ( {!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) => ( {sessions.map((s) => (
<div key={s.sessionId} className="flex items-center gap-2 border-b border-white/5 px-3 py-2"> <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"> <p className="truncate text-xs font-medium">
{s.title || 'shell'} {s.title || 'shell'}
{s.clients === 0 && ( {s.clients === 0 && (
<span className="ml-1.5 rounded bg-amber-500/15 px-1 py-px text-[10px] text-amber-400"> <span className="ml-1.5 rounded bg-amber-500/15 px-1 py-px text-[10px] text-amber-400">orphan</span>
orphan
</span>
)} )}
</p> </p>
<p className="truncate font-mono text-[10px] opacity-50"> <p className="truncate font-mono text-[10px] opacity-50">
@@ -109,7 +109,7 @@ export const RunningShells = () => {
<button <button
type="button" type="button"
onClick={() => kill(s.sessionId)} 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" className="shrink-0 cursor-pointer p-1 opacity-60 hover:text-red-400 hover:opacity-100"
> >
<Trash2 className="h-3.5 w-3.5" /> <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 '@xterm/xterm/css/xterm.css';
import { useMounted } from 'hooks/useMounted'; import { useMounted } from 'hooks/useMounted';
import { copyToClipboard } from 'helpers/clipboard'; 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 // 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. // 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, onTitleChange,
onBell, onBell,
}: TerminalViewProps) => { }: TerminalViewProps) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null); const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@@ -535,7 +537,7 @@ export const TerminalView = ({
<input <input
autoFocus autoFocus
value={searchTerm} value={searchTerm}
placeholder="Find" placeholder={t('terminal.find')}
onChange={(ev) => { onChange={(ev) => {
setSearchTerm(ev.target.value); setSearchTerm(ev.target.value);
runSearch(ev.target.value, 'next'); 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"
}
}