Update site structure, update global js to add domain warning, update SpiderADB

This commit is contained in:
octospacc 2024-04-19 00:34:22 +02:00
parent eecdf1f2fd
commit 87757fbcee
80 changed files with 248 additions and 75 deletions

5
.gitignore vendored
View File

@ -1,6 +1,3 @@
*.bak
node_modules/
/public/a/
/public/s/
/public/SpiderADB/
/public/WuppiMini/
/public/

View File

@ -1,12 +1,24 @@
#!/bin/sh
for App in SpiderADB WuppiMini
SourceApps="SpiderADB WuppiMini"
HubSdkApps="$(SourceApps) Ecoji MatrixStickerHelper"
rm -vrf ./public || true
cp -vr ./static ./public
cp -vr ./shared ./public/shared
for App in $(SourceApps)
do
mkdir -p ./public/${App}
cd ./src/${App}
cd ./source/${App}
sh ./Requirements.sh
cp -r $(sh ./Build.sh) ../../public/${App}/
cd ../..
done
cp -r ./shared ./public/shared
cd ./public
node ../WriteRedirectPages.js
for App in $(HubSdkApps)
do
echo # TODO write manifest.json files
done

View File

@ -0,0 +1,29 @@
window.addEventListener('load', (function(){
if (!['', 'hub.octt.eu.org'].includes(location.host)) {
var noticeElem = document.createElement('p');
noticeElem.style = `
position: fixed;
z-index: 1000;
top: 0;
left: 0;
margin: 0;
width: 100%;
color: black;
background-color: thistle;
font-size: initial;
font-size: smaller;
text-align: center;
`;
noticeElem.innerHTML = `You are viewing this page on the secondary/backup domain. <a style="color: darkblue;" href="https://hub.octt.eu.org${location.pathname}">Open it on hub.octt.eu.org</a>.
<button style="
float: right;
height: 1.25em;
margin: 0;
padding-top: 0;
padding-bottom: 0;
" onclick="this.parentElement.remove()">X</button>`;
document.body.appendChild(noticeElem);
}
}));

View File

