diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-05 12:23:18 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-05 12:23:18 +0200 |
| commit | 1d94a92936abb3d37a8f9bfb36c8850246fee15d (patch) | |
| tree | f07aa4f65277d703ac64a84882b0c559bf379f30 | |
| parent | 8f25294b0f6bc3f292442edd69a2e149f0717b52 (diff) | |
| download | meshbay-1d94a92936abb3d37a8f9bfb36c8850246fee15d.tar.gz | |
feat: copy a file's or folder's #/name@owner link from Files, Music, Photos and Search
"Copy link" puts the address group-link.js resolves on the clipboard, on the
hub's origin rather than the page's, so a link copied in the desktop
application is not app://meshbay. Files offers it for one row, from the
right-click menu or the toolbar with one row ticked (a phone's way in);
Music on one track's menu, whose dots a phone has; Photos on a right-clicked
tile and in the lightbox's bar. The video player and the file preview carry
a link button next to Download.
Applications get a `linkFor(entry | folderPath)` prop (MESHBAY_DESIGN.md
§9.2) and offer the action only when it names a link. The group page builds
it from the hub's row; Search from each result's own group and its path
before the merged views prefixed it, and names no link for a folder of the
merged tree, which a group name alone does not identify.
harness/copy_link_probe.py mounts the three applications in Chrome and reads
what reached the clipboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
23 files changed, 567 insertions, 20 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 7c5dce9..fdf08f9 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -2817,6 +2817,7 @@ destructures what it needs — a new application does not get a bespoke prop lis | `transportRef`, `gekRef` | **refs**, never state, so a reconnect does not re-render every application | | `deviceReady` | **the exception, and why it is a prop.** A ref not re-rendering is right for a transport reached into on demand and wrong for a *fact about the connection* an application renders from | | `mayUpload` | computed once; a second derivation would eventually disagree with the first | +| `linkFor(entry \| folderPath)` | the `#/name@owner/path` link "Copy link" puts on the clipboard (§8.4), or null where none can be named. The group page builds it from the hub's row; Search from each result's own group and unprefixed path. An application offers the action only when this returns a link, and copies with `copy-link.js` `copyLink` | An application that needs local state owns it. One pattern is worth carrying: **any notion of "current location within the group" resets on group change**, because a diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index d7f0624..ea81c76 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -304,6 +304,12 @@ file browser — sort, select, download, preview. Files there. Only members get anywhere with such a link — anyone else is told the group is unknown — and someone not signed in is asked to sign in first, then taken where the link pointed. Renaming the group breaks these links. +- **Copy link** gives you that address for one file or folder: right-click it, + or tick it and use the link button in the toolbar (the way on a phone). Music + has it in a track's menu (**⋯** on a phone), Photos when you right-click a + photo or in the photo viewer's bar, and the video player and file preview + have a link button next to Download. Search offers the same, pointing at the + group each result comes from. ### Chat diff --git a/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js b/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js new file mode 100644 index 0000000..ad6c3be --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js @@ -0,0 +1,67 @@ +import { t } from './i18n.js'; +import * as platform from './platform.js'; +import { groupLinkRoute } from './group-link.js'; + +/** + * "Copy link": the `#/name@owner/path` address of a file or folder + * (group-link.js), on this hub, put on the clipboard. + * + * The hub's origin, never the page's: in the desktop application the page is + * `app://meshbay`, and a link that only opens inside one person's application + * is not a link. + */ + +/** The full link to `path` inside `group` (`{ name, owner_username }`). */ +export function groupItemLink(group, path) { + return platform.hubOrigin() + '/#' + groupLinkRoute(group, path); +} + +/** An entry's path inside its group: its folder, root first, then its name. */ +export function entryPath(entry, dir = entry.path) { + return [dir, entry.name].filter(Boolean).join('/'); +} + +// The async clipboard first; the old command where it is refused — a page +// without focus, a WebView without the permission. Neither needs to read +// anything back. +async function _write(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { /* fall through */ } + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none'; + document.body.appendChild(area); + area.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch { ok = false; } + area.remove(); + return ok; +} + +let _note = null; +let _noteTimer = null; + +// Said once and gone, in the playlists' note (style.css `.playlist-note`). +// Appended to the body rather than rendered by a view, because the views that +// copy include modals and a context menu that has already closed. +function _say(text) { + if (!_note) { + _note = document.createElement('div'); + _note.className = 'playlist-note copy-link-note'; + _note.setAttribute('role', 'status'); + } + _note.textContent = text; + document.body.appendChild(_note); + clearTimeout(_noteTimer); + _noteTimer = setTimeout(() => { if (_note) _note.remove(); }, 2500); +} + +/** Copy `url`, and say whether it worked. The link itself is shown if not. */ +export async function copyLink(url) { + if (!url) return; + const ok = await _write(url); + _say(ok ? t('link.copied') : t('link.copy_failed', { url })); +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 978090c..b3672c9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -13,6 +13,7 @@ import { import { useStickyBand } from './sticky.js'; import { Menu, useMenu } from './menu.js'; import { askReport } from './report.js'; +import { copyLink } from './copy-link.js'; // ── Files ──────────────────────────────────────────────────────────────────── // @@ -142,7 +143,7 @@ function FilesPanel({ entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, userId, setError, onPreview, showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, onReport, - openDirectory, + openDirectory, linkFor, }) { const [selected, setSelected] = useState(() => new Set()); const [sortKey, setSortKey] = useState('name'); @@ -623,6 +624,14 @@ function FilesPanel({ onSelect: () => run(async () => { for (const d of dirs) await downloadDirectory(d); }, clear) }, + // One file or one folder, ticked (the toolbar, which is also how a phone + // gets at it) or under the pointer. Absent rather than disabled where + // the view can name no link at all (`linkFor` returns null). + linkFor && { key: 'link', icon: 'link', label: t('link.copy'), + disabled: files.length + dirs.length !== 1 + || !linkFor(files.length ? files[0] : dirs[0]), + onSelect: () => run( + () => copyLink(linkFor(files.length ? files[0] : dirs[0])), clear) }, mayEverDelete && !readOnly && { key: 'delete', icon: 'trash', danger: true, label: deletableCount > 1 ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), disabled: status !== 'connected' || deletableCount === 0, @@ -905,7 +914,7 @@ function FilesPanel({ const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; -function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { +function FilePreview({ entry, transportRef, gekRef, onClose, onDownload, onCopyLink }) { const [phase, setPhase] = useState('loading'); const [progress, setProgress] = useState(0); const [content, setContent] = useState(null); @@ -1000,6 +1009,11 @@ function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { }}> <div class="video-top-bar"> <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> + ${onCopyLink && html` + <button class="video-close" onClick=${onCopyLink} + title="${t('link.copy')}" aria-label="${t('link.copy')}"> + <${Icon} name="link" /></button> + `} ${onDownload && html` <button class="video-close ${downloading ? 'dl-active' : ''}" onClick=${() => { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index a2d6c18..48b1c80 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -6,6 +6,7 @@ import { Icon } from './icon.js'; import { transfers } from './transfers.js'; import { downloadEntry } from './file-utils.js'; import { resolveLinkedPath } from './group-link.js'; +import { copyLink, entryPath, groupItemLink } from './copy-link.js'; import { HUB, session, hubFetch, ensureFreshToken, _loadBundleKey, _loadRecoveryKey, _storeBundleKey, @@ -761,6 +762,14 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (onLinkOpened) onLinkOpened(); }, [openPath, status, groupId]); + // The link "Copy link" puts on the clipboard, for an entry or a folder path + // (group-link.js). Null until the hub's row for the group is in: the handle + // is its name and its owner's, and the node knows neither. + const linkFor = useCallback((target) => { + if (!group || !group.name || !group.owner_username || !target) return null; + return groupItemLink(group, typeof target === 'string' ? target : entryPath(target)); + }, [group]); + const refreshIndex = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; @@ -891,7 +900,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, entries, availableEntries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, attachRoot, attachDir, userId, setError, onPreview, - onRefreshIndex: refreshIndex, onActivity: touchActivity, + onRefreshIndex: refreshIndex, onActivity: touchActivity, linkFor, // Plural everywhere, and built from the registry rather than a list of app // names kept here: Videos and Music read a list, Photos always did, and an // application added to `APPS` gets its own entry without this file @@ -1060,7 +1069,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transportRef=${transportRef} gekRef=${gekRef} onClose=${() => setPreviewEntry(null)} - onDownload=${() => downloadFileForModal(previewEntry)} /> + onDownload=${() => downloadFileForModal(previewEntry)} + onCopyLink=${linkFor(previewEntry) && (() => copyLink(linkFor(previewEntry)))} /> `} ${videoEntry && html` <${VideoPlayer} @@ -1068,7 +1078,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transportRef=${transportRef} gekRef=${gekRef} onClose=${() => setVideoEntry(null)} - onDownload=${() => downloadFileForModal(videoEntry)} /> + onDownload=${() => downloadFileForModal(videoEntry)} + onCopyLink=${linkFor(videoEntry) && (() => copyLink(linkFor(videoEntry)))} /> `} </div> `; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js index 4fa4357..4d549f1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js @@ -65,6 +65,8 @@ const ICON_PATHS = { plus: ['M12 5v14', 'M5 12h14'], refresh: ['M20 11a8 8 0 0 0-15.3-2', 'M4 5v4h4', 'M4 13a8 8 0 0 0 15.3 2', 'M20 19v-4h-4'], + link: ['M10 13.5a4.5 4.5 0 0 0 6.4.4l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.6 1.6', + 'M14 10.5a4.5 4.5 0 0 0-6.4-.4l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.6-1.6'], clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'], pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3', 'M14.5 6.5l3 3'], diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 8d14dca..6e42af9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -212,6 +212,9 @@ export default { 'group.download_offline': 'Keine Verbindung zum Node — der Download kann nicht starten. Die Verbindung wird automatisch wiederhergestellt; versuchen Sie es gleich erneut.', 'group.link_unknown': "Dieser Link verweist auf eine Gruppe, in der Sie nicht Mitglied sind, oder die nicht mehr so heißt.", 'group.link_path_unknown': "Unter {path} gibt es in dieser Gruppe nichts — die Datei wurde vielleicht verschoben, umbenannt oder gelöscht.", + 'link.copy': "Link kopieren", + 'link.copied': "Link kopiert", + 'link.copy_failed': "Kopieren nicht möglich — der Link lautet {url}", 'group.download_write_stalled': 'Die Datei wird nicht mehr auf die Festplatte geschrieben ({seconds} s ohne Fortschritt). Der Download wurde abgebrochen statt hängen gelassen; versuchen Sie es erneut.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index a6cd0b4..f94b438 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -212,6 +212,9 @@ export default { 'group.download_offline': 'Not connected to the node — the download cannot start. It reconnects on its own; try again in a moment.', 'group.link_unknown': "This link names a group you are not a member of, or one that no longer goes by that name.", 'group.link_path_unknown': "Nothing at {path} in this group — the file may have been moved, renamed or deleted.", + 'link.copy': "Copy link", + 'link.copied': "Link copied", + 'link.copy_failed': "Could not copy — the link is {url}", 'group.download_write_stalled': 'The file stopped being written to disk ({seconds}s with no progress). The download was stopped rather than left hanging; try it again.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index e066081..6ff8be2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -210,6 +210,9 @@ export default { 'group.download_offline': 'Sin conexión con el nodo — la descarga no puede empezar. Se reconecta sola; inténtelo de nuevo en un momento.', 'group.link_unknown': "Este enlace nombra un grupo del que no eres miembro, o que ya no se llama así.", 'group.link_path_unknown': "No hay nada en {path} en este grupo: puede que el archivo se haya movido, renombrado o eliminado.", + 'link.copy': "Copiar enlace", + 'link.copied': "Enlace copiado", + 'link.copy_failed': "No se pudo copiar: el enlace es {url}", 'group.download_write_stalled': 'El archivo dejó de escribirse en el disco ({seconds} s sin avance). La descarga se detuvo en lugar de quedarse colgada; inténtelo de nuevo.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 01ccfcd..9132bd2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -211,6 +211,9 @@ export default { 'group.download_offline': 'Pas de connexion au node — le téléchargement ne peut pas démarrer. La reconnexion est automatique, réessayez dans un instant.', 'group.link_unknown': "Ce lien désigne un groupe dont vous n'êtes pas membre, ou qui ne porte plus ce nom.", 'group.link_path_unknown': "Rien à l'emplacement {path} dans ce groupe — le fichier a peut-être été déplacé, renommé ou supprimé.", + 'link.copy': "Copier le lien", + 'link.copied': "Lien copié", + 'link.copy_failed': "Copie impossible — le lien est {url}", 'group.download_write_stalled': 'L\'écriture du fichier sur le disque s\'est arrêtée ({seconds} s sans progression). Le téléchargement a été interrompu plutôt que laissé en suspens ; réessayez.', 'device.add_title': 'Ce navigateur n’est pas encore lié à ce nœud', 'device.add_hint': 'Votre compte est connu ici, mais ce navigateur détient une autre clé. Approuvez-le depuis un appareil déjà lié — sans passer par l’opérateur.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 71af9f8..f3ff714 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -211,6 +211,9 @@ export default { 'group.download_offline': 'Nessuna connessione al nodo — il download non può iniziare. La riconnessione è automatica, riprovi tra poco.', 'group.link_unknown': "Questo link indica un gruppo di cui non sei membro, o che non ha più questo nome.", 'group.link_path_unknown': "Non c'è nulla in {path} in questo gruppo: il file potrebbe essere stato spostato, rinominato o eliminato.", + 'link.copy': "Copia link", + 'link.copied': "Link copiato", + 'link.copy_failed': "Impossibile copiare — il link è {url}", 'group.download_write_stalled': 'Il file ha smesso di essere scritto su disco ({seconds} s senza progressi). Il download è stato interrotto invece di restare bloccato; riprovi.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 4b357c8..0690034 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -209,6 +209,9 @@ export default { 'group.download_offline': 'ノードに接続していません — ダウンロードを開始できません。再接続は自動で行われます。少し待って再試行してください。', 'group.link_unknown': "このリンクは、あなたがメンバーでないグループ、またはもうその名前ではないグループを指しています。", 'group.link_path_unknown': "このグループの {path} には何もありません。ファイルが移動、名前変更、または削除された可能性があります。", + 'link.copy': "リンクをコピー", + 'link.copied': "リンクをコピーしました", + 'link.copy_failed': "コピーできませんでした。リンク: {url}", 'group.download_write_stalled': 'ファイルのディスクへの書き込みが止まりました({seconds} 秒間進みません)。ぶら下がったままにせず中止しました。もう一度お試しください。', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 1f4b12c..ce6312c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -212,6 +212,9 @@ export default { 'group.download_offline': 'Geen verbinding met de node — de download kan niet starten. Er wordt automatisch opnieuw verbonden; probeer het zo weer.', 'group.link_unknown': "Deze link verwijst naar een groep waarvan je geen lid bent, of die niet meer zo heet.", 'group.link_path_unknown': "Er staat niets op {path} in deze groep — het bestand is misschien verplaatst, hernoemd of verwijderd.", + 'link.copy': "Link kopiëren", + 'link.copied': "Link gekopieerd", + 'link.copy_failed': "Kopiëren mislukt — de link is {url}", 'group.download_write_stalled': 'Het bestand wordt niet meer naar schijf geschreven ({seconds} s zonder voortgang). De download is gestopt in plaats van te blijven hangen; probeer het opnieuw.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 05a3006..2737532 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -215,6 +215,9 @@ export default { 'group.download_offline': 'Brak połączenia z węzłem — pobieranie nie może się rozpocząć. Połączenie wróci samo; proszę spróbować za chwilę.', 'group.link_unknown': "Ten link wskazuje grupę, do której nie należysz, lub która nie nosi już tej nazwy.", 'group.link_path_unknown': "W tej grupie nie ma niczego pod {path} — plik mógł zostać przeniesiony, zmieniony lub usunięty.", + 'link.copy': "Kopiuj link", + 'link.copied': "Link skopiowany", + 'link.copy_failed': "Nie udało się skopiować — link to {url}", 'group.download_write_stalled': 'Plik przestał być zapisywany na dysk ({seconds} s bez postępu). Pobieranie zostało przerwane, zamiast wisieć w nieskończoność; proszę spróbować ponownie.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 824c69a..c7cdf01 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -212,6 +212,9 @@ export default { 'group.download_offline': 'Sem conexão com o nó — o download não pode começar. Ele reconecta sozinho; tente de novo em instantes.', 'group.link_unknown': "Este link aponta para um grupo do qual você não é membro, ou que não tem mais esse nome.", 'group.link_path_unknown': "Não há nada em {path} neste grupo — o arquivo pode ter sido movido, renomeado ou excluído.", + 'link.copy': "Copiar link", + 'link.copied': "Link copiado", + 'link.copy_failed': "Não foi possível copiar — o link é {url}", 'group.download_write_stalled': 'O arquivo parou de ser gravado no disco ({seconds}s sem progresso). O download foi interrompido em vez de ficar travado; tente de novo.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index d0d3954..06f61ff 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -208,6 +208,9 @@ export default { 'group.download_offline': '未连接到节点 — 无法开始下载。连接会自动恢复,请稍后重试。', 'group.link_unknown': "此链接指向一个您不是其成员的群组,或该群组已不再使用此名称。", 'group.link_path_unknown': "此群组中 {path} 处没有内容——文件可能已被移动、重命名或删除。", + 'link.copy': "复制链接", + 'link.copied': "链接已复制", + 'link.copy_failed': "无法复制——链接为 {url}", 'group.download_write_stalled': '文件停止写入磁盘({seconds} 秒无进展)。已中止下载而不是让它一直卡住,请重试。', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js index f2c4374..9adb1e1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js @@ -11,6 +11,7 @@ import { Menu, MenuDots, useMenu } from './menu.js'; import { PlaylistMenuButton, NameModal, usePlaylists } from './playlist-menu.js'; import * as P from './playlists.js'; import { CastButton } from './cast-session.js'; +import { copyLink } from './copy-link.js'; // -- Music -------------------------------------------------------------------- // @@ -564,7 +565,7 @@ function FlatList({ items, onPlayQueue, onMenu }) { function MusicApp({ groupId, transportRef, gekRef, status, entries, availableEntries, musicDirectories, musicbrainzConfig, onPlayQueue, userId, - hideFilter, userPrefs, pageResetKey, + hideFilter, userPrefs, pageResetKey, linkFor, }) { const [mode, setMode] = useState(loadViewMode); const [filter, setFilter] = useState(''); @@ -618,6 +619,9 @@ function MusicApp({ // argument slot for one. const onMenu = useCallback((e, tracks, startIndex) => { if (!tracks || !tracks.length) return; + // One track's menu — a row's, whose dots are how a phone opens it. An + // album's carries all its tracks and gets no link: Files has the folder's. + const link = tracks.length === 1 && linkFor ? linkFor(tracks[0]) : null; openAt(e, [ { label: t('music.menu_play'), icon: 'play', onSelect: () => onPlayQueue(tracks, startIndex || 0) }, @@ -650,8 +654,10 @@ function MusicApp({ }, ], }, + ...(link ? [{ divider: true }, + { label: t('link.copy'), icon: 'link', onSelect: () => copyLink(link) }] : []), ]); - }, [openAt, onPlayQueue, playlists, addToPlaylist]); + }, [openAt, onPlayQueue, playlists, addToPlaylist, linkFor]); useEffect(() => { setMode(loadViewMode()); }, [groupId]); useEffect(() => { setFilter(''); }, [groupId]); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js index 00bc288..afc815a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js @@ -10,6 +10,8 @@ import { transfers } from './transfers.js'; import { MediaThumb, LazyTile } from './media-tiles.js'; import { SourceTag } from './group-name.js'; import { CastButton, castSession, useCastDevice, showPhoto } from './cast-session.js'; +import { Menu, useMenu } from './menu.js'; +import { copyLink } from './copy-link.js'; // ── Photos ─────────────────────────────────────────────────────────────────── // @@ -118,11 +120,11 @@ function AlbumLanding({ albums, transportRef, gekRef, onOpen }) { // ── open album: grid of its own photos ────────────────────────────────────── -function PhotoTile({ entry, transportRef, gekRef, onOpen }) { +function PhotoTile({ entry, transportRef, gekRef, onOpen, onMenu }) { const tRef = entry._tRef || transportRef; const gRef = entry._gRef || gekRef; return html` - <div class="photo-tile" onClick=${onOpen}> + <div class="photo-tile" onClick=${onOpen} onContextMenu=${onMenu}> <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name} cls="photo-tile-thumb" transportRef=${tRef} gekRef=${gRef} emptyIcon="image" /> @@ -153,7 +155,7 @@ const ZOOM_MAX = 400; // a slow node must not skip a photo nobody saw. const SLIDESHOW_MS = 5000; -function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { +function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor }) { const entry = photos[index]; const tRef = entry._tRef || transportRef; const gRef = entry._gRef || gekRef; @@ -284,6 +286,10 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { aria-pressed=${slideshow ? 'true' : 'false'}> <${Icon} name=${slideshow ? 'pause' : 'play'} /></button> <${CastButton} variant="overlay" cls="video-close" /> + ${linkFor && linkFor(entry) && html` + <button class="video-close" onClick=${() => copyLink(linkFor(entry))} + title=${t('link.copy')} aria-label=${t('link.copy')}> + <${Icon} name="link" /></button>`} <button class="video-close" onClick=${onClose} title=${t('video.close')}> <${Icon} name="close" /></button> </div> @@ -311,8 +317,16 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { `; } -function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly }) { +function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor }) { const [lightboxIndex, setLightboxIndex] = useState(null); + // Right-click a photo for its link. A tile has no dots to press, so on a + // touchscreen the link is in the lightbox's bar instead. + const { menu, openAt, close: closeMenu } = useMenu(); + const onTileMenu = (e, entry) => { + const link = linkFor ? linkFor(entry) : null; + if (!link) return; + openAt(e, [{ label: t('link.copy'), icon: 'link', onSelect: () => copyLink(link) }]); + }; const zip = useCallback(async () => { const transport = transportRef.current; @@ -352,15 +366,16 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea ${album.photos.map((e, i) => html` <${LazyTile} key=${e.id} cls="photo-tile-slot"> <${PhotoTile} entry=${e} transportRef=${transportRef} gekRef=${gekRef} - onOpen=${() => setLightboxIndex(i)} /> + onOpen=${() => setLightboxIndex(i)} onMenu=${(ev) => onTileMenu(ev, e)} /> </${LazyTile}> `)} </div> ${lightboxIndex !== null && html` <${Lightbox} photos=${album.photos} index=${lightboxIndex} transportRef=${transportRef} gekRef=${gekRef} - onClose=${() => setLightboxIndex(null)} onNav=${navigate} /> + onClose=${() => setLightboxIndex(null)} onNav=${navigate} linkFor=${linkFor} /> `} + ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`} `; } @@ -369,7 +384,7 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea function PhotosApp({ groupId, transportRef, gekRef, status, entries, availableEntries, photoDirectories, setError, - hideFilter, readOnly, + hideFilter, readOnly, linkFor, }) { const [openDir, setOpenDir] = useState(null); const [filter, setFilter] = useState(''); @@ -423,7 +438,7 @@ function PhotosApp({ ${status === 'connected' && openAlbum && html` <${AlbumView} album=${openAlbum} entries=${entries} transportRef=${transportRef} gekRef=${gekRef} setError=${setError} - onBack=${() => setOpenDir(null)} readOnly=${readOnly} /> + onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor} /> `} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js index 874b7b0..1ede1f6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -13,6 +13,7 @@ import { MusicApp, groupMusicEntries, foldKey } from './music-app.js'; import { PhotosApp, groupPhotoAlbums } from './photos-app.js'; import { VideoPlayer } from './video-player.js'; import { transfers } from './transfers.js'; +import { copyLink, entryPath, groupItemLink } from './copy-link.js'; import { mergeUnitEntries } from './source-merge.js'; import { useStickyBand } from './sticky.js'; import { ConnectionPool, MAX_IN_FLIGHT, MAX_POOL_SIZE } from './connection-pool.js'; @@ -514,6 +515,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) result.push({ ...e, path: SEARCH_VIDEO_ROOT + '/' + e.path, + _origPath: e.path, groupId, groupName: data.groupName, groupOwner: data.groupOwner, @@ -540,6 +542,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) result.push({ ...e, path: SEARCH_AUDIO_ROOT + '/' + e.path, + _origPath: e.path, groupId, groupName: data.groupName, groupOwner: data.groupOwner, @@ -567,6 +570,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) result.push({ ...e, path: '__search_photos__/' + e.path, + _origPath: e.path, groupId, groupName: data.groupName, groupOwner: data.groupOwner, @@ -640,6 +644,19 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) // No-op setters for FilesPanel const noop = useCallback(() => {}, []); + + // "Copy link" for a result: its own group's handle and its path there, from + // `_origPath` — every view here prefixes `path` to merge groups into one tree. + // A merged entry is the source it was resolved to (source-merge.js), so the + // link names a group that has the file. A folder is a path in that merged + // tree, named by group name alone, which two owners may share: no link. + const linkFor = useCallback((target) => { + if (!target || typeof target === 'string' || !target.groupName || !target.groupOwner) { + return null; + } + return groupItemLink({ name: target.groupName, owner_username: target.groupOwner }, + entryPath(target, target._origPath)); + }, []); // The search field and its view toggle are this page's equivalent of a // group's tab bar: the same band, pinned the same way, publishing the same // property for the toolbar underneath (style.css, "Sticky chrome"). @@ -726,6 +743,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) readOnly=${true} getTransport=${getTransport} showRefresh=${true} + linkFor=${linkFor} onRefreshIndex=${() => setRefreshTick((n) => n + 1)} /> `} @@ -754,7 +772,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) entries=${musicEntries} musicDirectories=${[SEARCH_AUDIO_ROOT]} musicbrainzConfig=${{ enabled: true }} - onPlayQueue=${handleMusicPlay} userId=${userId} + onPlayQueue=${handleMusicPlay} userId=${userId} linkFor=${linkFor} userPrefs=${userPrefs} pageResetKey=${q} hideFilter=${true} /> `} @@ -767,7 +785,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) status="connected" entries=${photoEntries} photoDirectories=${SEARCH_PHOTO_ROOTS} - setError=${noop} + setError=${noop} linkFor=${linkFor} hideFilter=${true} readOnly=${true} /> `} @@ -788,7 +806,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) transportRef=${modalTransportRef} gekRef=${modalGekRef} onClose=${() => setPreviewEntry(null)} - onDownload=${() => downloadForModal(previewEntry)} /> + onDownload=${() => downloadForModal(previewEntry)} + onCopyLink=${linkFor(previewEntry) && (() => copyLink(linkFor(previewEntry)))} /> `} ${videoEntry && html` <${VideoPlayer} @@ -796,7 +815,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) transportRef=${modalTransportRef} gekRef=${modalGekRef} onClose=${() => setVideoEntry(null)} - onDownload=${() => downloadForModal(videoEntry)} /> + onDownload=${() => downloadForModal(videoEntry)} + onCopyLink=${linkFor(videoEntry) && (() => copyLink(linkFor(videoEntry)))} /> `} </div> `; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 9383043..bde1c61 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -5613,6 +5613,14 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } overflow: hidden; text-overflow: ellipsis; } +/* The same note for "Copy link" (copy-link.js). When the clipboard refused, it + carries the link itself, which must be readable whole rather than cut. */ +.copy-link-note.playlist-note { + white-space: normal; + overflow-wrap: anywhere; + border-radius: 12px; + text-align: center; +} /* A run of single-album artists, pooled into one grid so five of them fill one row instead of spending five (music-app.js `albumSections`). diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index 37ea727..21abe8e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -403,7 +403,7 @@ function purgeUnscopedResumePositions() { purgeUnscopedResumePositions(); -function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { +function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload, onCopyLink }) { const [dlBusy, setDlBusy] = useState(false); const [phase, setPhase] = useState('loading'); const [error, setError] = useState(''); @@ -1957,6 +1957,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { } </span> `} + ${onCopyLink && html` + <button class="video-close" onClick=${onCopyLink} + title="${t('link.copy')}" aria-label="${t('link.copy')}"> + <${Icon} name="link" /></button> + `} ${onDownload && html` <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy} onClick=${() => { diff --git a/packages/meshbay-hub/tests/harness/copy_link_probe.py b/packages/meshbay-hub/tests/harness/copy_link_probe.py new file mode 100644 index 0000000..50d269d --- /dev/null +++ b/packages/meshbay-hub/tests/harness/copy_link_probe.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +""" +"Copy link" in Files, Music and Photos, in a real browser. + +Mounts the shipped `FilesPanel`, `MusicApp` and `PhotosApp` on a made-up +index — no node, no transport — with the `linkFor` a group page gives them, +built by the real `copy-link.js` for a group `demo@someowner`. Opens each +menu the way a reader would (right-click; the dots on a phone; the Files +toolbar with one row ticked), picks "Copy link", and reads back what reached +the clipboard and what the page said. + + copy_link_probe.py + +Prints JSON: one entry per case. +""" + +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8775 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +FRAME = r"""<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head><body> +<div id="files"></div><div id="music"></div><div id="photos"></div> +<script type="module"> +import { html, render } from '/vendor/htm-preact.js'; +import { initLocale, setLocale } from '/i18n.js'; +import { FilesPanel } from '/files-app.js'; +import { MusicApp } from '/music-app.js'; +import { PhotosApp } from '/photos-app.js'; +import { groupItemLink, entryPath } from '/copy-link.js'; + +const LOGS = []; +addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e))); +addEventListener('unhandledrejection', + (e) => LOGS.push('rejection: ' + (e.reason && e.reason.message || e.reason))); +const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +// What the clipboard was handed, and nothing else: a headless page has no +// clipboard permission to test against. +const COPIES = []; +Object.defineProperty(navigator, 'clipboard', { configurable: true, + value: { writeText: async (text) => { COPIES.push(text); } } }); + +const GROUP = { name: 'demo', owner_username: 'someowner' }; +const linkFor = (target) => (target + ? groupItemLink(GROUP, typeof target === 'string' ? target : entryPath(target)) : null); +const noop = () => {}; +const ref = { current: null }; + +const labels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-label')] + .map((el) => el.textContent); +const pick = async (label) => { + const item = [...document.querySelectorAll('.ctx-menu .ctx-menu-item')] + .find((b) => b.querySelector('.ctx-menu-label').textContent === label); + if (!item) return false; + item.click(); + await wait(50); + return true; +}; +const note = () => (document.querySelector('.copy-link-note') || {}).textContent || ''; +const closeMenus = async () => { + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + await frame(); +}; +const rightClick = async (el) => { + await closeMenus(); + const r = el.getBoundingClientRect(); + el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, + clientX: r.left + 10, clientY: r.top + 5 })); + await frame(); +}; +const rowNamed = (name) => [...document.querySelectorAll('tr.file-row')] + .find((tr) => tr.querySelector('.file-name') + && tr.querySelector('.file-name').textContent.trim().startsWith(name)); + +(async () => { + const cases = []; + try { + setLocale('en'); + await initLocale(); + try { localStorage.setItem('meshbay_music_view_mode', 'flat'); } catch {} + + // -- Files -- + const FILES = [ + { id: 'f1', name: 'IMG 0001.JPG', path: 'Root/trip_2015', size: 10, type: 'image', + added_at: 1 }, + { id: 'f2', name: 'notes.txt', path: 'Root/trip_2015', size: 5, type: 'document', + added_at: 2 }, + ]; + render(html`<${FilesPanel} groupId="g" transportRef=${ref} gekRef=${ref} + status="connected" entries=${FILES} nodeDirs=${['Root', 'Root/trip_2015']} + nodeRoots=${[{ name: 'Root', writable: false }]} + setEntries=${noop} setNodeDirs=${noop} setNodeRoots=${noop} applyIndex=${noop} + isNodeAdmin=${false} operatorPaired=${false} userId="me" setError=${noop} + onPreview=${noop} linkFor=${linkFor} />`, document.getElementById('files')); + await frame(); + + await rightClick(rowNamed('Root')); + const folderLabels = labels(); + await pick('Copy link'); + cases.push({ case: 'files folder', labels: folderLabels, copied: COPIES.at(-1), note: note() }); + + rowNamed('Root').click(); await frame(); + rowNamed('trip_2015').click(); await frame(); + await rightClick(rowNamed('IMG 0001.JPG')); + const fileLabels = labels(); + await pick('Copy link'); + cases.push({ case: 'files file', labels: fileLabels, copied: COPIES.at(-1), note: note() }); + + await closeMenus(); + const linkBtn = () => document.querySelector('.tb-actions button[aria-label="Copy link"]'); + rowNamed('notes.txt').querySelector('input[type=checkbox]').click(); await frame(); + const oneTicked = linkBtn() ? !linkBtn().disabled : null; + if (linkBtn() && !linkBtn().disabled) { linkBtn().click(); await wait(50); } + const copiedFromToolbar = COPIES.at(-1); + for (const n of ['IMG 0001.JPG', 'notes.txt']) { + rowNamed(n).querySelector('input[type=checkbox]').click(); await frame(); + } + const twoTicked = linkBtn() ? !linkBtn().disabled : null; + cases.push({ case: 'files toolbar', one_enabled: oneTicked, two_enabled: twoTicked, + copied: copiedFromToolbar }); + render(null, document.getElementById('files')); + + // -- Music: a loose track, its row's dots (what a phone has) -- + const TRACKS = [ + { id: 'a1', name: 'track 01.flac', path: 'Music/Some Album', size: 3, type: 'audio' }, + { id: 'a2', name: 'track 02.flac', path: 'Music/Some Album', size: 3, type: 'audio' }, + ]; + render(html`<${MusicApp} groupId="g" transportRef=${ref} gekRef=${ref} + status="connected" entries=${TRACKS} musicDirectories=${['Music']} + musicbrainzConfig=${{ enabled: false }} onPlayQueue=${noop} userId="" + linkFor=${linkFor} />`, document.getElementById('music')); + await frame(); + await closeMenus(); + const dots = document.querySelector('.music-flat-track .ctx-dots'); + if (dots) { dots.click(); await frame(); } + const trackLabels = labels(); + await pick('Copy link'); + cases.push({ case: 'music track', dots: Boolean(dots), labels: trackLabels, + copied: COPIES.at(-1) }); + render(null, document.getElementById('music')); + + // -- Photos: right-click a tile; the lightbox's button -- + const PHOTOS = [ + { id: 'p1', name: 'beach.jpg', path: 'Pics/Summer', size: 4, type: 'image', added_at: 1 }, + ]; + render(html`<${PhotosApp} groupId="g" transportRef=${ref} gekRef=${ref} + status="connected" entries=${PHOTOS} photoDirectories=${['Pics']} + setError=${noop} linkFor=${linkFor} />`, document.getElementById('photos')); + await frame(); + const album = document.querySelector('.photo-album-card'); + if (album) { album.click(); await frame(); } + const tile = document.querySelector('.photo-tile'); + let tileLabels = []; + if (tile) { + await rightClick(tile); + tileLabels = labels(); + await pick('Copy link'); + } + const fromTile = COPIES.at(-1); + if (tile) { tile.click(); await frame(); } + const lb = document.querySelector('.photo-lightbox button[aria-label="Copy link"]'); + COPIES.length = 0; + if (lb) { lb.click(); await wait(50); } + cases.push({ case: 'photos', tile: Boolean(tile), labels: tileLabels, copied: fromTile, + lightbox_button: Boolean(lb), lightbox_copied: COPIES.at(-1) || null }); + + parent.postMessage({ cases, logs: LOGS, origin: location.origin }, '*'); + } catch (err) { + parent.postMessage({ error: String(err && (err.stack || err)), logs: LOGS }, '*'); + } +})(); +</script></body></html>""" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head> +<body style="margin:0"><div id="frames"></div><script> +addEventListener('message', (e) => { + fetch('/log', { method: 'POST', body: JSON.stringify(e.data) }); +}); +const f = document.createElement('iframe'); +f.src = '/case'; +f.style.cssText = 'width:1100px;height:800px;border:0;display:block'; +document.getElementById('frames').appendChild(f); +</script></body></html>""" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if self.path == "/log": + RECORDS.append(json.loads(self.rfile.read(length).decode())) + else: + self.rfile.read(length) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/": + self._send(PAGE.encode(), "text/html; charset=utf-8") + elif path == "/case": + self._send(FRAME.encode(), "text/html; charset=utf-8") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/test_copy_link.py b/packages/meshbay-hub/tests/test_copy_link.py new file mode 100644 index 0000000..7115d04 --- /dev/null +++ b/packages/meshbay-hub/tests/test_copy_link.py @@ -0,0 +1,98 @@ +""" +"Copy link": the `#/name@owner/path` address of a file or folder, from the +views that show one (harness/copy_link_probe.py). + +Files offers it for one row — right-click, or the toolbar with one row ticked, +which is how a phone reaches it; Music on one track's menu, whose dots are a +phone's way in; Photos on a right-clicked tile and in the lightbox. The video +player and the file preview carry a button. Search passes the same `linkFor`, +naming each result's own group and its path there rather than the merged tree's. +""" + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "copy_link_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +ORIGIN = "http://127.0.0.1:8775" + + +@pytest.fixture(scope="module") +def cases(): + if shutil.which("google-chrome") is None: + pytest.skip("Chrome is not available") + proc = subprocess.run([sys.executable, str(HARNESS)], + capture_output=True, text=True, timeout=180) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = json.loads(proc.stdout) + assert "error" not in out, out["error"] + assert out["logs"] == [] + return {c["case"]: c for c in out["cases"]} + + +def test_files_a_folder(cases): + c = cases["files folder"] + assert "Copy link" in c["labels"] + assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Root" + assert c["note"] == "Link copied" + + +def test_files_a_file(cases): + c = cases["files file"] + assert c["labels"].index("Copy link") > c["labels"].index("Download") + assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Root/trip_2015/IMG%200001.JPG" + + +def test_files_toolbar_one_ticked_row_only(cases): + c = cases["files toolbar"] + assert c["one_enabled"] is True and c["two_enabled"] is False + assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Root/trip_2015/notes.txt" + + +def test_music_a_track_from_its_dots(cases): + c = cases["music track"] + assert c["dots"] and c["labels"][-1] == "Copy link" + assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Music/Some%20Album/track%2001.flac" + + +def test_photos_tile_and_lightbox(cases): + c = cases["photos"] + link = f"{ORIGIN}/#/demo@someowner/Pics/Summer/beach.jpg" + assert c["labels"] == ["Copy link"] and c["copied"] == link + assert c["lightbox_button"] and c["lightbox_copied"] == link + + +@pytest.mark.parametrize("module", ["video-player.js", "files-app.js"]) +def test_the_modals_carry_a_link_button(module): + source = (STATIC / module).read_text(encoding="utf-8") + assert re.search(r"function (VideoPlayer|FilePreview)\(\{[^}]*onCopyLink", source) + assert "${onCopyLink && html`" in source + + +@pytest.mark.parametrize("page, n", [("group-page.js", 2), ("search-page.js", 2)]) +def test_both_pages_hand_the_modals_a_link(page, n): + source = (STATIC / page).read_text(encoding="utf-8") + assert source.count("onCopyLink=${linkFor(") == n + + +def test_search_names_each_results_own_group_and_path(): + source = (STATIC / "search-page.js").read_text(encoding="utf-8") + # Every view prefixes `path`; the link is built from the original. + assert source.count("_origPath: e.path,") == 4 + assert "entryPath(target, target._origPath)" in source + for view in ("FilesPanel", "MusicApp", "PhotosApp"): + at = source.index(f"<${{{view}}}") + assert "linkFor=${linkFor}" in source[at:source.index("/>", at)], view + + +def test_the_link_is_the_hubs_not_the_pages(): + """In the desktop application the page is app://meshbay.""" + source = (STATIC / "copy-link.js").read_text(encoding="utf-8") + assert "platform.hubOrigin() + '/#' + groupLinkRoute(" in source + assert "location.origin" not in source |