htmlviewer-directediting: HTML rendering in Nextcloud iOS app

- htmlviewer: IEditor + Bridge + srcdoc iframe template + content controller
- text app: open() bridges text/html to htmlviewer template
- idempotent install.sh for re-deployment after app updates
- iPhone-tested 2026-09-11
This commit is contained in:
Hermes
2026-09-11 12:19:44 +00:00
commit 88d618b58a
20 changed files with 1245 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Streams the raw HTML file for the DirectEditing sandbox iframe.
* Authenticated via the user session (direct editing keeps a session).
*/
namespace OCA\HtmlViewer\Controller;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\IRequest;
use OCP\IUserSession;
class DirectController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private IRootFolder $rootFolder,
private IUserSession $userSession,
) {
parent::__construct($appName, $request);
}
#[NoAdminRequired]
#[NoCSRFRequired]
public function content(int $fileId) {
$user = $this->userSession->getUser();
if ($user === null) {
return new NotFoundResponse();
}
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$nodes = $userFolder->getById($fileId);
if (empty($nodes)) {
return new NotFoundResponse();
}
$file = array_shift($nodes);
if (!$file instanceof File) {
return new NotFoundResponse();
}
$response = new DataDisplayResponse($file->getContent());
$response->addHeader('Content-Type', 'text/html; charset=utf-8');
return $response;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* DirectEditing editor for text/html so the Nextcloud iOS/Android apps
* open HTML files in an embedded WebView instead of falling back to
* QuickLook source display.
*/
namespace OCA\HtmlViewer\DirectEditing;
use OCA\HtmlViewer\AppInfo\Application;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\DirectEditing\IEditor;
use OCP\DirectEditing\IToken;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\IL10N;
use OCP\IURLGenerator;
class HtmlDirectEditor implements IEditor {
public function __construct(
private IL10N $l10n,
private IURLGenerator $urlGenerator,
) {
}
#[\Override]
public function getId(): string {
return Application::APP_ID;
}
#[\Override]
public function getName(): string {
return $this->l10n->t('HTML Viewer');
}
#[\Override]
public function getMimetypes(): array {
return [
'text/html',
];
}
#[\Override]
public function getMimetypesOptional(): array {
return [];
}
#[\Override]
public function getCreators(): array {
return [];
}
#[\Override]
public function isSecure(): bool {
return false;
}
#[\Override]
public function open(IToken $token): Response {
$token->useTokenScope();
try {
$file = $token->getFile();
return new TemplateResponse(
Application::APP_ID,
'directEditing',
[
'fileId' => $file->getId(),
'fileName' => $file->getName(),
'fileContent' => $file->getContent(),
],
'base'
);
} catch (InvalidPathException|NotFoundException) {
return new NotFoundResponse();
}
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Bridge called from OCA\Text\DirectEditing\TextDirectEditor::open()
* for text/html files. Reuses the htmlviewer DirectEditing template
* so the mobile apps (hard-coded editor registry) render HTML in a
* sandboxed iframe instead of showing source via QuickLook.
*/
namespace OCA\HtmlViewer\DirectEditing;
use OCA\HtmlViewer\AppInfo\Application;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\DirectEditing\IToken;
use OCP\Server;
class HtmlDirectEditorBridge {
public static function openHtml(IToken $token): Response {
try {
$file = $token->getFile();
return new TemplateResponse(
Application::APP_ID,
'directEditing',
[
'fileId' => $file->getId(),
'fileName' => $file->getName(),
'fileContent' => $file->getContent(),
],
'base'
);
} catch (\Throwable) {
return new NotFoundResponse();
}
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\HtmlViewer\Listener;
use OCA\HtmlViewer\DirectEditing\HtmlDirectEditor;
use OCP\DirectEditing\RegisterDirectEditorEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/** @template-implements IEventListener<Event|RegisterDirectEditorEvent> */
final class RegisterDirectEditorListener implements IEventListener {
public function __construct(
private HtmlDirectEditor $editor,
) {
}
#[\Override]
public function handle(Event $event): void {
if (!$event instanceof RegisterDirectEditorEvent) {
return;
}
$event->register($this->editor);
}
}
@@ -0,0 +1,37 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* DirectEditing template: renders the HTML file in a sandboxed iframe
* that fills the whole viewport (works in the iOS/Android WebView).
* The file content is embedded as srcdoc, so the iframe needs no
* session cookie (mobile WebViews do not share the app session).
*/
/** @var array $_ */
?>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background-color: var(--color-main-background, #ffffff);
}
#htmlviewer-frame {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
background: transparent;
}
</style>
<iframe
id="htmlviewer-frame"
title="<?php echo htmlspecialchars($_['fileName'] ?? 'HTML'); ?>"
srcdoc="<?php echo htmlspecialchars($_['fileContent'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8', false); ?>"
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-modals"
></iframe>
+26
View File
@@ -0,0 +1,26 @@
import re
APP='/media/orange/RocketChat/nextcloud1/nextcloud_data/custom_apps/htmlviewer'
p=APP+'/lib/AppInfo/Application.php'
c=open(p).read()
if 'RegisterDirectEditorListener' not in c:
c=c.replace(
'use OCP\\Security\\CSP\\AddContentSecurityPolicyEvent;',
'use OCP\\Security\\CSP\\AddContentSecurityPolicyEvent;\nuse OCA\\HtmlViewer\\Listener\\RegisterDirectEditorListener;\nuse OCP\\DirectEditing\\RegisterDirectEditorEvent;'
)
c=c.replace(
" $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class);",
" $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class);\n $context->registerEventListener(RegisterDirectEditorEvent::class, RegisterDirectEditorListener::class);"
)
open(p,'w').write(c)
print('Application.php patched:', 'RegisterDirectEditorEvent' in open(p).read())
p=APP+'/appinfo/routes.php'
c=open(p).read()
if 'Direct#content' not in c:
c=c.replace(
"['name' => 'settings#disableWarning', 'url' => '/settings/warning', 'verb' => 'GET'],",
"['name' => 'settings#disableWarning', 'url' => '/settings/warning', 'verb' => 'GET'],\n\t\t['name' => 'Direct#content', 'url' => '/direct/{fileId}', 'verb' => 'GET'],"
)
open(p,'w').write(c)
print('routes.php patched:', 'Direct#content' in open(p).read())