From 8b212fffe5482ca8e1ea1d1a96a6124ee68b751b Mon Sep 17 00:00:00 2001 From: Matt Hill <9935159+MattDHill@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:34:42 -0600 Subject: [PATCH 1/2] fix: wait for the document server before configuring its connector; 34.0.3:3 -> 34.0.3:4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a suite and starting Nextcloud before the document server was up left editing permanently broken. `richdocuments:activate-config` sets the WOPI url and then fetches discovery through this container's own proxy; when Collabora had bound its port but was not yet accepting connections the fetch returned 503, leaving the app holding a url with no discovery behind it. Every document then posted to the token endpoint, got a 500, and opened to a spinner that never resolved. The reconcile logged a warning and returned without recording its signature, which only schedules a retry on the next chain build — and nothing rebuilds the chain once the backend address has resolved, so it never retried. A bridge address is not a readiness signal: the port is bound, and the address therefore resolves, well before coolwsd accepts a connection. The oneshot now waits on the document server's own health check before it touches Nextcloud at all. That check fetches the endpoint this package depends on, so a pass means the work can succeed rather than merely that something is listening. The wait is a subscription, not a poll: it runs no commands, cannot fail, and releases the instant the service is ready, whether that is seconds later or whenever the user installs it. Nothing in the chain requires the oneshot, so a parked one holds nothing up. Past the gate every failure is unexpected, so none of them throw — a rejected oneshot fn is re-invoked on a widening backoff, which would run occ forever against a service that is simply not there. Switching suites now disables the connector it moves away from. richdocuments demotes the Microsoft formats whenever it finds a rival enabled, so every switch previously landed on a failing health check naming a step the package could take itself. It also enables the connector for the suite just selected, since occ refuses an app with no release compatible with the running major version and enforces that protection on its own. Both office services are declared in the manifest, so an uninstalled one is shown with its own title and icon rather than a bare package id, and the dependency result is typed against the manifest so a missing declaration is a compile error. The four Apache proxy modules move to `a2enmod` in the image; only the generated proxy config is still written at runtime. Health-check messages become whole sentences with a parameter rather than six concatenated fragments, and the check polls sparingly once settled while keeping the SDK default's fast first result and recovery. The ONLYOFFICE secret is read after the gate, because its install writes that file after its port is bound and a snapshot taken at chain build can be empty. Everywhere the docs still told users to install the connector app by hand is corrected; the package has done that since 34.0.3:3. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 3 +- README.md | 36 +-- instructions.md | 2 +- nextcloud.Dockerfile | 3 + startos/actions/setOfficeSuite.ts | 8 +- startos/dependencies.ts | 3 +- startos/fileModels/store.json.ts | 3 +- startos/i18n/dictionaries/default.ts | 20 +- startos/i18n/dictionaries/translations.ts | 54 ++-- startos/main.ts | 285 +++++++++++----------- startos/manifest/i18n.ts | 26 ++ startos/manifest/index.ts | 25 +- startos/officeSuite.ts | 36 +-- startos/utils.ts | 38 +++ startos/versions/current.ts | 12 +- 15 files changed, 325 insertions(+), 229 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4638eb1..9f5bb73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,4 +37,5 @@ verified, tried, and decided belongs in the commit message and the PR body. - **A `config.php` number the double cannot print back exactly is carried through as source text too**, so `9223372036854775807`, `1.0` and `1.0E+30` survive a write. Ordinary values stay real numbers, which is what keeps `maintenance_window_start` and `redis.port` reaching their validators — do not widen this into writing every integer as a float. - **The File Browser mount's `idmap` (uid 1000 → `www-data` 33) is what makes the integration work at all**, and it needs StartOS 0.4.0-beta.10+. Files other services drop into File Browser's volume under a different uid surface as `nobody` until those services idmap their own mount to 1000 too. - **Adding an external-storage source is a registry edit in `startos/externalStorage.ts` plus a typed mount.** File Browser is the shared hub most services route through, so a direct source is worth adding only for a service whose files live browsably on its own volume. -- **`richdocuments` drops the Microsoft formats the moment a second office connector is enabled** (`CapabilitiesService::hasOtherOOXMLApps` checks `onlyoffice` and `officeonline`), and the second app does not claim them unless it is configured — so Word, Excel and PowerPoint open in neither and silently download. That is what the `office-connectors` health check exists to catch; don't simplify it away because "only one backend is ever configured", which is true and is not the condition that breaks this. +- **`richdocuments` drops the Microsoft formats the moment a second office connector is enabled** (`CapabilitiesService::hasOtherOOXMLApps` checks `onlyoffice` and `officeonline`), and the second app does not claim them unless it is configured — so Word, Excel and PowerPoint open in neither and silently download. That is what the `office-connectors` health check exists to catch; don't simplify it away because the reconcile disables the outgoing connector on a switch, which does not stop a user enabling one by hand. +- **`richdocuments:activate-config` fetches the discovery document as it runs, so it must not be called before Collabora answers.** A fetch that fails still leaves the app holding a WOPI url with no discovery, and every document then opens to a spinner that never resolves — with nothing in Nextcloud's log naming the cause. The `office-suite` oneshot therefore waits on `sdk.getStatus(effects, { packageId }).waitFor(...)` for the document server's own health check before it touches anything. **Do not turn that into a retry.** A oneshot whose fn rejects is re-invoked by the SDK on a widening backoff forever, so a suite selected against a service that is stopped or was never installed would run `occ` every 30 seconds for the life of the chain. The wait costs nothing, runs no commands, cannot fail, and resolves the instant the service is ready; a bridge address that resolves is not that signal, because the port is bound well before `coolwsd` accepts a connection. diff --git a/README.md b/README.md index 025c35e..580adc1 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Six oneshots run alongside them, in order: `chown` hands the data directory to ` **`finish-upgrade` runs after the web daemon is ready, not before it**, which is what makes it safe. In the normal case the upgrade has already happened during init and this is a no-op; when it does have work to do, Apache is up serving the maintenance page while `occ upgrade` runs, exactly as a manual recovery would. It fails open — nothing it does can prevent the service from serving. -Apache also carries a generated `startos-office.conf` and the four proxy modules it needs, written into the container's filesystem on every start. It puts the chosen document server on Nextcloud's own origin — `/browser`, `/cool` and `/hosting` for Collabora, `/ds-vpath` for ONLYOFFICE — which is what lets the editor work on every address Nextcloud is reachable at rather than one. The file is empty when no office suite is selected. +Apache also carries a generated `startos-office.conf`, written into the container's filesystem on every start; the four proxy modules it needs are enabled in the image. It puts the chosen document server on Nextcloud's own origin — `/browser`, `/cool` and `/hosting` for Collabora, `/ds-vpath` for ONLYOFFICE — which is what lets the editor work on every address Nextcloud is reachable at rather than one. The file is empty when no office suite is selected. For Collabora it also rewrites the WOPI discovery response, stripping the absolute origin out of every `urlsrc` so the editor loads same-origin — Nextcloud otherwise copies Collabora's own absolute address into the editor frame verbatim, pinning it to one address. Tracked upstream as nextcloud/richdocuments#6019; if that lands, the rewrite can go. @@ -103,12 +103,12 @@ Three settings depart from what upstream would do: None are required. Both are optional and exist only while they are selected. -| Dependency | Kind | Health checks | Required | -| ------------- | --------- | ------------- | ----------------------------------------------------------- | -| `filebrowser` | `exists` | — | Only while chosen in the External Storage action | -| `coturn` | `running` | **none** | Only while Talk call relaying is on in the Configure action | -| `collabora-online` | `running` | `cool` | Only while chosen in the Office Suite action | -| `onlyoffice-docs` | `running` | `documentserver` | Only while chosen in the Office Suite action; published to the Community Registry, not the Start9 one | +| Dependency | Kind | Health checks | Required | +| ------------------ | --------- | ---------------- | ---------------------------------------------------------------------------------------------------- | +| `filebrowser` | `exists` | — | Only while chosen in the External Storage action | +| `coturn` | `running` | **none** | Only while Talk call relaying is on in the Configure action | +| `collabora-online` | `running` | `cool` | Only while chosen in the Office Suite action | +| `onlyoffice-docs` | `running` | `documentserver` | Only while chosen in the Office Suite action; published to the Community Registry, not the Start9 one | The External Storage action offers only the sources whose backing service is actually installed, so an uninstalled one never appears in the form. @@ -122,10 +122,10 @@ The shared secret is read through a throwaway container that mounts only Coturn' Two interfaces, both on the same binding and port. WebDAV is the same server under a different path, offered separately so the desktop and mobile sync clients have an address to copy. -| Interface | Id | Type | Port | Path | Description | -| --------- | -- | ---- | ---- | ---- | ----------- | -| Web UI | `ui` | ui | 80 | `/` | The web interface of Nextcloud | -| WebDAV | `webdav` | api | 80 | `/remote.php/dav/` | Addresses for WebDAV syncing | +| Interface | Id | Type | Port | Path | Description | +| --------- | -------- | ---- | ---- | ------------------ | ------------------------------ | +| Web UI | `ui` | ui | 80 | `/` | The web interface of Nextcloud | +| WebDAV | `webdav` | api | 80 | `/remote.php/dav/` | Addresses for WebDAV syncing | Neither is masked. The addresses published for `ui` are what init writes into `trusted_domains`, so an address Nextcloud does not know about is rejected by Nextcloud itself, not by StartOS. @@ -173,18 +173,20 @@ Surfaces another StartOS service's files as a folder in Nextcloud Files, using N Selects the document server that opens office files — Collabora Online, ONLYOFFICE Docs, or none. Collabora is labelled recommended in the form and is the right answer for most installs; the trade-off is set out in `instructions.md` under **Which one to choose**, and rests on a measured round-trip rather than a marketing claim: both engines preserve text, tables, images, links, footnotes and fields exactly, but LibreOffice rewrites style-inherited formatting as direct formatting on each run, where ONLYOFFICE returns the file byte-identical in structure. -- **When to run it:** after installing one of the two services and its Nextcloud app, and again to switch or to turn editing off. +- **When to run it:** after installing one of the two services, and again to switch or to turn editing off. - **What it changes:** `officeSuite` in `store.json`. Through it: the package's dependency on that service, the host bridge's IP in `trusted_domains`, the generated Apache proxy that serves the editor from Nextcloud's own origin, and — on the next start, via the `office-suite` oneshot — the connector app's own settings. - **Cost:** seconds, then a restart. - **Repeat safety:** idempotent; the form is pre-filled with the current choice. **Only one connector app may be enabled.** `richdocuments` drops the Microsoft formats out of its default-open capability whenever it finds `onlyoffice` or `officeonline` enabled, and the other app does not pick them up unless it is configured too — so Word, Excel and PowerPoint files open in neither and download instead, with nothing in Nextcloud saying why. The `office-connectors` health check fails while that is the case and names the app to disable. -**Switching first deletes the settings written for the previous backend**, so a connector is never left pointed at a service that has since been uninstalled. That teardown is also what keeps the choice unambiguous: an unconfigured connector registers no file actions of its own, so exactly one handler is live in the Files UI. +**Switching deletes the settings written for the previous backend and disables its connector**, so a connector is never left pointed at a service that has since been uninstalled, and the two are never enabled at once — which is the state that stops Word, Excel and PowerPoint opening in either. -**It installs the connector app, once, on a change of selection.** The `office-suite` oneshot runs `occ app:install` for **Nextcloud Office (Collabora)** or **ONLYOFFICE** when that app is absent. Because it sits behind the signature check it fires only when the selection changes, never on an ordinary start — so an app the user later removes stays removed, and the health check reports it rather than the package silently putting it back. +**It installs and enables the connector app on a change of selection.** The `office-suite` oneshot runs `occ app:install` for **Nextcloud Office (Collabora)** or **ONLYOFFICE** when that app is absent, and `occ app:enable` when it is present but switched off. Because it sits behind the signature check it fires only when the selection changes, never on an ordinary start — so an app the user later removes or disables stays that way, and the health check reports it rather than the package silently putting it back. Neither command overrides Nextcloud's own compatibility check, so an app with no release for the running major version is refused rather than force-enabled. -**It never re-enables a disabled connector.** An app that is present but switched off is either the user's decision or a major Nextcloud upgrade disabling one with no compatible release; re-enabling it is how that protection gets undone, and it is what `Disable Non-default Apps` exists to recover from. That state falls through to the health check instead. +**The reconcile waits for the document server to report healthy before it touches anything.** The `office-suite` oneshot watches the chosen service's status and blocks until its own health check passes — `cool` for Collabora, `documentserver` for ONLYOFFICE. That check fetches the same endpoint this package depends on, so passing it means the work below can succeed rather than merely that something is listening. It matters most for `richdocuments:activate-config`, which refreshes the cached discovery document by fetching it: a fetch that fails partway leaves the app holding a WOPI url with no discovery behind it, and every document then opens to a spinner that never resolves. + +The wait is a subscription, not a poll. It runs no commands, cannot fail, and releases the moment the service becomes ready — a few tens of seconds into an ordinary start, or whenever the user installs the service if they selected it first. A bridge address is not a usable readiness signal here: the port is bound, and the address therefore resolves, well before `coolwsd` accepts its first connection. **The `trusted_domains` entry is load-bearing.** A document server fetches and saves files over the host bridge, and without that entry Nextcloud answers every one of those requests with `Trusted domain error` — the editor opens and then fails to load the document. Nextcloud matches on the host alone, so the bare IP covers whatever port the binding was assigned. @@ -245,7 +247,7 @@ A web-interface failure after the grace period is Nextcloud itself: an app that The transient checks — Recognize Model Download, Memories Indexing, Memories Map Setup, File Scan, Repair — exist only while their task is pending, and report `loading` with a progress message throughout. -**Office Connector** (`office-connectors`) — present only while an office suite is selected. It reads Nextcloud's enabled-app list and fails in two distinct cases, each with its own instruction. +**Office Connector** (`office-connectors`) — present only while an office suite is selected. It reads Nextcloud's enabled-app list and fails in two distinct cases, each with its own instruction. Each poll boots PHP to read that list, so it runs every two minutes while passing and every fifteen seconds while failing. That ceiling is also how long a connector someone has just switched off keeps reading as ready. **The selected suite's connector is not enabled.** Either it was never installed, or it has been removed or switched off since. The message says *Install* or *Enable* accordingly — telling someone to install what they already have is how a message stops being read — and names the other way out: selecting `None` in the Office Suite action. Without this the failure is silent: the reconcile simply waits, the document server runs, and nothing in Nextcloud opens in it. @@ -316,7 +318,7 @@ dependencies: - filebrowser # optional, exists; only while selected as an external-storage source - coturn # optional, running, no health checks; only while Talk call relaying is on - collabora-online # optional, running, health check `cool`; only while selected as the office suite - - onlyoffice-docs # optional, running, health check `documentserver`; only while selected as the office suite + - onlyoffice-docs # optional, running, health check `documentserver`; only while selected as the office suite; Community Registry interfaces: ui: { type: ui, port: 80 } webdav: { type: api, port: 80 } # same binding, path /remote.php/dav/ diff --git a/instructions.md b/instructions.md index 534e8ac..7f7d97e 100644 --- a/instructions.md +++ b/instructions.md @@ -64,7 +64,7 @@ Nextcloud on its own shows your documents but cannot edit them. To edit in the b 1. Install **Collabora Online**, unless you have a specific reason for the other one — see below. ONLYOFFICE Docs is in the Community Registry, so you would need that registry added to your server to find it. 2. Run the **Office Suite** action here and pick the one you installed. Nextcloud installs the app it needs for it — **Nextcloud Office (Collabora)** or **ONLYOFFICE** — and points itself at the service. -Keep only one of those apps enabled. With both on, Nextcloud stops opening Word, Excel and PowerPoint files in either, and this service's page will name the one to disable. It will also tell you if the app is ever removed or switched off while a document server is still selected. +Switching between the two later is the same step: pick the other one, and Nextcloud sets up its app and switches the previous one off. Only one of them may be enabled — with both on, Nextcloud stops opening Word, Excel and PowerPoint files in either. If you ever remove or switch off the app yourself, this service's page will say so and name the one to turn back on. Documents, spreadsheets and presentations then open in the browser from Files, and several people can edit the same file at once. The editor is served from your Nextcloud address, so it works the same on your local network, a public domain, or Tor. diff --git a/nextcloud.Dockerfile b/nextcloud.Dockerfile index 64a1518..153f744 100644 --- a/nextcloud.Dockerfile +++ b/nextcloud.Dockerfile @@ -41,3 +41,6 @@ RUN printf '%s\n' \ '' \ > /etc/apache2/conf-available/startos-dav-redirects.conf \ && a2enconf startos-dav-redirects + +# For the office-suite proxy the package writes into conf-enabled at daemon start. +RUN a2enmod proxy proxy_http proxy_wstunnel substitute diff --git a/startos/actions/setOfficeSuite.ts b/startos/actions/setOfficeSuite.ts index 477955d..1058c5c 100644 --- a/startos/actions/setOfficeSuite.ts +++ b/startos/actions/setOfficeSuite.ts @@ -9,12 +9,12 @@ export const inputSpec = InputSpec.of({ office_suite: Value.select({ name: i18n('Office Suite'), description: i18n( - 'Which document server opens documents, spreadsheets and presentations in your browser. Collabora Online is recommended: it needs about a quarter of the memory and opens more formats. ONLYOFFICE Docs, from the Community Registry, is worth its size only if you shuttle a large body of style-heavy documents back and forth with Microsoft Office. Install the service first, then its Nextcloud app — Nextcloud Office for Collabora, ONLYOFFICE for ONLYOFFICE Docs — and enable only that one.', + 'Which document server opens documents, spreadsheets and presentations in your browser. Install it from the Marketplace first, then pick it here — Nextcloud installs the app it needs and points itself at the service. Collabora Online is recommended: it needs about a quarter of the memory and opens more formats. ONLYOFFICE Docs, from the Community Registry, is worth its size only if you shuttle a large body of style-heavy documents back and forth with Microsoft Office.', ), default: 'none', values: { none: i18n('None'), - collabora: 'Collabora Online (recommended)', + collabora: i18n('Collabora Online (recommended)'), onlyoffice: 'ONLYOFFICE Docs', }, }), @@ -43,7 +43,9 @@ export const setOfficeSuite = sdk.Action.withInput( async ({ effects }) => { const officeSuite = await storeJson.read((s) => s.officeSuite).once() return { - office_suite: isOfficeSuite(officeSuite) ? officeSuite : ('none' as const), + office_suite: isOfficeSuite(officeSuite) + ? officeSuite + : ('none' as const), } }, diff --git a/startos/dependencies.ts b/startos/dependencies.ts index cd59be1..c1925d4 100644 --- a/startos/dependencies.ts +++ b/startos/dependencies.ts @@ -1,5 +1,6 @@ import { T } from '@start9labs/start-sdk' import { externalStorageMeta } from './externalStorage' +import { manifest } from './manifest' import { storeJson } from './fileModels/store.json' import { sdk } from './sdk' import { officeSuiteMeta } from './officeSuite' @@ -11,7 +12,7 @@ export const setDependencies = sdk.setupDependencies(async ({ effects }) => { const talkTurn = await storeJson.read((s) => s.talkTurn).const(effects) const officeSuite = await storeJson.read((s) => s.officeSuite).const(effects) - const deps: T.CurrentDependenciesResult = {} + const deps: T.CurrentDependenciesResult = {} // Only require a source while it's selected. `exists` (not `running`) — we // only need the source's volume present on disk to mount and read/write it. diff --git a/startos/fileModels/store.json.ts b/startos/fileModels/store.json.ts index 3975668..c09e52d 100644 --- a/startos/fileModels/store.json.ts +++ b/startos/fileModels/store.json.ts @@ -61,7 +61,8 @@ const shape = z.object({ // `externalStoragesConfigured` above. talkTurnConfigured: z.string().catch('').default(''), // Which document server serves the office editors, if any. DESIRED state: - // written by the Configure action, read reactively in setupMain/setDependencies. + // written by the Office Suite action, read reactively in + // setupMain/setDependencies. officeSuite: z.enum(['collabora', 'onlyoffice']).optional().catch(undefined), // ACTUAL state: an opaque signature of the office settings this package last // applied, and the record of what to clear on the next change. Same diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index a039cdf..3d68a9b 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -207,20 +207,22 @@ const dict = { 'Delete Files in Trash': 140, "How long Nextcloud keeps a deleted file in each user's Deleted Files before removing it for good. By default files are kept for at least 30 days and then removed only as disk space is needed, so trash can grow without bound on a server with room to spare. Setting a limit removes them on schedule instead. Restoring a file is only possible while it is still in Deleted Files.": 141, - // actions/setConfig.ts — office suite + // actions/setOfficeSuite.ts 'Office Suite': 142, - 'Which document server opens documents, spreadsheets and presentations in your browser. Collabora Online is recommended: it needs about a quarter of the memory and opens more formats. ONLYOFFICE Docs, from the Community Registry, is worth its size only if you shuttle a large body of style-heavy documents back and forth with Microsoft Office. Install the service first, then its Nextcloud app — Nextcloud Office for Collabora, ONLYOFFICE for ONLYOFFICE Docs — and enable only that one.': 143, + 'Which document server opens documents, spreadsheets and presentations in your browser. Install it from the Marketplace first, then pick it here — Nextcloud installs the app it needs and points itself at the service. Collabora Online is recommended: it needs about a quarter of the memory and opens more formats. ONLYOFFICE Docs, from the Community Registry, is worth its size only if you shuttle a large body of style-heavy documents back and forth with Microsoft Office.': 143, None: 144, 'Choose the document server that opens office files in your browser.': 145, - 'Office Connector': 146, + 'Collabora Online (recommended)': 147, + // maintenance/resetAdmin.ts 'This replaces the password on the chosen account immediately. The current one stops working, and anyone signed in as that user is signed out.': 150, - 'Install ': 152, - ' is ready': 154, - 'Disable ': 155, - ' on Nextcloud’s Apps page. With two office apps enabled, Word, Excel and PowerPoint files open in neither.': 156, - 'Enable ': 157, - ' in Nextcloud, or select “None” using the “Office Suite” action.': 158, + + // main.ts: the office-connectors health check + 'Office Connector': 146, + '${app} is ready': 154, + 'Install ${app} in Nextcloud, or select “None” using the “Office Suite” action.': 152, + 'Enable ${app} in Nextcloud, or select “None” using the “Office Suite” action.': 157, + 'Disable ${app} on Nextcloud’s Apps page. With two office apps enabled, Word, Excel and PowerPoint files open in neither.': 155, } as const /** diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index b47c424..69a6a7b 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -145,17 +145,16 @@ export default { 140: 'Eliminar los archivos de la papelera', 141: 'Cuánto tiempo conserva Nextcloud un archivo borrado en los Archivos eliminados de cada usuario antes de suprimirlo definitivamente. De forma predeterminada los archivos se conservan al menos 30 días y solo se suprimen cuando hace falta espacio en disco, por lo que la papelera puede crecer sin límite en un servidor con espacio de sobra. Si establece un límite, se suprimen según ese plazo. Un archivo solo se puede restaurar mientras siga en los Archivos eliminados.', 142: 'Suite ofimática', - 143: 'Qué servidor de documentos abre documentos, hojas de cálculo y presentaciones en su navegador. Se recomienda Collabora Online: necesita alrededor de la cuarta parte de memoria y abre más formatos. ONLYOFFICE Docs, del Registro de la Comunidad, solo compensa su tamaño si mueve un gran volumen de documentos con estilos complejos entre su servidor y Microsoft Office. Instale primero el servicio y después su aplicación de Nextcloud —Nextcloud Office para Collabora, ONLYOFFICE para ONLYOFFICE Docs— y active solo esa.', + 143: 'Qué servidor de documentos abre documentos, hojas de cálculo y presentaciones en su navegador. Instálelo primero desde el Mercado y luego elíjalo aquí: Nextcloud instala la aplicación que necesita y se apunta al servicio. Se recomienda Collabora Online: necesita alrededor de la cuarta parte de memoria y abre más formatos. ONLYOFFICE Docs, del Registro de la Comunidad, solo compensa su tamaño si mueve un gran volumen de documentos con estilos complejos entre su servidor y Microsoft Office.', 144: 'Ninguna', 145: 'Elija el servidor de documentos que abre los archivos ofimáticos en su navegador.', 146: 'Conector ofimático', + 147: 'Collabora Online (recomendado)', 150: 'Esto reemplaza de inmediato la contraseña de la cuenta elegida. La actual deja de funcionar y se cierra la sesión de quien la esté usando.', - 152: 'Instale ', - 154: ' está listo', - 155: 'Desactive ', - 156: ' en la página Aplicaciones de Nextcloud. Con dos aplicaciones ofimáticas activadas, los archivos de Word, Excel y PowerPoint no se abren en ninguna.', - 157: 'Active ', - 158: ' en Nextcloud, o seleccione «Ninguna» con la acción «Suite ofimática».', + 152: 'Instale ${app} en Nextcloud, o seleccione «Ninguna» con la acción «Suite ofimática».', + 154: '${app} está listo', + 155: 'Desactive ${app} en la página Aplicaciones de Nextcloud. Con dos aplicaciones ofimáticas activadas, los archivos de Word, Excel y PowerPoint no se abren en ninguna.', + 157: 'Active ${app} en Nextcloud, o seleccione «Ninguna» con la acción «Suite ofimática».', }, de_DE: { 0: 'Starte Nextcloud...', @@ -301,17 +300,16 @@ export default { 140: 'Dateien im Papierkorb löschen', 141: 'Wie lange Nextcloud eine gelöschte Datei im Papierkorb jedes Benutzers aufbewahrt, bevor sie endgültig entfernt wird. Standardmäßig bleiben Dateien mindestens 30 Tage erhalten und werden erst entfernt, wenn Speicherplatz benötigt wird — auf einem Server mit reichlich Platz kann der Papierkorb dadurch unbegrenzt wachsen. Mit einem Limit werden sie stattdessen planmäßig entfernt. Wiederherstellen lässt sich eine Datei nur, solange sie noch im Papierkorb liegt.', 142: 'Office-Suite', - 143: 'Welcher Dokumentenserver Dokumente, Tabellen und Präsentationen im Browser öffnet. Empfohlen wird Collabora Online: Es benötigt etwa ein Viertel des Arbeitsspeichers und öffnet mehr Formate. ONLYOFFICE Docs aus dem Community-Registry lohnt seine Größe nur, wenn Sie viele formatlastige Dokumente zwischen Ihrem Server und Microsoft Office hin- und herbewegen. Installieren Sie zuerst den Dienst und danach seine Nextcloud-App — Nextcloud Office für Collabora, ONLYOFFICE für ONLYOFFICE Docs — und aktivieren Sie nur diese eine.', + 143: 'Welcher Dokumentenserver Dokumente, Tabellen und Präsentationen im Browser öffnet. Installieren Sie ihn zuerst aus dem Marktplatz und wählen Sie ihn dann hier: Nextcloud installiert die benötigte App und richtet sich selbst auf den Dienst aus. Empfohlen wird Collabora Online: Es benötigt etwa ein Viertel des Arbeitsspeichers und öffnet mehr Formate. ONLYOFFICE Docs aus dem Community-Registry lohnt seine Größe nur, wenn Sie viele formatlastige Dokumente zwischen Ihrem Server und Microsoft Office hin- und herbewegen.', 144: 'Keine', 145: 'Wählen Sie den Dokumentenserver, der Office-Dateien in Ihrem Browser öffnet.', 146: 'Office-Connector', + 147: 'Collabora Online (empfohlen)', 150: 'Dies ersetzt das Passwort des gewählten Kontos sofort. Das aktuelle funktioniert dann nicht mehr, und wer als dieser Benutzer angemeldet ist, wird abgemeldet.', - 152: 'Installieren Sie ', - 154: ' ist bereit', - 155: 'Deaktivieren Sie ', - 156: ' auf der Apps-Seite von Nextcloud. Sind zwei Office-Apps aktiviert, öffnen sich Word-, Excel- und PowerPoint-Dateien in keiner von beiden.', - 157: 'Aktivieren Sie ', - 158: ' in Nextcloud, oder wählen Sie „Keine“ über die Aktion „Office-Suite“.', + 152: 'Installieren Sie ${app} in Nextcloud, oder wählen Sie „Keine“ über die Aktion „Office-Suite“.', + 154: '${app} ist bereit', + 155: 'Deaktivieren Sie ${app} auf der Apps-Seite von Nextcloud. Sind zwei Office-Apps aktiviert, öffnen sich Word-, Excel- und PowerPoint-Dateien in keiner von beiden.', + 157: 'Aktivieren Sie ${app} in Nextcloud, oder wählen Sie „Keine“ über die Aktion „Office-Suite“.', }, pl_PL: { 0: 'Uruchamianie Nextcloud...', @@ -457,17 +455,16 @@ export default { 140: 'Usuwanie plików z kosza', 141: 'Jak długo Nextcloud przechowuje usunięty plik w Usuniętych plikach każdego użytkownika, zanim skasuje go bezpowrotnie. Domyślnie pliki są przechowywane co najmniej 30 dni i usuwane dopiero wtedy, gdy potrzebne jest miejsce na dysku, więc na serwerze z zapasem miejsca kosz może rosnąć bez ograniczeń. Ustawienie limitu powoduje usuwanie ich zgodnie z harmonogramem. Plik można przywrócić tylko dopóki znajduje się w Usuniętych plikach.', 142: 'Pakiet biurowy', - 143: 'Który serwer dokumentów otwiera dokumenty, arkusze i prezentacje w przeglądarce. Zalecana jest Collabora Online: potrzebuje około czterokrotnie mniej pamięci i otwiera więcej formatów. ONLYOFFICE Docs z Rejestru Społeczności wart jest swojego rozmiaru tylko wtedy, gdy przenosisz duże ilości dokumentów o rozbudowanym formatowaniu między swoim serwerem a Microsoft Office. Najpierw zainstaluj usługę, potem jej aplikację Nextcloud — Nextcloud Office dla Collabory, ONLYOFFICE dla ONLYOFFICE Docs — i włącz tylko tę jedną.', + 143: 'Który serwer dokumentów otwiera dokumenty, arkusze i prezentacje w przeglądarce. Najpierw zainstaluj go z Rynku, a potem wskaż tutaj — Nextcloud zainstaluje potrzebną aplikację i sam skieruje się na usługę. Zalecana jest Collabora Online: potrzebuje około czterokrotnie mniej pamięci i otwiera więcej formatów. ONLYOFFICE Docs z Rejestru Społeczności wart jest swojego rozmiaru tylko wtedy, gdy przenosisz duże ilości dokumentów o rozbudowanym formatowaniu między swoim serwerem a Microsoft Office.', 144: 'Brak', 145: 'Wybierz serwer dokumentów, który otwiera pliki biurowe w przeglądarce.', 146: 'Łącznik biurowy', + 147: 'Collabora Online (zalecana)', 150: 'To natychmiast zastąpi hasło wybranego konta. Dotychczasowe przestanie działać, a osoba zalogowana jako ten użytkownik zostanie wylogowana.', - 152: 'Zainstaluj ', - 154: ' jest gotowy', - 155: 'Wyłącz ', - 156: ' na stronie Aplikacje w Nextcloud. Przy dwóch włączonych aplikacjach biurowych pliki Word, Excel i PowerPoint nie otworzą się w żadnej z nich.', - 157: 'Włącz ', - 158: ' w Nextcloud albo wybierz „Brak” w akcji „Pakiet biurowy”.', + 152: 'Zainstaluj ${app} w Nextcloud albo wybierz „Brak” w akcji „Pakiet biurowy”.', + 154: '${app} jest gotowy', + 155: 'Wyłącz ${app} na stronie Aplikacje w Nextcloud. Przy dwóch włączonych aplikacjach biurowych pliki Word, Excel i PowerPoint nie otworzą się w żadnej z nich.', + 157: 'Włącz ${app} w Nextcloud albo wybierz „Brak” w akcji „Pakiet biurowy”.', }, fr_FR: { 0: 'Démarrage de Nextcloud...', @@ -613,16 +610,15 @@ export default { 140: 'Suppression des fichiers de la corbeille', 141: "Durée pendant laquelle Nextcloud conserve un fichier supprimé dans les Fichiers supprimés de chaque utilisateur avant de l'effacer définitivement. Par défaut les fichiers sont conservés au moins 30 jours puis effacés uniquement lorsque de l'espace disque est nécessaire : sur un serveur disposant de place, la corbeille peut donc croître sans limite. Définir une limite les efface selon ce délai. Un fichier ne peut être restauré que tant qu'il se trouve encore dans les Fichiers supprimés.", 142: 'Suite bureautique', - 143: 'Quel serveur de documents ouvre documents, feuilles de calcul et présentations dans votre navigateur. Collabora Online est recommandé : il demande environ quatre fois moins de mémoire et ouvre davantage de formats. ONLYOFFICE Docs, du Registre Communautaire, ne vaut sa taille que si vous faites circuler un grand volume de documents très structurés entre votre serveur et Microsoft Office. Installez d\'abord le service, puis son application Nextcloud — Nextcloud Office pour Collabora, ONLYOFFICE pour ONLYOFFICE Docs — et n\'activez que celle-là.', + 143: "Quel serveur de documents ouvre documents, feuilles de calcul et présentations dans votre navigateur. Installez-le d'abord depuis la Place de marché, puis choisissez-le ici : Nextcloud installe l'application nécessaire et se pointe sur le service. Collabora Online est recommandé : il demande environ quatre fois moins de mémoire et ouvre davantage de formats. ONLYOFFICE Docs, du Registre Communautaire, ne vaut sa taille que si vous faites circuler un grand volume de documents très structurés entre votre serveur et Microsoft Office.", 144: 'Aucune', 145: 'Choisissez le serveur de documents qui ouvre les fichiers bureautiques dans votre navigateur.', 146: 'Connecteur bureautique', - 150: 'Cela remplace immédiatement le mot de passe du compte choisi. L\'actuel cesse de fonctionner et toute personne connectée sous cet utilisateur est déconnectée.', - 152: 'Installez ', - 154: ' est prêt', - 155: 'Désactivez ', - 156: ' sur la page Applications de Nextcloud. Avec deux applications bureautiques activées, les fichiers Word, Excel et PowerPoint ne s\'ouvrent dans aucune.', - 157: 'Activez ', - 158: ' dans Nextcloud, ou sélectionnez « Aucune » avec l\'action « Suite bureautique ».', + 147: 'Collabora Online (recommandé)', + 150: "Cela remplace immédiatement le mot de passe du compte choisi. L'actuel cesse de fonctionner et toute personne connectée sous cet utilisateur est déconnectée.", + 152: "Installez ${app} dans Nextcloud, ou sélectionnez « Aucune » avec l'action « Suite bureautique ».", + 154: '${app} est prêt', + 155: "Désactivez ${app} sur la page Applications de Nextcloud. Avec deux applications bureautiques activées, les fichiers Word, Excel et PowerPoint ne s'ouvrent dans aucune.", + 157: "Activez ${app} dans Nextcloud, ou sélectionnez « Aucune » avec l'action « Suite bureautique ».", }, } satisfies Record diff --git a/startos/main.ts b/startos/main.ts index 58a492f..e21cc4d 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -17,11 +17,9 @@ import { sdk } from './sdk' import { createHash } from 'node:crypto' import { writeFile } from 'node:fs/promises' import { - APACHE_MODULES, CONNECTOR_APP_TITLES, DS_VPATH, isOfficeSuite, - moduleLoadLine, officeMountpoint, OFFICE_CONNECTOR_APPS, officeSecretPath, @@ -43,6 +41,7 @@ import { getPostgresSub, getValkeySub, hasNextcloudApp, + readDependencySecret, readEnabledApps, nextcloudMount, TALK_APP, @@ -191,9 +190,9 @@ export const main = sdk.setupMain(async ({ effects }) => { // Host it sends has to be trusted or Nextcloud answers every request with // `Trusted domain error`. Nextcloud matches on the host alone, so the bare // bridge IP covers whatever port the binding was assigned. - const osIp = await sdk.getOsIp(effects) + const osIp = officeSuite ? await sdk.getOsIp(effects) : null await configPhp.merge(effects, { - trusted_domains: officeSuite + trusted_domains: osIp ? [...new Set([...trustedDomains, osIp])].sort() : trustedDomains, }) @@ -297,30 +296,14 @@ export const main = sdk.setupMain(async ({ effects }) => { } } - // Read through a throwaway container so a missing Coturn can never break - // Nextcloud's own daemons, and we only ever see the `shared` subpath. - async function readCoturnSecret() { - const reader = sdk.SubContainer.of( - effects, - { imageId: 'valkey' }, - sdk.Mounts.of().mountDependency({ - dependencyId: coturnId, - volumeId: 'main', - subpath: 'shared', - mountpoint: coturnMountpoint, - readonly: true, - }), - 'coturn-secret-read', - ) - try { - const { stdout } = await reader.execFail(['cat', coturnSecretPath]) - return stdout.toString().trim() || null - } catch { - return null - } finally { - await reader.destroy().catch(() => {}) - } - } + const readCoturnSecret = () => + readDependencySecret(effects, { + dependencyId: coturnId, + volumeId: 'main', + subpath: 'shared', + mountpoint: coturnMountpoint, + path: coturnSecretPath, + }) // Mount each selected source's volume into Nextcloud's container, read-write. // `idmap` remaps the source's on-disk uid to www-data (33) across the userns @@ -356,13 +339,15 @@ export const main = sdk.setupMain(async ({ effects }) => { // Nextcloud's own bridge address, which is what the document server is told // to fetch and save documents through: plaintext, so neither side needs to // trust this server's certificate. - const ownBridge = await sdk.host - .getBridgeAddress(effects, { - hostId: 'main', - internalPort: uiPort, - ssl: false, - }) - .const() + const ownBridge = officeSuite + ? await sdk.host + .getBridgeAddress(effects, { + hostId: 'main', + internalPort: uiPort, + ssl: false, + }) + .const() + : null const officeBackend = officeSuite ? await sdk.host @@ -375,47 +360,11 @@ export const main = sdk.setupMain(async ({ effects }) => { .const() : null - // ONLYOFFICE signs every request between the two services. Read through a - // throwaway container so a missing or stopped document server can never break - // Nextcloud's own daemons, and we only ever see the `shared` subpath. - const officeSecret = - officeSuite === 'onlyoffice' ? await readOfficeSecret() : null - - async function readOfficeSecret() { - const reader = sdk.SubContainer.of( - effects, - { imageId: 'valkey' }, - sdk.Mounts.of().mountDependency({ - dependencyId: officeSuiteMeta.onlyoffice.packageId, - volumeId: 'startos', - subpath: 'shared', - mountpoint: officeMountpoint, - readonly: true, - }), - 'office-secret-read', - ) - try { - const { stdout } = await reader.execFail(['cat', officeSecretPath]) - return stdout.toString().trim() || null - } catch { - return null - } finally { - await reader.destroy().catch(() => {}) - } - } - // Apache reads its configuration once, at startup, so the proxy that puts the // editor on Nextcloud's own origin has to be written before the web daemon // starts rather than reconciled after it. - const rootfs = await nextcloudSub.rootfs - for (const mod of APACHE_MODULES) { - await writeFile( - `${rootfs}/etc/apache2/mods-enabled/${mod}.load`, - moduleLoadLine(mod), - ) - } await writeFile( - `${rootfs}/etc/apache2/conf-enabled/startos-office.conf`, + `${await nextcloudSub.rootfs}/etc/apache2/conf-enabled/startos-office.conf`, officeSuite && officeBackend ? renderOfficeProxyConf(officeSuite, officeBackend) : '', @@ -685,7 +634,6 @@ export const main = sdk.setupMain(async ({ effects }) => { suite: officeSuite, backend: officeBackend, ownBridge, - secret: officeSecret, configured: officeConfigured, }) return null @@ -701,6 +649,18 @@ export const main = sdk.setupMain(async ({ effects }) => { ? { ready: { display: i18n('Office Connector'), + // Each poll boots PHP to read the app list, so a settled + // install is checked less often than the SDK's 30s default. + // The other intervals match that default, because the trigger + // waits out the interval for the status it is already in + // before polling again: raising them delays the first result + // and every recovery, and the ceiling here is how long a + // connector someone just switched off keeps reading as ready. + trigger: sdk.trigger.statusTrigger(120_000, { + starting: 1_000, + waiting: 1_000, + failure: 15_000, + }), fn: async () => { const enabled = await readEnabledApps(nextcloudSub).catch( () => null, @@ -713,21 +673,24 @@ export const main = sdk.setupMain(async ({ effects }) => { const { connectorApp: keep, title } = officeSuiteMeta[officeSuite] // Selected a document server but Nextcloud has nothing to - // reach it with. Silent otherwise: the reconcile just waits. + // reach it with — the connector was removed or switched off + // after the selection, which the reconcile leaves alone. if (!(keep in enabled)) { // Present but disabled is a different instruction from // absent, and telling someone to install what they already // have is how a message stops being followed. - const present = await hasNextcloudApp(keep) + const app = CONNECTOR_APP_TITLES[keep] return { result: 'failure' as const, - message: (present ? i18n('Enable ') : i18n('Install ')) - .concat(CONNECTOR_APP_TITLES[keep] ?? keep) - .concat( - i18n( - ' in Nextcloud, or select “None” using the “Office Suite” action.', + message: (await hasNextcloudApp(keep)) + ? i18n( + 'Enable ${app} in Nextcloud, or select “None” using the “Office Suite” action.', + { app }, + ) + : i18n( + 'Install ${app} in Nextcloud, or select “None” using the “Office Suite” action.', + { app }, ), - ), } } const rivals = OFFICE_CONNECTOR_APPS.filter( @@ -736,21 +699,18 @@ export const main = sdk.setupMain(async ({ effects }) => { if (rivals.length === 0) return { result: 'success' as const, - message: title.concat(i18n(' is ready')), + message: i18n('${app} is ready', { app: title }), } return { result: 'failure' as const, - message: i18n('Disable ') - .concat( - rivals - .map((a) => CONNECTOR_APP_TITLES[a] ?? a) + message: i18n( + 'Disable ${app} on Nextcloud’s Apps page. With two office apps enabled, Word, Excel and PowerPoint files open in neither.', + { + app: rivals + .map((a) => CONNECTOR_APP_TITLES[a]) .join(', '), - ) - .concat( - i18n( - ' on Nextcloud’s Apps page. With two office apps enabled, Word, Excel and PowerPoint files open in neither.', - ), - ), + }, + ), } }, }, @@ -1315,7 +1275,6 @@ async function reconcileExternalStorage( } } - /** * Point the chosen office connector at its document server, or clear what we * previously pointed it at. @@ -1326,10 +1285,12 @@ async function reconcileExternalStorage( * certificates out of the path entirely. The browser-facing one is relative, so * the editor follows whichever address the user already reached Nextcloud on. * - * The connector app is installed by the user from the Nextcloud app store, and - * `occ` resolves an app's settings only while it is enabled. A missing app is - * left as a retry rather than an error: the signature is not written, so the - * next chain build applies the config once the app is back. + * Nothing here runs until the chosen document server reports healthy, so every + * step below is one we expect to succeed rather than one we retry until it + * does. Past that gate it acts only on a change of selection, since the + * signature check short-circuits every other start — which is what makes it + * safe to install and switch on the connector for the suite the user just + * chose, and to switch off the one being left behind. */ async function reconcileOffice( subc: Awaited>, @@ -1339,11 +1300,45 @@ async function reconcileOffice( suite: OfficeSuite | null backend: string | null ownBridge: string | null - secret: string | null configured: string }, ): Promise { - const { suite, backend, ownBridge, secret, configured } = desired + const { suite, backend, ownBridge, configured } = desired + + // Wait for the document server before touching Nextcloud at all. Its own + // health check fetches the endpoint this reconcile depends on, so `success` + // is precisely "the work below can succeed". Waiting is a watch on its + // status: it runs no commands, cannot fail, and resolves the moment the + // service becomes ready — seconds later on an ordinary start, or whenever + // the user gets around to installing it. `waitFor` rejects only when the + // context is torn down, which is a chain rebuild, not an error. + if (suite) { + const { packageId, healthCheckId, title } = officeSuiteMeta[suite] + try { + await sdk + .getStatus(effects, { packageId }) + .waitFor((s) => s?.health[healthCheckId]?.result === 'success') + } catch { + return + } + if (abort.aborted) return + console.info(`office-suite: ${title} is ready`) + } + + // ONLYOFFICE signs every request between the two services. Read after the + // gate: the secret is written during its install, so a healthy service has + // one, where a snapshot taken at chain build can predate it. + const secret = + suite === 'onlyoffice' + ? await readDependencySecret(effects, { + dependencyId: officeSuiteMeta.onlyoffice.packageId, + volumeId: 'startos', + subpath: 'shared', + mountpoint: officeMountpoint, + path: officeSecretPath, + }) + : null + const desiredSig = JSON.stringify({ v: 1, suite, @@ -1368,14 +1363,18 @@ async function reconcileOffice( const occ = (args: string[]) => subc.exec(['php', 'occ', ...args], { user: 'www-data' }) - // Clear the connector we are moving away from, so switching backends does not - // leave the old one still pointed at a service that may be uninstalled next. + // Clear the connector we are moving away from and switch it off. Its settings + // would otherwise point at a service that may be uninstalled next, and either + // connector left enabled demotes the Microsoft formats out of the other one's + // default-open list, so both suites end up opening neither. if (previous.suite && previous.suite !== suite) { - const app = officeSuiteMeta[previous.suite].connectorApp - for (const key of OFFICE_KEYS[previous.suite]) { + const { connectorApp, settingKeys } = officeSuiteMeta[previous.suite] + for (const key of settingKeys) { if (abort.aborted) return - await occ(['config:app:delete', app, key]) + await occ(['config:app:delete', connectorApp, key]) } + if (abort.aborted) return + await occ(['app:disable', connectorApp]) } if (!suite) { @@ -1396,28 +1395,20 @@ async function reconcileOffice( }) const app = officeSuiteMeta[suite].connectorApp if (!(app in enabled)) { - // Install it, but only when it is absent entirely, and only here — this - // runs on a change of selection, never on an ordinary start, so an app the - // user later removes stays removed and the health check reports it. - // - // A present-but-disabled app is left alone on purpose. That state is either - // the user's decision or a major Nextcloud upgrade disabling an app with no - // compatible release, and re-enabling it is exactly how that protection - // gets undone — the failure `Disable Non-default Apps` exists to recover. - if (await hasNextcloudApp(app)) { - console.warn( - `office-suite: the ${app} app is present but disabled; not re-enabling it`, - ) - return - } - const res = await occ(['app:install', app]) + // Both commands refuse an app with no release compatible with this + // Nextcloud, which is the state a major upgrade leaves behind — so the + // protection `Disable Non-default Apps` exists to recover holds here + // without a check of our own. + const res = (await hasNextcloudApp(app)) + ? await occ(['app:enable', app]) + : await occ(['app:install', app]) if (res.exitCode !== 0) { console.warn( - `office-suite: could not install ${app}: ${res.stdout.toString()} ${res.stderr.toString()}`, + `office-suite: could not enable ${app}: ${res.stdout.toString()} ${res.stderr.toString()}`, ) return } - console.info(`office-suite: installed the ${app} app`) + console.info(`office-suite: enabled the ${app} app`) } // Collabora's two URLs are set through `activate-config` rather than @@ -1425,6 +1416,11 @@ async function reconcileOffice( // it is run without `--callback-url` — and it has to be run, since it is what // refreshes the cached discovery document. if (suite === 'collabora') { + // That refresh is a live fetch of `/hosting/discovery` back through this + // container's own proxy, which is why the readiness gate above has to have + // passed: a fetch that fails still leaves richdocuments holding a WOPI url + // with no discovery behind it, and every document then opens to a spinner + // that never resolves. const res = await occ([ 'richdocuments:activate-config', // Our own Apache, in-container: the discovery fetch has to pass through @@ -1432,8 +1428,9 @@ async function reconcileOffice( '--wopi-url=http://127.0.0.1', ...(ownBridge ? [`--callback-url=http://${ownBridge}`] : []), ]) + if (abort.aborted) return if (res.exitCode !== 0) { - console.warn( + console.error( `office-suite: richdocuments:activate-config failed: ${res.stdout.toString()} ${res.stderr.toString()}`, ) return @@ -1442,23 +1439,32 @@ async function reconcileOffice( return } - const settings: Record = - suite === 'onlyoffice' - ? { - // Relative, so the browser resolves it against the address it is on. - DocumentServerUrl: `${DS_VPATH}/`, - DocumentServerInternalUrl: `http://${backend}/`, - StorageUrl: ownBridge ? `http://${ownBridge}/` : '', - jwt_secret: secret ?? '', - jwt_header: OO_JWT_HEADER, - } - : {} + // Pairing on an empty secret authenticates nothing, so record nothing and let + // the next chain build try again. + if (!secret) { + console.error( + `office-suite: ${officeSuiteMeta.onlyoffice.packageId} is ready but has published no JWT secret`, + ) + return + } + + const settings: Record< + (typeof officeSuiteMeta)['onlyoffice']['settingKeys'][number], + string + > = { + // Relative, so the browser resolves it against the address it is on. + DocumentServerUrl: `${DS_VPATH}/`, + DocumentServerInternalUrl: `http://${backend}/`, + StorageUrl: ownBridge ? `http://${ownBridge}/` : '', + jwt_secret: secret, + jwt_header: OO_JWT_HEADER, + } for (const [key, value] of Object.entries(settings)) { if (abort.aborted) return const res = await occ(['config:app:set', app, key, `--value=${value}`]) if (res.exitCode !== 0) { - console.warn( + console.error( `office-suite: could not set ${key}: ${res.stdout.toString()} ${res.stderr.toString()}`, ) return @@ -1467,14 +1473,3 @@ async function reconcileOffice( await storeJson.merge(effects, { officeConfigured: desiredSig }) } - -const OFFICE_KEYS: Record = { - collabora: ['wopi_url', 'wopi_callback_url', 'public_wopi_url'], - onlyoffice: [ - 'DocumentServerUrl', - 'DocumentServerInternalUrl', - 'StorageUrl', - 'jwt_secret', - 'jwt_header', - ], -} diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts index 15126bd..cf71bb2 100644 --- a/startos/manifest/i18n.ts +++ b/startos/manifest/i18n.ts @@ -32,6 +32,32 @@ export const coturnDescription = { 'Fournit un relais TURN/STUN pour que les appels Talk aboutissent à travers le NAT et les pare-feu restrictifs', } +export const collaboraDescription = { + en_US: + 'Opens and edits documents, spreadsheets and presentations in the browser, from Nextcloud Files', + es_ES: + 'Abre y edita documentos, hojas de cálculo y presentaciones en el navegador, desde Archivos de Nextcloud', + de_DE: + 'Öffnet und bearbeitet Dokumente, Tabellen und Präsentationen im Browser, direkt aus Nextcloud Dateien', + pl_PL: + 'Otwiera i edytuje dokumenty, arkusze i prezentacje w przeglądarce, prosto z Plików Nextcloud', + fr_FR: + 'Ouvre et modifie documents, feuilles de calcul et présentations dans le navigateur, depuis Fichiers de Nextcloud', +} + +export const onlyofficeDescription = { + en_US: + 'Opens and edits documents, spreadsheets and presentations in the browser, from Nextcloud Files. The heavier alternative to Collabora Online', + es_ES: + 'Abre y edita documentos, hojas de cálculo y presentaciones en el navegador, desde Archivos de Nextcloud. La alternativa más pesada a Collabora Online', + de_DE: + 'Öffnet und bearbeitet Dokumente, Tabellen und Präsentationen im Browser, direkt aus Nextcloud Dateien. Die schwerere Alternative zu Collabora Online', + pl_PL: + 'Otwiera i edytuje dokumenty, arkusze i prezentacje w przeglądarce, prosto z Plików Nextcloud. Cięższa alternatywa dla Collabora Online', + fr_FR: + 'Ouvre et modifie documents, feuilles de calcul et présentations dans le navigateur, depuis Fichiers de Nextcloud. Lalternative plus lourde à Collabora Online', +} + export const long = { en_US: 'Access & share your files, calendars, contacts, mail & more from any device, on your terms.', diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index 38bbe93..6fde801 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -1,5 +1,12 @@ import { setupManifest } from '@start9labs/start-sdk' -import { coturnDescription, filebrowserDescription, long, short } from './i18n' +import { + collaboraDescription, + coturnDescription, + filebrowserDescription, + long, + onlyofficeDescription, + short, +} from './i18n' export const manifest = setupManifest({ id: 'nextcloud', @@ -50,5 +57,21 @@ export const manifest = setupManifest({ icon: 'https://raw.githubusercontent.com/Start9Labs/coturn-startos/d67ecaca5800a87e3300ce44c62484888f35d51b/icon.svg', }, }, + 'collabora-online': { + description: collaboraDescription, + optional: true, + metadata: { + title: 'Collabora Online', + icon: 'https://raw.githubusercontent.com/Start9Labs/collabora-online-startos/f03b9c67c185bf63d55b5e6f28e6e85a46c65fcb/icon.png', + }, + }, + 'onlyoffice-docs': { + description: onlyofficeDescription, + optional: true, + metadata: { + title: 'ONLYOFFICE Docs', + icon: 'https://raw.githubusercontent.com/Start9-Community/onlyoffice-docs-startos/fa723dfd81ec7aae30d1b29520daff139427b3e4/icon.png', + }, + }, }, }) diff --git a/startos/officeSuite.ts b/startos/officeSuite.ts index 972af5d..699d531 100644 --- a/startos/officeSuite.ts +++ b/startos/officeSuite.ts @@ -10,6 +10,10 @@ export const DS_VPATH = '/ds-vpath' export const isOfficeSuite = (v: unknown): v is OfficeSuite => typeof v === 'string' && (OFFICE_SUITES as readonly string[]).includes(v) +// Collabora's WOPI discovery document, proxied below and fetched back through +// that proxy by `richdocuments:activate-config`. +export const COLLABORA_DISCOVERY = '/hosting/discovery' + // Every Nextcloud app that handles office documents. richdocuments demotes the // Microsoft formats out of its default-open list whenever it finds one of the // others enabled (`CapabilitiesService::hasOtherOOXMLApps`), so a second one @@ -19,9 +23,10 @@ export const OFFICE_CONNECTOR_APPS = [ 'onlyoffice', 'officeonline', ] as const +export type OfficeConnectorApp = (typeof OFFICE_CONNECTOR_APPS)[number] /** Exactly what each connector is called in Nextcloud's own Apps list. */ -export const CONNECTOR_APP_TITLES: Record = { +export const CONNECTOR_APP_TITLES: Record = { richdocuments: 'Nextcloud Office (Collabora)', onlyoffice: 'ONLYOFFICE', officeonline: 'Office Online', @@ -44,6 +49,9 @@ export const officeSuiteMeta = { healthCheckId: 'cool', title: 'Collabora Online', connectorApp: 'richdocuments', + // What `richdocuments:activate-config` writes, and so what has to be + // cleared when the selection moves off this backend. + settingKeys: ['wopi_url', 'wopi_callback_url', 'public_wopi_url'], }, onlyoffice: { packageId: 'onlyoffice-docs', @@ -53,6 +61,13 @@ export const officeSuiteMeta = { healthCheckId: 'documentserver', title: 'ONLYOFFICE Docs', connectorApp: 'onlyoffice', + settingKeys: [ + 'DocumentServerUrl', + 'DocumentServerInternalUrl', + 'StorageUrl', + 'jwt_secret', + 'jwt_header', + ], }, } as const satisfies Record< OfficeSuite, @@ -63,7 +78,8 @@ export const officeSuiteMeta = { internalPort: number healthCheckId: string title: string - connectorApp: string + connectorApp: OfficeConnectorApp + settingKeys: readonly string[] } > @@ -85,8 +101,8 @@ ProxyPreserveHost On ProxyPass /browser http://${backend}/browser retry=0 ProxyPassReverse /browser http://${backend}/browser -ProxyPass /hosting/discovery http://${backend}/hosting/discovery retry=0 -ProxyPassReverse /hosting/discovery http://${backend}/hosting/discovery +ProxyPass ${COLLABORA_DISCOVERY} http://${backend}${COLLABORA_DISCOVERY} retry=0 +ProxyPassReverse ${COLLABORA_DISCOVERY} http://${backend}${COLLABORA_DISCOVERY} ProxyPass /hosting/capabilities http://${backend}/hosting/capabilities retry=0 ProxyPassReverse /hosting/capabilities http://${backend}/hosting/capabilities @@ -103,7 +119,7 @@ ProxyPassReverse /cool http://${backend}/cool # Nextcloud copies that URL into the editor frame verbatim. Stripping the origin # leaves a relative URL, which the browser resolves against whichever address it # is already on. - + SetOutputFilter SUBSTITUTE Substitute "s#(urlsrc|favIconUrl)=\\"https?://[^/]+/#$1=\\"/#i" @@ -122,13 +138,3 @@ ProxyPass ${DS_VPATH}/ http://${backend}/ upgrade=websocket nocanon ProxyPassReverse ${DS_VPATH}/ http://${backend}/ ` } - -export const APACHE_MODULES = [ - 'proxy', - 'proxy_http', - 'proxy_wstunnel', - 'substitute', -] as const - -export const moduleLoadLine = (mod: string) => - `LoadModule ${mod}_module /usr/lib/apache2/modules/mod_${mod}.so\n` diff --git a/startos/utils.ts b/startos/utils.ts index 74df86f..423942e 100644 --- a/startos/utils.ts +++ b/startos/utils.ts @@ -90,6 +90,44 @@ export const coturnInterfaceId = 'turn' export const coturnMountpoint = '/mnt/coturn' export const coturnSecretPath = `${coturnMountpoint}/turn-secret` +/** + * Read a secret another package publishes on one of its volumes, through a + * throwaway container that mounts only that subpath read-only — so a missing or + * stopped dependency can never take Nextcloud's own daemons down, and nothing + * else on that volume comes into view. Null if it cannot be read. + */ +export async function readDependencySecret( + effects: T.Effects, + opts: { + dependencyId: string + volumeId: string + subpath: string + mountpoint: string + path: string + }, +): Promise { + const reader = sdk.SubContainer.of( + effects, + { imageId: 'valkey' }, + sdk.Mounts.of().mountDependency({ + dependencyId: opts.dependencyId, + volumeId: opts.volumeId, + subpath: opts.subpath, + mountpoint: opts.mountpoint, + readonly: true, + }), + `${opts.dependencyId}-secret-read`, + ) + try { + const { stdout } = await reader.execFail(['cat', opts.path]) + return stdout.toString().trim() || null + } catch { + return null + } finally { + await reader.destroy().catch(() => {}) + } +} + export const nextcloudMount = sdk.Mounts.of().mountVolume({ volumeId: 'nextcloud', mountpoint: NEXTCLOUD_PATH, diff --git a/startos/versions/current.ts b/startos/versions/current.ts index d8a32bf..4a486e3 100644 --- a/startos/versions/current.ts +++ b/startos/versions/current.ts @@ -1,13 +1,13 @@ import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' export const current = VersionInfo.of({ - version: '34.0.3:3', + version: '34.0.3:4', releaseNotes: { en_US: `Updated Nextcloud to 34.0.3 — a major upgrade from the Nextcloud 33 series. **New in this package** -- **Office editing.** A new **Office Suite** action connects Nextcloud to Collabora Online or ONLYOFFICE Docs, both in the marketplace, so you can open and edit documents, spreadsheets and presentations in your browser and work on them with other people at the same time. Install the service, then its app from the Nextcloud app store — Nextcloud Office for Collabora, ONLYOFFICE for ONLYOFFICE Docs — and pick it in the Office Suite action. Collabora Online is recommended for almost everyone; it is the lighter of the two by far. The editor is served from your own Nextcloud address, so it works on your local network, a public domain and Tor alike. +- **Office editing.** A new **Office Suite** action connects Nextcloud to Collabora Online or ONLYOFFICE Docs, both in the marketplace, so you can open and edit documents, spreadsheets and presentations in your browser and work on them with other people at the same time. Install the service and pick it in the Office Suite action; Nextcloud installs the app it needs and points itself at it. Collabora Online is recommended for almost everyone; it is the lighter of the two by far. The editor is served from your own Nextcloud address, so it works on your local network, a public domain and Tor alike. - **Delete Files in Trash**, a new setting in the Configure action. Nextcloud keeps a deleted file for at least 30 days and then clears it only when disk space runs short, so on a server with room to spare the trash grows without bound. You can now cap it at 7 to 365 days, or keep everything. @@ -40,7 +40,7 @@ Full changelog: https://github.com/nextcloud-releases/server/releases/tag/v34.0. **Novedades de este paquete** -- **Edición ofimática.** Una nueva acción **Suite ofimática** conecta Nextcloud con Collabora Online o con ONLYOFFICE Docs, ambos en el mercado, para que pueda abrir y editar documentos, hojas de cálculo y presentaciones en su navegador y trabajar en ellos con otras personas a la vez. Instale el servicio, después su aplicación desde la tienda de aplicaciones de Nextcloud —Nextcloud Office para Collabora, ONLYOFFICE para ONLYOFFICE Docs— y elíjalo en la acción Suite ofimática. Se recomienda Collabora Online para casi todo el mundo; es con diferencia la más ligera de las dos. El editor se sirve desde su propia dirección de Nextcloud, así que funciona igual en su red local, en un dominio público y en Tor. +- **Edición ofimática.** Una nueva acción **Suite ofimática** conecta Nextcloud con Collabora Online o con ONLYOFFICE Docs, ambos en el mercado, para que pueda abrir y editar documentos, hojas de cálculo y presentaciones en su navegador y trabajar en ellos con otras personas a la vez. Instale el servicio y elíjalo en la acción Suite ofimática; Nextcloud instala la aplicación que necesita y se apunta a ella. Se recomienda Collabora Online para casi todo el mundo; es con diferencia la más ligera de las dos. El editor se sirve desde su propia dirección de Nextcloud, así que funciona igual en su red local, en un dominio público y en Tor. - **Eliminar los archivos de la papelera**, un nuevo ajuste en la acción Configuración. Nextcloud conserva un archivo borrado al menos 30 días y solo lo suprime cuando escasea el espacio en disco, de modo que en un servidor con espacio de sobra la papelera crece sin límite. Ahora puede limitarla de 7 a 365 días, o conservarlo todo. @@ -73,7 +73,7 @@ Registro de cambios completo: https://github.com/nextcloud-releases/server/relea **Neu in diesem Paket** -- **Dokumentbearbeitung.** Eine neue Aktion **Office-Suite** verbindet Nextcloud mit Collabora Online oder ONLYOFFICE Docs, beide im Marktplatz. Damit öffnen und bearbeiten Sie Dokumente, Tabellen und Präsentationen im Browser und arbeiten gemeinsam mit anderen daran. Installieren Sie den Dienst, danach seine App aus dem Nextcloud App Store — Nextcloud Office für Collabora, ONLYOFFICE für ONLYOFFICE Docs — und wählen Sie ihn in der Aktion „Office-Suite“. Für fast alle ist Collabora Online die Empfehlung; es ist die mit Abstand leichtere der beiden. Der Editor wird über Ihre eigene Nextcloud-Adresse ausgeliefert und funktioniert dadurch im Heimnetz, über eine öffentliche Domain und über Tor gleichermaßen. +- **Dokumentbearbeitung.** Eine neue Aktion **Office-Suite** verbindet Nextcloud mit Collabora Online oder ONLYOFFICE Docs, beide im Marktplatz. Damit öffnen und bearbeiten Sie Dokumente, Tabellen und Präsentationen im Browser und arbeiten gemeinsam mit anderen daran. Installieren Sie den Dienst und wählen Sie ihn in der Aktion „Office-Suite“; Nextcloud installiert die benötigte App und richtet sich selbst darauf aus. Für fast alle ist Collabora Online die Empfehlung; es ist die mit Abstand leichtere der beiden. Der Editor wird über Ihre eigene Nextcloud-Adresse ausgeliefert und funktioniert dadurch im Heimnetz, über eine öffentliche Domain und über Tor gleichermaßen. - **Dateien im Papierkorb löschen**, eine neue Einstellung in der Aktion Konfiguration. Nextcloud bewahrt eine gelöschte Datei mindestens 30 Tage auf und entfernt sie erst, wenn der Speicherplatz knapp wird — auf einem Server mit reichlich Platz wächst der Papierkorb also unbegrenzt. Sie können ihn jetzt auf 7 bis 365 Tage begrenzen oder alles behalten. @@ -106,7 +106,7 @@ Vollständiges Änderungsprotokoll: https://github.com/nextcloud-releases/server **Nowości w tym pakiecie** -- **Edycja dokumentów.** Nowa akcja **Pakiet biurowy** łączy Nextcloud z Collabora Online albo z ONLYOFFICE Docs — oba są dostępne na rynku. Możesz otwierać i edytować dokumenty, arkusze i prezentacje w przeglądarce oraz pracować nad nimi razem z innymi osobami. Zainstaluj usługę, następnie jej aplikację ze sklepu Nextcloud — Nextcloud Office dla Collabory, ONLYOFFICE dla ONLYOFFICE Docs — i wskaż ją w akcji Pakiet biurowy. Dla niemal wszystkich zalecana jest Collabora Online; jest zdecydowanie lżejsza z tych dwóch. Edytor jest udostępniany spod Twojego własnego adresu Nextcloud, więc działa tak samo w sieci lokalnej, pod domeną publiczną i przez Tor. +- **Edycja dokumentów.** Nowa akcja **Pakiet biurowy** łączy Nextcloud z Collabora Online albo z ONLYOFFICE Docs — oba są dostępne na rynku. Możesz otwierać i edytować dokumenty, arkusze i prezentacje w przeglądarce oraz pracować nad nimi razem z innymi osobami. Zainstaluj usługę i wskaż ją w akcji Pakiet biurowy; Nextcloud zainstaluje potrzebną aplikację i sam się na nią skieruje. Dla niemal wszystkich zalecana jest Collabora Online; jest zdecydowanie lżejsza z tych dwóch. Edytor jest udostępniany spod Twojego własnego adresu Nextcloud, więc działa tak samo w sieci lokalnej, pod domeną publiczną i przez Tor. - **Usuwanie plików z kosza**, nowe ustawienie w akcji Konfiguracja. Nextcloud przechowuje usunięty plik co najmniej 30 dni i kasuje go dopiero wtedy, gdy zaczyna brakować miejsca na dysku, więc na serwerze z zapasem miejsca kosz rośnie bez ograniczeń. Teraz możesz ograniczyć go do 7–365 dni albo zachować wszystko. @@ -139,7 +139,7 @@ Pełny dziennik zmian: https://github.com/nextcloud-releases/server/releases/tag **Nouveautés de ce paquet** -- **Édition bureautique.** Une nouvelle action **Suite bureautique** relie Nextcloud à Collabora Online ou à ONLYOFFICE Docs, tous deux dans la place de marché, pour ouvrir et modifier documents, feuilles de calcul et présentations dans votre navigateur, et y travailler à plusieurs simultanément. Installez le service, puis son application depuis la boutique Nextcloud — Nextcloud Office pour Collabora, ONLYOFFICE pour ONLYOFFICE Docs — et sélectionnez-le dans l'action Suite bureautique. Collabora Online est recommandé pour presque tout le monde ; c'est de loin le plus léger des deux. L'éditeur est servi depuis votre propre adresse Nextcloud : il fonctionne donc aussi bien sur votre réseau local, sur un domaine public et via Tor. +- **Édition bureautique.** Une nouvelle action **Suite bureautique** relie Nextcloud à Collabora Online ou à ONLYOFFICE Docs, tous deux dans la place de marché, pour ouvrir et modifier documents, feuilles de calcul et présentations dans votre navigateur, et y travailler à plusieurs simultanément. Installez le service et sélectionnez-le dans l'action Suite bureautique ; Nextcloud installe l'application nécessaire et se pointe dessus. Collabora Online est recommandé pour presque tout le monde ; c'est de loin le plus léger des deux. L'éditeur est servi depuis votre propre adresse Nextcloud : il fonctionne donc aussi bien sur votre réseau local, sur un domaine public et via Tor. - **Suppression des fichiers de la corbeille**, un nouveau réglage dans l'action Configuration. Nextcloud conserve un fichier supprimé au moins 30 jours et ne l'efface que lorsque l'espace disque vient à manquer : sur un serveur disposant de place, la corbeille grossit donc sans limite. Vous pouvez désormais la plafonner de 7 à 365 jours, ou tout conserver. From b0aa046b7bd1317f52d07d3a101c62e4122a250d Mon Sep 17 00:00:00 2001 From: Matt Hill <9935159+MattDHill@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:50:30 -0600 Subject: [PATCH 2/2] fix: have the office connector check report the setup's progress and failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the office-suite oneshot has applied a selection, the check reported on Nextcloud's app list alone: red with "Install X in Nextcloud" while the document server was still coming up, and green on an enabled app even when the setup behind it had failed. It now follows the oneshot's progress — waiting for the document server, setting up the connector, failed, applied — and only reads the app list once the selection is applied. A step that fails after the readiness gate posts an error notification with the command's output, and the check turns red until the next start retries. Also: the success message names the connector app, since that is what the check verifies; the trigger drops the dead `waiting` interval and polls the cheap loading states every five seconds; the "ready" log fires only when there is work to do; the ONLYOFFICE settings type gets a name; a missing apostrophe in the French dependency description; and the comments and AGENTS.md bullet added in this branch are cut to the fact each carries. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- README.md | 6 +- instructions.md | 2 + startos/i18n/dictionaries/default.ts | 8 +- startos/i18n/dictionaries/translations.ts | 24 ++- startos/main.ts | 203 ++++++++++++---------- startos/manifest/i18n.ts | 2 +- startos/officeSuite.ts | 11 +- startos/utils.ts | 5 +- 9 files changed, 153 insertions(+), 110 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9f5bb73..27461f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,4 +38,4 @@ verified, tried, and decided belongs in the commit message and the PR body. - **The File Browser mount's `idmap` (uid 1000 → `www-data` 33) is what makes the integration work at all**, and it needs StartOS 0.4.0-beta.10+. Files other services drop into File Browser's volume under a different uid surface as `nobody` until those services idmap their own mount to 1000 too. - **Adding an external-storage source is a registry edit in `startos/externalStorage.ts` plus a typed mount.** File Browser is the shared hub most services route through, so a direct source is worth adding only for a service whose files live browsably on its own volume. - **`richdocuments` drops the Microsoft formats the moment a second office connector is enabled** (`CapabilitiesService::hasOtherOOXMLApps` checks `onlyoffice` and `officeonline`), and the second app does not claim them unless it is configured — so Word, Excel and PowerPoint open in neither and silently download. That is what the `office-connectors` health check exists to catch; don't simplify it away because the reconcile disables the outgoing connector on a switch, which does not stop a user enabling one by hand. -- **`richdocuments:activate-config` fetches the discovery document as it runs, so it must not be called before Collabora answers.** A fetch that fails still leaves the app holding a WOPI url with no discovery, and every document then opens to a spinner that never resolves — with nothing in Nextcloud's log naming the cause. The `office-suite` oneshot therefore waits on `sdk.getStatus(effects, { packageId }).waitFor(...)` for the document server's own health check before it touches anything. **Do not turn that into a retry.** A oneshot whose fn rejects is re-invoked by the SDK on a widening backoff forever, so a suite selected against a service that is stopped or was never installed would run `occ` every 30 seconds for the life of the chain. The wait costs nothing, runs no commands, cannot fail, and resolves the instant the service is ready; a bridge address that resolves is not that signal, because the port is bound well before `coolwsd` accepts a connection. +- **`richdocuments:activate-config` fetches the discovery document as it runs, so the `office-suite` oneshot waits on the document server's own health check before it touches anything** — a bridge address resolves well before `coolwsd` accepts a connection. Do not turn that wait into a retry: a oneshot whose fn rejects is re-invoked on a widening backoff forever, so a suite selected against a stopped or uninstalled service would run `occ` for the life of the chain. diff --git a/README.md b/README.md index 580adc1..55eb252 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,8 @@ Selects the document server that opens office files — Collabora Online, ONLYOF The wait is a subscription, not a poll. It runs no commands, cannot fail, and releases the moment the service becomes ready — a few tens of seconds into an ordinary start, or whenever the user installs the service if they selected it first. A bridge address is not a usable readiness signal here: the port is bound, and the address therefore resolves, well before `coolwsd` accepts its first connection. +**A step that fails after that gate is reported, not retried.** The oneshot posts an error notification carrying the command's output, and the Office Connector check turns red until the next start, which runs the reconcile again. Re-running the action with the same choice does not — the chain rebuilds only when the selection changes. + **The `trusted_domains` entry is load-bearing.** A document server fetches and saves files over the host bridge, and without that entry Nextcloud answers every one of those requests with `Trusted domain error` — the editor opens and then fails to load the document. Nextcloud matches on the host alone, so the bare IP covers whatever port the binding was assigned. ### Maintenance — Reset Admin Password, Disable Maintenance Mode, Disable Non-default Apps, Scan Files, Repair @@ -247,9 +249,9 @@ A web-interface failure after the grace period is Nextcloud itself: an app that The transient checks — Recognize Model Download, Memories Indexing, Memories Map Setup, File Scan, Repair — exist only while their task is pending, and report `loading` with a progress message throughout. -**Office Connector** (`office-connectors`) — present only while an office suite is selected. It reads Nextcloud's enabled-app list and fails in two distinct cases, each with its own instruction. Each poll boots PHP to read that list, so it runs every two minutes while passing and every fifteen seconds while failing. That ceiling is also how long a connector someone has just switched off keeps reading as ready. +**Office Connector** (`office-connectors`) — present only while an office suite is selected. Until the `office-suite` oneshot has applied the selection it reports `loading`: *Waiting for Collabora Online to be ready* while the document server is not yet healthy, then *Setting up Nextcloud Office (Collabora)* while the connector is installed and configured. A setup that fails turns it red until the next start, which retries it; the failure's notification carries the command output. Once applied, it reads Nextcloud's enabled-app list and fails in two distinct cases, each with its own instruction. Reading that list boots PHP, so a passing check polls every two minutes and a failing one every fifteen seconds; the loading states, which read nothing from Nextcloud, poll every five. The two-minute ceiling is also how long a connector someone has just switched off keeps reading as enabled. -**The selected suite's connector is not enabled.** Either it was never installed, or it has been removed or switched off since. The message says *Install* or *Enable* accordingly — telling someone to install what they already have is how a message stops being read — and names the other way out: selecting `None` in the Office Suite action. Without this the failure is silent: the reconcile simply waits, the document server runs, and nothing in Nextcloud opens in it. +**The selected suite's connector is not enabled.** It has been removed or switched off since the package set it up. The message says *Install* or *Enable* accordingly — telling someone to install what they already have is how a message stops being read — and names the other way out: selecting `None` in the Office Suite action. Without this the failure is silent: the document server runs, and nothing in Nextcloud opens in it. **More than one office connector is enabled.** Everything is running and OpenDocument files still open; what breaks is Word, Excel and PowerPoint, silently. `richdocuments` demotes those formats the moment it sees a rival connector enabled, and the rival does not claim them unless it is configured too. The message names the app to disable. diff --git a/instructions.md b/instructions.md index 7f7d97e..847627c 100644 --- a/instructions.md +++ b/instructions.md @@ -66,6 +66,8 @@ Nextcloud on its own shows your documents but cannot edit them. To edit in the b Switching between the two later is the same step: pick the other one, and Nextcloud sets up its app and switches the previous one off. Only one of them may be enabled — with both on, Nextcloud stops opening Word, Excel and PowerPoint files in either. If you ever remove or switch off the app yourself, this service's page will say so and name the one to turn back on. +If you pick a document server that is still starting, or one you have not installed yet, this service's page shows it waiting and finishes the setup once the server is ready. Should that setup fail, you get a notification saying so; restarting Nextcloud tries again. + Documents, spreadsheets and presentations then open in the browser from Files, and several people can edit the same file at once. The editor is served from your Nextcloud address, so it works the same on your local network, a public domain, or Tor. ### Which one to choose diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index 3d68a9b..e111a32 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -219,10 +219,16 @@ const dict = { // main.ts: the office-connectors health check 'Office Connector': 146, - '${app} is ready': 154, + 'Waiting for ${suite} to be ready': 159, + 'Setting up ${app}...': 160, + 'Could not set up ${app}. Restart Nextcloud to try again, or select “None” using the “Office Suite” action.': 161, + '${app} is enabled': 154, 'Install ${app} in Nextcloud, or select “None” using the “Office Suite” action.': 152, 'Enable ${app} in Nextcloud, or select “None” using the “Office Suite” action.': 157, 'Disable ${app} on Nextcloud’s Apps page. With two office apps enabled, Word, Excel and PowerPoint files open in neither.': 155, + + // main.ts: the office-suite oneshot + 'Office Suite Setup Failed': 162, } as const /** diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index 69a6a7b..d07ff4f 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -152,9 +152,13 @@ export default { 147: 'Collabora Online (recomendado)', 150: 'Esto reemplaza de inmediato la contraseña de la cuenta elegida. La actual deja de funcionar y se cierra la sesión de quien la esté usando.', 152: 'Instale ${app} en Nextcloud, o seleccione «Ninguna» con la acción «Suite ofimática».', - 154: '${app} está listo', + 154: 'La aplicación ${app} está activada', 155: 'Desactive ${app} en la página Aplicaciones de Nextcloud. Con dos aplicaciones ofimáticas activadas, los archivos de Word, Excel y PowerPoint no se abren en ninguna.', 157: 'Active ${app} en Nextcloud, o seleccione «Ninguna» con la acción «Suite ofimática».', + 159: 'Esperando a que ${suite} esté disponible', + 160: 'Configurando ${app}...', + 161: 'No se pudo configurar ${app}. Reinicie Nextcloud para volver a intentarlo, o seleccione «Ninguna» con la acción «Suite ofimática».', + 162: 'Error al configurar la suite ofimática', }, de_DE: { 0: 'Starte Nextcloud...', @@ -307,9 +311,13 @@ export default { 147: 'Collabora Online (empfohlen)', 150: 'Dies ersetzt das Passwort des gewählten Kontos sofort. Das aktuelle funktioniert dann nicht mehr, und wer als dieser Benutzer angemeldet ist, wird abgemeldet.', 152: 'Installieren Sie ${app} in Nextcloud, oder wählen Sie „Keine“ über die Aktion „Office-Suite“.', - 154: '${app} ist bereit', + 154: 'Die App ${app} ist aktiviert', 155: 'Deaktivieren Sie ${app} auf der Apps-Seite von Nextcloud. Sind zwei Office-Apps aktiviert, öffnen sich Word-, Excel- und PowerPoint-Dateien in keiner von beiden.', 157: 'Aktivieren Sie ${app} in Nextcloud, oder wählen Sie „Keine“ über die Aktion „Office-Suite“.', + 159: 'Warten, bis ${suite} bereit ist', + 160: '${app} wird eingerichtet...', + 161: '${app} konnte nicht eingerichtet werden. Starten Sie Nextcloud neu, um es erneut zu versuchen, oder wählen Sie „Keine“ über die Aktion „Office-Suite“.', + 162: 'Einrichtung der Office-Suite fehlgeschlagen', }, pl_PL: { 0: 'Uruchamianie Nextcloud...', @@ -462,9 +470,13 @@ export default { 147: 'Collabora Online (zalecana)', 150: 'To natychmiast zastąpi hasło wybranego konta. Dotychczasowe przestanie działać, a osoba zalogowana jako ten użytkownik zostanie wylogowana.', 152: 'Zainstaluj ${app} w Nextcloud albo wybierz „Brak” w akcji „Pakiet biurowy”.', - 154: '${app} jest gotowy', + 154: 'Aplikacja ${app} jest włączona', 155: 'Wyłącz ${app} na stronie Aplikacje w Nextcloud. Przy dwóch włączonych aplikacjach biurowych pliki Word, Excel i PowerPoint nie otworzą się w żadnej z nich.', 157: 'Włącz ${app} w Nextcloud albo wybierz „Brak” w akcji „Pakiet biurowy”.', + 159: 'Oczekiwanie na gotowość ${suite}', + 160: 'Konfigurowanie ${app}...', + 161: 'Nie udało się skonfigurować ${app}. Uruchom Nextcloud ponownie, aby spróbować jeszcze raz, albo wybierz „Brak” w akcji „Pakiet biurowy”.', + 162: 'Konfiguracja pakietu biurowego nie powiodła się', }, fr_FR: { 0: 'Démarrage de Nextcloud...', @@ -617,8 +629,12 @@ export default { 147: 'Collabora Online (recommandé)', 150: "Cela remplace immédiatement le mot de passe du compte choisi. L'actuel cesse de fonctionner et toute personne connectée sous cet utilisateur est déconnectée.", 152: "Installez ${app} dans Nextcloud, ou sélectionnez « Aucune » avec l'action « Suite bureautique ».", - 154: '${app} est prêt', + 154: "L'application ${app} est activée", 155: "Désactivez ${app} sur la page Applications de Nextcloud. Avec deux applications bureautiques activées, les fichiers Word, Excel et PowerPoint ne s'ouvrent dans aucune.", 157: "Activez ${app} dans Nextcloud, ou sélectionnez « Aucune » avec l'action « Suite bureautique ».", + 159: 'En attente que ${suite} soit disponible', + 160: 'Configuration de ${app}...', + 161: "Impossible de configurer ${app}. Redémarrez Nextcloud pour réessayer, ou sélectionnez « Aucune » avec l'action « Suite bureautique ».", + 162: 'Échec de la configuration de la suite bureautique', }, } satisfies Record diff --git a/startos/main.ts b/startos/main.ts index e21cc4d..d149812 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -24,7 +24,9 @@ import { OFFICE_CONNECTOR_APPS, officeSecretPath, officeSuiteMeta, + OfficeSetupState, OfficeSuite, + OnlyofficeSettingKey, OO_JWT_HEADER, renderOfficeProxyConf, } from './officeSuite' @@ -335,6 +337,8 @@ export const main = sdk.setupMain(async ({ effects }) => { const officeConfigured = (await storeJson.read().once())?.officeConfigured ?? '' + // How far the office-suite oneshot has got, for its health check to report. + let officeSetup: OfficeSetupState = 'waiting' // Nextcloud's own bridge address, which is what the document server is told // to fetch and save documents through: plaintext, so neither side needs to @@ -630,12 +634,20 @@ export const main = sdk.setupMain(async ({ effects }) => { subcontainer: nextcloudSub, exec: { fn: async (subc, abort) => { - await reconcileOffice(subc, abort, effects, { - suite: officeSuite, - backend: officeBackend, - ownBridge, - configured: officeConfigured, - }) + await reconcileOffice( + subc, + abort, + effects, + { + suite: officeSuite, + backend: officeBackend, + ownBridge, + configured: officeConfigured, + }, + (state) => { + officeSetup = state + }, + ) return null }, }, @@ -649,19 +661,36 @@ export const main = sdk.setupMain(async ({ effects }) => { ? { ready: { display: i18n('Office Connector'), - // Each poll boots PHP to read the app list, so a settled - // install is checked less often than the SDK's 30s default. - // The other intervals match that default, because the trigger - // waits out the interval for the status it is already in - // before polling again: raising them delays the first result - // and every recovery, and the ceiling here is how long a - // connector someone just switched off keeps reading as ready. + // Reading the app list boots PHP. trigger: sdk.trigger.statusTrigger(120_000, { starting: 1_000, - waiting: 1_000, + loading: 5_000, failure: 15_000, }), fn: async () => { + const { connectorApp: keep, title } = + officeSuiteMeta[officeSuite] + const app = CONNECTOR_APP_TITLES[keep] + if (officeSetup === 'waiting') + return { + result: 'loading' as const, + message: i18n('Waiting for ${suite} to be ready', { + suite: title, + }), + } + if (officeSetup === 'applying') + return { + result: 'loading' as const, + message: i18n('Setting up ${app}...', { app }), + } + if (officeSetup === 'failed') + return { + result: 'failure' as const, + message: i18n( + 'Could not set up ${app}. Restart Nextcloud to try again, or select “None” using the “Office Suite” action.', + { app }, + ), + } const enabled = await readEnabledApps(nextcloudSub).catch( () => null, ) @@ -670,16 +699,9 @@ export const main = sdk.setupMain(async ({ effects }) => { result: 'starting' as const, message: null, } - const { connectorApp: keep, title } = - officeSuiteMeta[officeSuite] - // Selected a document server but Nextcloud has nothing to - // reach it with — the connector was removed or switched off - // after the selection, which the reconcile leaves alone. - if (!(keep in enabled)) { - // Present but disabled is a different instruction from - // absent, and telling someone to install what they already - // have is how a message stops being followed. - const app = CONNECTOR_APP_TITLES[keep] + // Applied, so the connector was removed or switched off + // since; the reconcile leaves that alone. + if (!(keep in enabled)) return { result: 'failure' as const, message: (await hasNextcloudApp(keep)) @@ -692,14 +714,13 @@ export const main = sdk.setupMain(async ({ effects }) => { { app }, ), } - } const rivals = OFFICE_CONNECTOR_APPS.filter( (a) => a !== keep && a in enabled, ) if (rivals.length === 0) return { result: 'success' as const, - message: i18n('${app} is ready', { app: title }), + message: i18n('${app} is enabled', { app }), } return { result: 'failure' as const, @@ -1285,12 +1306,8 @@ async function reconcileExternalStorage( * certificates out of the path entirely. The browser-facing one is relative, so * the editor follows whichever address the user already reached Nextcloud on. * - * Nothing here runs until the chosen document server reports healthy, so every - * step below is one we expect to succeed rather than one we retry until it - * does. Past that gate it acts only on a change of selection, since the - * signature check short-circuits every other start — which is what makes it - * safe to install and switch on the connector for the suite the user just - * chose, and to switch off the one being left behind. + * Runs only once the chosen document server reports healthy, and acts only on + * a change of selection; `report` carries its progress to the health check. */ async function reconcileOffice( subc: Awaited>, @@ -1302,18 +1319,14 @@ async function reconcileOffice( ownBridge: string | null configured: string }, + report: (state: OfficeSetupState) => void, ): Promise { const { suite, backend, ownBridge, configured } = desired - // Wait for the document server before touching Nextcloud at all. Its own - // health check fetches the endpoint this reconcile depends on, so `success` - // is precisely "the work below can succeed". Waiting is a watch on its - // status: it runs no commands, cannot fail, and resolves the moment the - // service becomes ready — seconds later on an ordinary start, or whenever - // the user gets around to installing it. `waitFor` rejects only when the - // context is torn down, which is a chain rebuild, not an error. + // The port is bound well before the server answers, so wait on its health + // check rather than its address. if (suite) { - const { packageId, healthCheckId, title } = officeSuiteMeta[suite] + const { packageId, healthCheckId } = officeSuiteMeta[suite] try { await sdk .getStatus(effects, { packageId }) @@ -1322,12 +1335,11 @@ async function reconcileOffice( return } if (abort.aborted) return - console.info(`office-suite: ${title} is ready`) + report('applying') } - // ONLYOFFICE signs every request between the two services. Read after the - // gate: the secret is written during its install, so a healthy service has - // one, where a snapshot taken at chain build can predate it. + // Read after the gate: ONLYOFFICE writes this during its install, so a + // snapshot taken at chain build can predate it. const secret = suite === 'onlyoffice' ? await readDependencySecret(effects, { @@ -1350,7 +1362,14 @@ async function reconcileOffice( ? createHash('sha256').update(secret).digest('hex').slice(0, 16) : '', }) - if (desiredSig === configured) return + const record = async () => { + await storeJson.merge(effects, { officeConfigured: desiredSig }) + report('applied') + } + if (desiredSig === configured) { + report('applied') + return + } const previous: { suite?: OfficeSuite | null } = (() => { try { @@ -1363,10 +1382,8 @@ async function reconcileOffice( const occ = (args: string[]) => subc.exec(['php', 'occ', ...args], { user: 'www-data' }) - // Clear the connector we are moving away from and switch it off. Its settings - // would otherwise point at a service that may be uninstalled next, and either - // connector left enabled demotes the Microsoft formats out of the other one's - // default-open list, so both suites end up opening neither. + // Left enabled, the old connector demotes the Microsoft formats out of the + // new one. if (previous.suite && previous.suite !== suite) { const { connectorApp, settingKeys } = officeSuiteMeta[previous.suite] for (const key of settingKeys) { @@ -1378,13 +1395,42 @@ async function reconcileOffice( } if (!suite) { - await storeJson.merge(effects, { officeConfigured: desiredSig }) + await record() return } + const { title, connectorApp: app, packageId } = officeSuiteMeta[suite] + console.info(`office-suite: ${title} is ready`) + + const fail = async (what: string, res?: Awaited>) => { + const output = res + ? `${res.stdout.toString()}\n${res.stderr.toString()}`.trim() + : '' + console.error(`office-suite: ${what}${output ? `: ${output}` : ''}`) + const heading = i18n('Office Suite Setup Failed') + await sdk.notification + .create(effects, { + level: 'error', + title: heading, + message: i18n( + 'Could not set up ${app}. Restart Nextcloud to try again, or select “None” using the “Office Suite” action.', + { app: CONNECTOR_APP_TITLES[app] }, + ), + data: res + ? logDetails( + heading, + { code: res.exitCode, signal: res.exitSignal }, + output, + ) + : null, + }) + .catch(() => null) + report('failed') + } + if (!backend) { console.warn( - `office-suite: ${officeSuiteMeta[suite].packageId} is not reachable yet; leaving the connector alone until it is`, + `office-suite: ${packageId} is not reachable yet; leaving the connector alone until it is`, ) return } @@ -1393,21 +1439,13 @@ async function reconcileOffice( console.warn(`office-suite: ${e}`) return {} }) - const app = officeSuiteMeta[suite].connectorApp if (!(app in enabled)) { - // Both commands refuse an app with no release compatible with this - // Nextcloud, which is the state a major upgrade leaves behind — so the - // protection `Disable Non-default Apps` exists to recover holds here - // without a check of our own. + // Neither command overrides Nextcloud's compatibility check, so an app a + // major upgrade disabled stays disabled. const res = (await hasNextcloudApp(app)) ? await occ(['app:enable', app]) : await occ(['app:install', app]) - if (res.exitCode !== 0) { - console.warn( - `office-suite: could not enable ${app}: ${res.stdout.toString()} ${res.stderr.toString()}`, - ) - return - } + if (res.exitCode !== 0) return fail(`could not enable ${app}`, res) console.info(`office-suite: enabled the ${app} app`) } @@ -1416,11 +1454,6 @@ async function reconcileOffice( // it is run without `--callback-url` — and it has to be run, since it is what // refreshes the cached discovery document. if (suite === 'collabora') { - // That refresh is a live fetch of `/hosting/discovery` back through this - // container's own proxy, which is why the readiness gate above has to have - // passed: a fetch that fails still leaves richdocuments holding a WOPI url - // with no discovery behind it, and every document then opens to a spinner - // that never resolves. const res = await occ([ 'richdocuments:activate-config', // Our own Apache, in-container: the discovery fetch has to pass through @@ -1429,29 +1462,16 @@ async function reconcileOffice( ...(ownBridge ? [`--callback-url=http://${ownBridge}`] : []), ]) if (abort.aborted) return - if (res.exitCode !== 0) { - console.error( - `office-suite: richdocuments:activate-config failed: ${res.stdout.toString()} ${res.stderr.toString()}`, - ) - return - } - await storeJson.merge(effects, { officeConfigured: desiredSig }) - return + if (res.exitCode !== 0) + return fail('richdocuments:activate-config failed', res) + return record() } - // Pairing on an empty secret authenticates nothing, so record nothing and let - // the next chain build try again. - if (!secret) { - console.error( - `office-suite: ${officeSuiteMeta.onlyoffice.packageId} is ready but has published no JWT secret`, - ) - return - } + // An empty secret authenticates nothing, so record nothing. + if (!secret) + return fail(`${packageId} is ready but has published no JWT secret`) - const settings: Record< - (typeof officeSuiteMeta)['onlyoffice']['settingKeys'][number], - string - > = { + const settings: Record = { // Relative, so the browser resolves it against the address it is on. DocumentServerUrl: `${DS_VPATH}/`, DocumentServerInternalUrl: `http://${backend}/`, @@ -1463,13 +1483,8 @@ async function reconcileOffice( for (const [key, value] of Object.entries(settings)) { if (abort.aborted) return const res = await occ(['config:app:set', app, key, `--value=${value}`]) - if (res.exitCode !== 0) { - console.error( - `office-suite: could not set ${key}: ${res.stdout.toString()} ${res.stderr.toString()}`, - ) - return - } + if (res.exitCode !== 0) return fail(`could not set ${key}`, res) } - await storeJson.merge(effects, { officeConfigured: desiredSig }) + await record() } diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts index cf71bb2..88aef7a 100644 --- a/startos/manifest/i18n.ts +++ b/startos/manifest/i18n.ts @@ -55,7 +55,7 @@ export const onlyofficeDescription = { pl_PL: 'Otwiera i edytuje dokumenty, arkusze i prezentacje w przeglądarce, prosto z Plików Nextcloud. Cięższa alternatywa dla Collabora Online', fr_FR: - 'Ouvre et modifie documents, feuilles de calcul et présentations dans le navigateur, depuis Fichiers de Nextcloud. Lalternative plus lourde à Collabora Online', + "Ouvre et modifie documents, feuilles de calcul et présentations dans le navigateur, depuis Fichiers de Nextcloud. L'alternative plus lourde à Collabora Online", } export const long = { diff --git a/startos/officeSuite.ts b/startos/officeSuite.ts index 699d531..dee46a9 100644 --- a/startos/officeSuite.ts +++ b/startos/officeSuite.ts @@ -10,8 +10,7 @@ export const DS_VPATH = '/ds-vpath' export const isOfficeSuite = (v: unknown): v is OfficeSuite => typeof v === 'string' && (OFFICE_SUITES as readonly string[]).includes(v) -// Collabora's WOPI discovery document, proxied below and fetched back through -// that proxy by `richdocuments:activate-config`. +// Fetched back through the proxy below by `richdocuments:activate-config`. export const COLLABORA_DISCOVERY = '/hosting/discovery' // Every Nextcloud app that handles office documents. richdocuments demotes the @@ -49,8 +48,7 @@ export const officeSuiteMeta = { healthCheckId: 'cool', title: 'Collabora Online', connectorApp: 'richdocuments', - // What `richdocuments:activate-config` writes, and so what has to be - // cleared when the selection moves off this backend. + // What `richdocuments:activate-config` writes. settingKeys: ['wopi_url', 'wopi_callback_url', 'public_wopi_url'], }, onlyoffice: { @@ -83,6 +81,11 @@ export const officeSuiteMeta = { } > +export type OnlyofficeSettingKey = + (typeof officeSuiteMeta)['onlyoffice']['settingKeys'][number] + +export type OfficeSetupState = 'waiting' | 'applying' | 'applied' | 'failed' + /** * The Apache configuration that puts the chosen document server on Nextcloud's * own origin. Serving the editor from the address the browser already holds is diff --git a/startos/utils.ts b/startos/utils.ts index 423942e..dd9b811 100644 --- a/startos/utils.ts +++ b/startos/utils.ts @@ -92,9 +92,8 @@ export const coturnSecretPath = `${coturnMountpoint}/turn-secret` /** * Read a secret another package publishes on one of its volumes, through a - * throwaway container that mounts only that subpath read-only — so a missing or - * stopped dependency can never take Nextcloud's own daemons down, and nothing - * else on that volume comes into view. Null if it cannot be read. + * throwaway container that mounts only that subpath read-only, so a missing + * dependency can never take Nextcloud's own daemons down. Null if unreadable. */ export async function readDependencySecret( effects: T.Effects,