@ -1,48 +1,54 @@
import * as Adb from '@yume-chan/adb';
import * as AdbDaemonWebUsb from '@yume-chan/adb-daemon-webusb';
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
import { DecodeUtf8Stream } from '@yume-chan/stream-extra';
import { DecodeUtf8Stream, WrapReadableStream, WrapConsumableStream } from '@yume-chan/stream-extra';
import { PackageManager } from '@yume-chan/android-bin';
// TODO:
// * warning on fail to claim USB interface (it may be because of other tabs, or a local adb server)
// * warn or gracefully handle debug permission not granted
// * package manager with install/uninstall/dump, debloat tool with default list and import/export
// * fastboot shell and tools? possible?
// * logs for Packages section?
(async function(){
const deviceSelect = $('select$deviceSelect$');
const deviceConnect = $('button$deviceConnect$');
const terminalOutput = $('textarea$terminalOutput$');
function resizeTerminal () {
terminalOutput.style.height = `${window.innerHeight - ((48 + 8) * 4)}px`;
}
resizeTerminal();
window.addEventListener('resize', (function(){
resizeTerminal();
}));
$('input$terminalInput$').addEventListener('keydown', (async function(event){
if (event.keyCode == 13) { // Enter
const cmd = $('input$terminalInput$').value;
terminalOutput.textContent += (cmd + '\n');
const process = await Device.adb.subprocess.spawn(cmd);
await process.stdout.pipeThrough(new DecodeUtf8Stream()).pipeTo(
new WritableStream({ write(chunk) {
terminalOutput.textContent += chunk;
terminalOutput.scrollTop = terminalOutput.scrollHeight;
} }),
);
terminalOutput.textContent += '\n> ';
$('input$terminalInput$').value = null;
};
}));
$('button$apkInstall$').onclick = (async function(){
// TODO show info popup before actually installing, also allow installing via drag&drop on packages section
const fileInput = $('button$apkInstall$').querySelector('input');
fileInput.onchange = (function(event){
const count = event.target.files.length;
if (!count > 0) {
return;
}
alert(`Installing ${count} package(s)...`);
const pm = new PackageManager(Device.adb);
Array.from(event.target.files).forEach(async function(file, index){
try {
await pm.installStream(file.size, (new WrapReadableStream(file.stream())).pipeThrough(new WrapConsumableStream()));
alert(`Successfully installed package ${index + 1} of ${count}.`);
refreshPackagesList();
} catch (err) {
alert(err);
}
});
});
fileInput.click();
});
let Device = {};
const CredentialStore = new AdbWebCredentialStore();
function resizeTerminal () {
const divider = (Device.adb ? 2 : 3);
terminalOutput.style.height = `${window.innerHeight - ((48 + 8) * divider)}px`;
}
window.addEventListener('resize', resizeTerminal);
resizeTerminal();
const UsbManager = AdbDaemonWebUsb.AdbDaemonWebUsbDeviceManager.BROWSER;
if (!UsbManager) {
$('div$browserWarning$').innerHTML = `<p>
@ -79,8 +85,10 @@ async function connectAuthorizeDevice () {
Device.transport = await Adb.AdbDaemonTransport.authenticate({ connection: Device.connection, credentialStore: CredentialStore });
Device.adb = new Adb.Adb(Device.transport);
} catch (err) {
$('[name="deviceStatus"]').innerHTML = 'An error occurred while trying to establish a device connection. Please ensure that no other processes or browser tabs on this system are currently using the device, then retry.';
$('p$deviceStatus$').textContent = 'An error occurred while trying to establish a device connection. Please ensure that no other processes or browser tabs on this system are currently using the device, then retry.';
}
} else {
$('p$deviceStatus$').textContent = null;
}
}
@ -103,15 +111,18 @@ async function refreshDeviceSelect () {
deviceSelect.innerHTML = null;
const devices = await UsbManager.getDevices();
if (devices.length) {
$('p$deviceStatus$').textContent = null;
deviceSelect.innerHTML = '<option>[📲️ Select a connected device]</option>';
devices.forEach(function(device, index){
var deviceOption = document.createElement('option');
const deviceOption = document.createElement('option');
deviceOption.textContent = `${device.raw.productName} [${device.raw.serialNumber}]`;
deviceSelect.appendChild(deviceOption);
});
deviceSelect.onchange = onSwitchDevice;
deviceSelect.disabled = false;
} else {
// TODO probably put this warning elsewhere? because it seems like the browser can see even Androids with ADB disabled, obviously they won't be able to connect
$('p$deviceStatus$').innerHTML = 'Connect a debuggable Android device via USB to continue. (Read "<a target="_blank" href="https://www.lifewire.com/enable-usb-debugging-android-4690927#toc-how-to-enable-usb-debugging-on-android">How to Enable USB Debugging on Android</a>" for help).';
deviceSelect.innerHTML = '<option>[📵️ No connected devices]</option>';
}
}
@ -123,7 +134,8 @@ async function onSwitchDevice () {
}
async function refreshDeviceInfo () {
if (deviceSelect.selectedIndex > 0) {
let onDevice = (deviceSelect.selectedIndex > 0);
if (onDevice) {
const device = await getDevice();
$('$deviceOem$').innerHTML = `<b>Brand</b>: ${device.raw.manufacturerName}`;
$('$deviceModel$').innerHTML = `<b>Model</b>: ${device.raw.productName}`;
@ -133,29 +145,38 @@ async function refreshDeviceInfo () {
if (Device.adb) {
$('$deviceStatus$').innerHTML = null;
// $('$devicePropDump$').innerHTML = null;
$('$deviceCpuAbis$').innerHTML = `<b>CPU ABIs</b>: ${await Device.adb.getProp('ro.system.product.cpu.abilist')}`;
$('$androidVersion$').innerHTML = `<b>Android version</b>: ${await Device.adb.getProp('ro.build.version.release')}`;
$('$androidApi$').innerHTML = `<b>API version</b>: ${await Device.adb.getProp('ro.build.version.sdk')}`;
$('$androidApi$').innerHTML = `<b>SDK API version</b>: ${await Device.adb.getProp('ro.build.version.sdk')}`;
//$('$androidNickname$').innerHTML = `<b>Device name</b>: ${await Device.adb.getProp('persist.sys.device_name')}`;
$('$androidBuildDate$').innerHTML = `<b>Build date</b>: ${await Device.adb.getProp('ro.vendor.build.date')}`;
$('$androidBuildFingerprint$').innerHTML = `<b>Build fingerprint</b>: ${await Device.adb.getProp('ro.vendor.build.fingerprint')}`;
$('$androidInfo$').hidden = false;
$('$connectReminder$').hidden = true;
terminalOutput.disabled = false;
terminalOutput.textContent += '> ';
$('input$terminalInput$').disabled = false;
terminalOutput.textContent += (terminalOutput.textContent ? '\n> ' : '> ');
$('button$clearTerminal$').disabled = false;
/* for (const line of (await Device.adb.getProp()).split('\n')) {
const elem = document.createElement('li');
elem.textContent = line;
$('$devicePropDump$').appendChild(elem);
} */
} else {
$('$deviceInfo$').hidden = true;
onDevice = false;
}
$('$deviceInfo$').hidden = false;
} else {
$('$deviceStatus$').innerHTML = null;
//$('$deviceStatus$').innerHTML = null;
$('$connectReminder$').hidden = false;
terminalOutput.disabled = true;
$('input$terminalInput$').disabled = true;
$('$deviceInfo$').hidden = true;
}
toggleDeviceElems(onDevice);
}
function toggleDeviceElems (enabled) {
$('$deviceInfo$').hidden = !enabled;
$('button$apkInstall$').disabled = !enabled;
$('input$terminalInput$').disabled = !enabled;
resizeTerminal();
}
async function refreshDeviceSection () {
@ -164,7 +185,7 @@ async function refreshDeviceSection () {
}
refreshDeviceSection();
deviceConnect.onclick = (async function(){
$('button$deviceConnect$').onclick = (async function(){
const device = await UsbManager.requestDevice();
if (!device) {
return;
@ -174,6 +195,60 @@ deviceConnect.onclick = (async function(){
deviceSelect.selectedIndex = (deviceSelect.children.length - 1);
deviceSelect.onchange();
});
deviceConnect.disabled = false;
$('button$deviceConnect$').disabled = false;
$('input$terminalInput$').addEventListener('keydown', (async function(event){
if (event.keyCode == 13) { // Enter
const cmd = $('input$terminalInput$').value;
if (!terminalOutput.textContent) {
terminalOutput.textContent += '> ';
}
terminalOutput.textContent += (cmd + '\n');
const process = await Device.adb.subprocess.spawn(cmd);
const processWriteToTerminal = () => new WritableStream({ write(chunk) {
terminalOutput.textContent += chunk;
terminalOutput.scrollTop = terminalOutput.scrollHeight;
$('button$clearTerminal$').disabled = false;
} });
await process.stdout.pipeThrough(new DecodeUtf8Stream()).pipeTo(processWriteToTerminal());
await process.stderr.pipeThrough(new DecodeUtf8Stream()).pipeTo(processWriteToTerminal());
terminalOutput.textContent += '\n> ';
terminalOutput.scrollTop = terminalOutput.scrollHeight;
$('input$terminalInput$').value = null;
};
}));
$('button$clearTerminal$').onclick = (function(){
terminalOutput.textContent = '';
$('button$clearTerminal$').disabled = true;
});
$('button$wrapTerminal$').onclick = (function(){
terminalOutput.style.textWrap = (terminalOutput.style.textWrap ? '' : 'nowrap');
terminalOutput.scrollTop = terminalOutput.scrollHeight;
});
async function refreshPackagesList () {
$('ul$packageList$').innerHTML = null;
const pm = new PackageManager(Device.adb);
const list = await pm.listPackages();
let result = await list.next();
while (!result.done) {
var packageElem = document.createElement('li');
packageElem.innerHTML = `${result.value.packageName}<!-- <input type="checkbox" class="floatRight"/>-->`;
/* packageElem.querySelector('input').onclick = (function(){
// TODO: hide or show action buttons that do actions on selected elements if there is none or at least one
}); */
$('ul$packageList$').appendChild(packageElem);
result = await list.next();
}
}
window.addEventListener('hashchange', (async function(){
const sectionHash = location.hash.slice(2).split('/')[0];
if (Device.adb && sectionHash === 'packages' /* && !$('ul$packageList$').innerHTML */) {
refreshPackagesList();
}
}));
})();

View File

@ -27,7 +27,7 @@ a[data-action-section] {
/* width: 60%; */
height: 100vh;
z-index: 1;
background: rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.6);
}
.holo-sidebar[data-open="open"], .holo-sideBar[data-open="open"] {
@ -37,7 +37,7 @@ a[data-action-section] {
.holo-sidebar .holo-list, .holo-sideBar .holo-list {
width: 60%;
height: 100vh;
background: black;
background: rgba(0, 0, 0, 0.9);
}
.actionBar .holo-title.holo-menu, .holo-actionBar .holo-title.holo-menu {

View File

@ -1,13 +1,17 @@
window.addEventListener('load', (function() {
var initialSectionName = $('[data-section][data-open]').dataset.section;
$('::[data-action-sidebar]').forEach(function(actionSidebarElem){
var sidebarElem = $('[data-sidebar="' + actionSidebarElem.dataset.actionSidebar + '"]');
actionSidebarElem.onclick = sidebarElem.onclick = (function(){
function toggleSidebar () {
sidebarElem.dataset.open = (sidebarElem.dataset.open !== 'open' ? 'open' : false);
});
sidebarElem.querySelector('.holo-list').onclick = (function(event){
}
actionSidebarElem.addEventListener('click', toggleSidebar);
sidebarElem.addEventListener('click', toggleSidebar);
sidebarElem.querySelector('.holo-list').addEventListener('click', (function(event){
event.stopPropagation();
});
}));
arrayFrom(sidebarElem.querySelectorAll('.holo-list li button, .holo-list li [role="button"]')).forEach(function(buttonElem){
buttonElem.addEventListener('click', (function(){
sidebarElem.dataset.open = false;
@ -40,9 +44,11 @@ function refreshDisplaySections (sectionTargetName) {
}
refreshDisplaySections();
var sectionHash = location.hash.slice(2).split('/')[0];
if (sectionHash) {
$(`[data-action-section="${sectionHash}"]`).click();
function hashChange () {
var sectionHash = location.hash.slice(2).split('/')[0];
$(`[data-action-section="${sectionHash || initialSectionName}"]`).click();
}
window.addEventListener('hashchange', hashChange);
hashChange();
}));

View File

Before

Width:  |  Height:  |  Size: 98 B

After

Width:  |  Height:  |  Size: 98 B

View File

@ -14,8 +14,16 @@
<script src="./holo-web/holo-touch.js"></script>
<script src="../../shared/OctoHub-Global.js"></script>
<style>
body { overflow-x: hidden; }
.floatRight { float: right; }
body { overflow-x: hidden; padding-bottom: 0; }
ul.holo-list li input[type="checkbox"].floatRight { margin-top: 0; }
section.holo-sideBar > ul.holo-list { overflow-y: auto; }
[name="terminalInput"], [name="terminalOutput"] {
width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
resize: none;
}
</style>
</head>
<body>
@ -26,6 +34,16 @@
<!-- <button class="floatRight" data-display-sections="terminal">
Tabs
</button> -->
<button class="floatRight" data-display-sections="terminal" name="clearTerminal" disabled="true">
Clear
</button>
<button class="floatRight" data-display-sections="terminal" name="wrapTerminal">
Wrap
</button>
<button class="floatRight" data-display-sections="packages" name="apkInstall">
<input type="file" hidden="true" multiple="true" accept=".apk, application/vnd.android.package-archive"/>
Install APK(s)
</button>
</header>
<section class="holo-sideBar" data-sidebar="sidebar">
<ul class="holo-list">
@ -38,9 +56,9 @@
<!-- <li><button data-action-section="files">
📄 Files
</button></li> -->
<!-- <li><button data-action-section="packages">
📦 Packages
</button></li> -->
<li><button data-action-section="packages">
📦 Packages <small>(WIP)</small>
</button></li>
<li><button data-action-section="about">
❓️ About
</button></li>
@ -54,15 +72,31 @@
You must <a data-action-section="devices">connect and authorize a device</a> first.
</p>
<section class="holo-section" data-section="about">
<p>SpiderADB is an user-friendly webapp for connecting to devices via the Android Debug Bridge, straight from a browser. More infos coming soon.</p>
<p>SpiderADB is an user-friendly webapp for connecting to devices via the Android Debug Bridge, straight from a browser.</p>
<!-- TODO features -->
<p>Here are some additional tips and tricks you might find useful to make the most out of this app:</p><ul>
<li><a target="_blank" href="https://dev.to/larsonzhong/most-complete-adb-commands-4pcg">Most complete ADB command manual</a></li>
</ul>
<h3>Open Source and Credits</h3>
<p>
This app is open-source and made with mostly-vanilla web technologies. You can find the full source code on my Git repo: <a href="https://gitlab.com/octospacc/octospacc.gitlab.io/-/tree/master/source/SpiderADB/">https://gitlab.com/octospacc/octospacc.gitlab.io/-/tree/master/source/SpiderADB/</a>.
<p>Also, this app wouldn't have been possible without these third-party components, of which the license is specified in brackets:</p><ul>
<li><a target="_blank" href="https://github.com/yume-chan/ya-webadb">Tango</a> [MIT]: ADB port for the web</li>
<li><a target="_blank" href="https://github.com/tango-adb/old-demo">Tango Demo (Old)</a> [MIT]: the previous official Tango demo webapp, helpful for writing my app since the Tango documentation is pretty lacking</li>
<li><a target="_blank" href="https://github.com/zmyaro/holo-web">Holo Web</a> [MIT]: stylesheets for recreating the Android Holo theme on the web</li>
</ul>
<h3>Changelog</h3>
<h4>2024-04-18</h4><ul>
<li>Improve Terminal logic, which now also shows stdout, scaling, and add a Clear feature.</li>
<li>Introduce Packages menu, listing all installed packages' names, and allow installing (multiple) APKs files.</li>
</ul>
<h4>2024-04-16</h4><ul>
<li>Introduced the basic Terminal.</li>
<li>Introduce the basic Terminal.</li>
<li>Slight improvements to the user experience with better error handling.</li>
</ul>
<h4>2024-04-14</h4><ul>
<li>First WIP version, with Android ICS Holo UI, allows simply connecting to devices and shows basic info.</li>
<li>Introduced sections: Devices, About.</li>
<li>Introduce sections: Devices, About.</li>
</ul>
</section>
<section class="holo-section" data-section="devices" data-open="open">
@ -81,6 +115,10 @@
<div name="androidInfo" hidden="true">
<li name="androidVersion"></li>
<li name="androidApi"></li>
<!-- <li name="androidNickname"></li> -->
<li name="androidBuildDate"></li>
<li name="androidBuildFingerprint"></li>
<li name="deviceCpuAbis"></li>
</div>
</ul>
<!-- <details>
@ -90,14 +128,12 @@
</section>
</section>
<section class="holo-section" data-section="terminal">
<textarea name="terminalOutput" readonly="true" disabled="true" placeholder="Terminal output will be displayed here." style="width: 100%; margin-left: 0; margin-right: 0;"></textarea>
<input name="terminalInput" type="text" disabled="true" placeholder="&gt; Input any command..." style="width: 100%; margin-left: 0; margin-right: 0;"/>
<textarea name="terminalOutput" readonly="true" disabled="true" placeholder="Terminal output will be displayed here."></textarea>
<input name="terminalInput" type="text" disabled="true" placeholder="&gt; Input any command..."/>
</section>
<section class="holo-section" data-section="packages">
<!-- TODO buttons -->
<ul class="holo-list" name="packageList">
<li>Test <input type="checkbox"/></li>
</ul>
<ul class="holo-list" name="packageList"></ul><!--<li>Test <input type="checkbox" class="floatRight"/></li><!--<label><li>Test <input type="checkbox"/></li></label>-->
<section class="holo-subsection" name="packageInfo">
</section>

View File

@ -8,6 +8,7 @@
"@yume-chan/adb": "^0.0.23",
"@yume-chan/adb-credential-web": "^0.0.23",
"@yume-chan/adb-daemon-webusb": "^0.0.23",
"@yume-chan/android-bin": "^0.0.23",
"@yume-chan/stream-extra": "^0.0.23"
}
},
@ -50,6 +51,17 @@
"tslib": "^2.6.2"
}
},
"node_modules/@yume-chan/android-bin": {
"version": "0.0.23",
"resolved": "https://registry.npmjs.org/@yume-chan/android-bin/-/android-bin-0.0.23.tgz",
"integrity": "sha512-yOhErwfD7oe8piG/kboHbYGLQJU/eJE81yUzQFJbXtI0guR8j9baeyVYuTHzSVyvFTlBqsd5cd68bYGT6o3yAw==",
"dependencies": {
"@yume-chan/adb": "^0.0.23",
"@yume-chan/stream-extra": "^0.0.23",
"@yume-chan/struct": "^0.0.23",
"tslib": "^2.6.2"
}
},
"node_modules/@yume-chan/async": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@yume-chan/async/-/async-2.2.0.tgz",

View File

@ -3,6 +3,7 @@
"@yume-chan/adb": "^0.0.23",
"@yume-chan/adb-credential-web": "^0.0.23",
"@yume-chan/adb-daemon-webusb": "^0.0.23",
"@yume-chan/android-bin": "^0.0.23",
"@yume-chan/stream-extra": "^0.0.23"
}
}

View File

@ -260,6 +260,7 @@ const endpointInfo = [ (ctx) => (ctx.urlSections[0] === 'info' && ctx.request.me
${isEnvServer ? `You can obtain the full source code and assets by downloading the following files:
${resFiles.map((file) => ` • <a href="/res/${file}">${file}</a>`).join('')}.
` : 'To get the original, unminified source code, visit this same page on the server-side version (refer to the Versions section above).'}
Alternatively, you can also find the source code on my shared Git repo: ${A('https://gitlab.com/octospacc/octospacc.gitlab.io/-/tree/master/source/WuppiMini/')}.
</p>
${isEnvServer ? `<h3>Terms of Use and Privacy Policy</h3>${appTerms}` : ''}
<h3>Changelog</h3>

View File

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

View File

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 354 KiB

View File

Before

Width:  |  Height:  |  Size: 581 KiB

After

Width:  |  Height:  |  Size: 581 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -48,27 +48,31 @@
--><a href="./HashyMagnet/">🧲 HashyMagnet</a>
<small>(BitTorrent Hash to Magnet)</small><!--
--></h4>
<h4><!--
--><a href="./Ecoji/">🦜 Ecoji v1</a>
<small>(webapp fork)</small><!--
--></h4>
<h4><!--
--><a href="./FramesBrowser/">🪟️ Frames Browser</a>
<small>(<i>yo dawg, i heard you...</i>)</small><!--
--></h4>
<h4><a href="./MatrixStickerHelper/">🃏️ [Matrix] Sticker Helper</a></h4>
<h4><!--
--><a href="./MBViewer/">👁️‍🗨️️ MBViewer</a>
<small>(WordPress/RSS/... chat-like UI)</small><!--
--><a href="./SpiderADB/">🕷️ SpiderADB</a>
<small>(Android debugging for Web)</small><!--
--></h4>
<h4><!--
--><a href="./WuppiMini/">☘️ WuppìMini</a>
<small>(basic-HTML posting client)</small><!--
--></h4>
<h4><!--
--><a href="./MBViewer/">👁️‍🗨️️ MBViewer</a>
<small>(WordPress/RSS/... chat-like UI)</small><!--
--></h4>
<h4><!--
--><a href="./Ecoji/">🦜 Ecoji v1</a>
<small>(webapp fork)</small><!--
--></h4>
<br/>
<h4><a href="https://octospacc.gitlab.io/FumoPrisms">🔺️ Fumo Prisms (!)</a></h4>
<h4><a href="https://octospacc.gitlab.io/FumoPrisms/">🔺️ Fumo Prisms (!)</a></h4>
<h4><a href="./Collections/">🎀 My Collections</a> <small>(of posts/pages)</small></h4>
<h4><a href="./Userscripts/">⚙️ My Userscripts</a></h4>
<h4><a href="./Userscripts/">⚙️ My Userscripts</a> <small>for a nicer web</small></h4>
<h4>Nice <a href="https://addons.mozilla.org/firefox/collections/18049170/octollection/">🦊 Firefox Add-ons</a>
<small>(<a href="https://addons.mozilla.org/firefox/user/18049170/">mine</a> + suggestions)</small></h4>
</div></div>

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB