#!/usr/bin/perl use strict; use warnings; use File::Copy qw(copy); use File::Path qw(make_path); my ($source_dir, $assets_dir) = @ARGV; die "usage: $0 SOURCE_DIR ASSETS_DIR\n" unless $source_dir && $assets_dir; sub read_file { my ($path) = @_; open my $fh, '<', $path or die "open $path: $!\n"; local $/; return <$fh>; } sub write_file { my ($path, $content) = @_; open my $fh, '>', $path or die "write $path: $!\n"; print {$fh} $content; close $fh or die "close $path: $!\n"; } my $index_path = "$source_dir/src/index.html"; my $index = read_file($index_path); my $hero = read_file("$assets_dir/login-hero.html"); my $core_welcome = $hero; $core_welcome =~ s{
\s*

.*?

}{
\n$hero}sx); die "could not replace Glowing Bear login header\n" unless $count == 1; $count = ($index =~ s{ () }{$1\n }x); die "could not inject cyberpunk stylesheet\n" unless $count == 1; $count = ($index =~ s{ \s* \s*\s* \s* Connection\ settings.*? (?=) }{$connection_panel}sx); die "could not replace connection panel\n" unless $count == 1; $count = ($index =~ s{ ]*id="gettingStartedAccordion">.*? (?=\s*
\s*\s*}{}; $index =~ s{Image upload failed\.}{\{\{ uploadErrorMessage || 'Encrypted upload failed.' \}\}} or die "could not customize upload error\n"; $count = ($index =~ s{ \s*\s* \s*]+for="iToken".*?\s* }{$drop_settings}sx); die "could not replace Imgur settings\n" unless $count == 1; $count = ($index =~ s{ ()\s* }{$1
    }sx); die "could not add settings tabs\n" unless $count == 1; $index =~ s{Settings\ will\ be\ stored\ in\ your\ browser\.}{Browser preferences stay local; Colours and WeeChat settings are saved to the relay.} or die "could not clarify settings persistence\n"; $count = ($index =~ s{
\s*(\s*) }{ $colour_settings $weechat_settings $1}x); die "could not add colour settings panel\n" unless $count == 1; $index =~ s{ng-class="\{'no-overflow':\ connected\}"}{ng-class="{'no-overflow': connected, 'neo-preferred-font': connected}"} or die "could not scope preferred typography to the connected client\n"; $index =~ s{}{

The quick brown fox jumps over the lazy dog. 0123456789

} or die "could not add preferred font previews\n"; $count = ($index =~ s{ (]*>\s*) ({{PRODUCT_NAME}} private IRC gateway\n {{PRODUCT_NAME}}\n $1 ng-hide="activeBuffer().fullName === 'core.weechat'"}x); die "could not guard mobile core title\n" unless $count == 1; $count = ($index =~ s{ .*? }{}sx); die "could not remove topbar actions\n" unless $count == 1; my $core_label = q!{{ buffer.fullName === 'core.weechat' ? '{{PRODUCT_NAME}}' : (buffer.trimmedName || buffer.fullName) }}!; $count = ($index =~ s!(class="buffername" ng-class="::buffer\.nameClasses">)\{\{ buffer\.trimmedName \|\| buffer\.fullName \}\}!$1$core_label!); die "could not rename visible core buffer\n" unless $count == 1; $count = ($index =~ s{ (]*>)\s* }{$1\n $core_welcome\n
}x); die "could not replace core buffer contents\n" unless $count == 1; $index =~ s{('private': buffer\.type === 'private')}{$1, 'server': buffer.type === 'server'} or die "could not mark server buffers\n"; $index =~ s{}{} or die "could not add nick context menu\n"; $index =~ s{\s*\s*
}{ } or die "could not add NeoBear UI overlays\n"; write_file($index_path, $index); my $js_path = "$source_dir/src/js/glowingbear.js"; my $js = read_file($js_path); $js =~ s/'hostField': 'localhost'/'hostField': '{{RELAY_HOST}}'/ or die "could not set relay host default\n"; $js =~ s/'port': 9001/'port': 443/ or die "could not set relay port default\n"; $js =~ s/'customCSS': '',/'customCSS': '', 'dropProfile': 'small', 'neorelaySidebarWidth': 0,/ or die "could not add NeoDrop profile setting\n"; $js =~ s{(\s+\$scope\.settings\s*=\s*settings;)}{$1\n\n$weechat_settings_js\n$colour_settings_js\n$sidebar_resize} or die "could not add desktop sidebar resizing\n"; $js =~ s{ 'fontfamily':\s*\(utils\.isMobileUi\(\)\s*\?\s*'sans-serif'\s*:\s*'[^']+'\) }{'fontfamily': 'OpenDyslexic, Atkinson Hyperlegible, monospace'}x or die "could not set font default\n"; $js =~ s{ (\$scope\.command\s*=\s*'';) }{$1 \$scope.maskPassword = function(value) { return value ? new Array(value.length + 1).join('*') : ''; }; \$scope.openServerManager = function() { \$scope.\$broadcast('neoServerManagerOpen'); }; var neoPwaInstallPrompt = null; \$scope.pwaInstallAvailable = false; window.addEventListener('beforeinstallprompt', function(event) { event.preventDefault(); neoPwaInstallPrompt = event; \$scope.\$applyAsync(function() { \$scope.pwaInstallAvailable = true; }); }); window.addEventListener('appinstalled', function() { neoPwaInstallPrompt = null; \$scope.\$applyAsync(function() { \$scope.pwaInstallAvailable = false; }); }); \$scope.installPwa = function() { if (!neoPwaInstallPrompt) return; neoPwaInstallPrompt.prompt(); neoPwaInstallPrompt.userChoice.finally(function() { neoPwaInstallPrompt = null; \$scope.\$applyAsync(function() { \$scope.pwaInstallAvailable = false; }); }); };}x or die "could not add password masker\n"; $js =~ s{ \$scope\.init\s*=\s*function\(\)\s*\{\s* \$scope\.parseHost\(\);\s* \$scope\.parseHash\(\);\s* \}; }{\$scope.init = function() { settings.hostField = '{{RELAY_HOST}}'; settings.host = '{{RELAY_HOST}}'; settings.port = 443; settings.path = 'weechat'; settings.tls = true; \$scope.parseHost(); };}x or die "could not hardcode relay initialization\n"; $js =~ s{ (\$scope\.connect\s*=\s*function\(\)\s*\{) }{$1 settings.hostField = '{{RELAY_HOST}}'; settings.host = '{{RELAY_HOST}}'; settings.port = 443; settings.path = 'weechat'; settings.tls = true;}x or die "could not hardcode relay connection\n"; $js =~ s{ document\.getElementById\('audioNotificationInitializer'\)\.play\(\); }{var audioPlay = document.getElementById('audioNotificationInitializer').play(); if (audioPlay && audioPlay.catch) { audioPlay.catch(function() {}); }}x or die "could not handle blocked notification audio\n"; $js =~ s{ (\$rootScope\.bufferlistfilter\s*=\s*function\(buffer\)\s*\{) }{$1\n if (buffer.fullName === 'relay.relay.list') {\n return false;\n }}x or die "could not hide relay list buffer\n"; $js =~ s{ (\$rootScope\.\$on\('relayDisconnect',\ function\(\)\ \{) }{$1\n \$rootScope.loadingLines = false;}x or die "could not reset line loading on disconnect\n"; $js =~ s{ (settings\.addCallback\('fontfamily',\ function\(fontfamily\)\ \{\s* utils\.changeClassStyle\('favorite-font',\ 'fontFamily',\ fontfamily\);) }{$1 document.documentElement.style.setProperty('--neo-preferred-font', fontfamily);}x or die "could not apply preferred font to connected controls\n"; $js =~ s{ (utils\.changeClassStyle\('favorite-font',\ 'fontSize',\ fontsize\);\s* \}\);) }{utils.changeClassStyle('favorite-font', 'fontSize', fontsize); document.documentElement.style.setProperty('--neo-preferred-font-size', fontsize); \});}x or die "could not apply preferred font size to connected controls\n"; write_file($js_path, $js); my $models_path = "$source_dir/src/js/models.js"; my $models = read_file($models_path); $models =~ s{(var\ nicklist\ =\ \{\};)}{$1\n var nickColours = {};} or die "could not add nick colour index\n"; $models =~ s{(nicklist\[group\]\.nicks\.push\(nick\);)}{$1\n nickColours[nick.name] = nick.displayClasses;} or die "could not index added nick colour\n"; $models =~ s{(if\ \(group\ ===\ undefined\)\ \{.*?\n\s*\})\s*(for\ \(i\ in\ group\.nicks\)\ \{)}{$1\n $2}sx or die "could not locate nick deletion loop\n"; $models =~ s{(if\ \(group\.nicks\[i\]\.name\ ==\ nick\.name\)\ \{)}{$1\n delete nickColours[nick.name];} or die "could not remove deleted nick colour\n"; $models =~ s{(var\ clearNicklist\ =\ function\(\)\ \{)}{$1\n nickColours = {};} or die "could not clear nick colour index\n"; $models =~ s{(if\ \(group\.nicks\[i\]\.name\ ===\ nick\.name\)\ \{\s*group\.nicks\[i\]\ =\ nick;)}{$1\n nickColours[nick.name] = nick.displayClasses;}x or die "could not update nick colour index\n"; $models =~ s{ (//\ Check\ whether\ a\ particular\ nick\ is\ in\ the\ nicklist) }{$1 var getNickColourClasses = function(nick) { return nick ? (nickColours[nick] || ['neo-nick']) : []; }; }x or die "could not add nick colour lookup\n"; $models =~ s{(nicklistRequested:\ nicklistRequested,)}{$1\n getNickColourClasses: getNickColourClasses,} or die "could not export nick colour lookup\n"; $models =~ s{(for\ \(var\ pti\ =\ 0;\ pti\ <\ prefix\.length;\ \+\+pti\)\ \{\s*prefixtext\ \+=\ prefix\[pti\]\.text;\s*\})}{$1\n var nick = showHiddenBrackets && prefix.length ? prefix[prefix.length - 1].text : '';}x or die "could not identify chat prefix nick\n"; $models =~ s{(prefixtext:\ prefixtext,)}{$1\n nick: nick,} or die "could not export chat prefix nick\n"; $models =~ s{ (var\ colorClasses\ =\ nickGetClasses\(message\);)\s* return\ \{\s* prefix:\ prefix,\s* visible:\ visible,\s* name:\ name,\s* prefixClasses:\ colorClasses\.prefix,\s* nameClasses:\ colorClasses\.name\s* \}; }{$1 var roleClass = 'neo-nick'; if (colorClasses.name.indexOf('cof-nicklist_away') >= 0) roleClass = 'neo-nick-away'; else if (['~', '&', String.fromCharCode(64)].indexOf(prefix.charAt(0)) >= 0) roleClass = 'neo-nick-op'; else if (prefix.charAt(0) === '%') roleClass = 'neo-nick-halfop'; else if (prefix.charAt(0) === '+') roleClass = 'neo-nick-voice'; var isSelf = colorClasses.name.indexOf('cof-chat_nick_self') >= 0; var isAway = colorClasses.name.indexOf('cof-nicklist_away') >= 0; var hasWeeChatRoleColour = prefix && !( colorClasses.prefix.length === 1 && colorClasses.prefix[0] === 'cwf-default' ); var displayClasses = colorClasses.name.slice(); if (!isSelf && !isAway && hasWeeChatRoleColour) displayClasses = [roleClass]; else if (!isSelf && isAway) displayClasses.push('neo-nick-away'); else if (!isSelf && prefix && !hasWeeChatRoleColour) displayClasses.push(roleClass); return { prefix: prefix, visible: visible, name: name, prefixClasses: colorClasses.prefix, nameClasses: colorClasses.name, roleClass: roleClass, displayClasses: displayClasses }; }x or die "could not add semantic nick colours\n"; write_file($models_path, $models); my $connection_path = "$source_dir/src/js/connection.js"; my $connection = read_file($connection_path); $count = ($connection =~ s!\Qconnect(d[0], d[1], d[2], d[3], d[4], false, "", d[5], function() {\E!connect(d[0], d[1], d[2], d[3], d[4], d[5], function() {!); die "could not fix reconnect callback arguments\n" unless $count == 1; write_file($connection_path, $connection); my $websockets_path = "$source_dir/src/js/websockets.js"; my $websockets = read_file($websockets_path); $count = ($websockets =~ s{ if\ \(ws\ !==\ null\ &&\ ws\.readyState\ !==\ WebSocket\.CLOSED\)\ \{\s* ws\.close\(\);\s* \} }{if (ws !== null && ws.readyState !== WebSocket.CLOSED) { ws.onclose = null; ws.onerror = null; ws.onmessage = null; ws.close(); }}x); die "could not detach stale WebSocket handlers\n" unless $count == 1; write_file($websockets_path, $websockets); my $input_path = "$source_dir/src/directives/input.html"; my $input = read_file($input_path); $input =~ s/title="Send image"/title="Upload encrypted file"/g; $input =~ s/ accept="image\/\*"//; $input =~ s{(]*>)}{$1\n$command_menu} or die "could not add command menu template\n"; $input =~ s{(ng-paste="inputPasted\(\$event\)")}{$1 aria-autocomplete="list" aria-controls="{{inputId}}-command-menu" aria-expanded="{{commandMenuOpen}}"} or die "could not add command menu accessibility attributes\n"; write_file($input_path, $input); my $inputbar_path = "$source_dir/src/js/inputbar.js"; my $inputbar = read_file($inputbar_path); $inputbar =~ s{(\s+\$scope\.utils\s*=\s*utils;)}{$1\n\n$command_menu_js} or die "could not add command menu behavior\n"; $inputbar =~ s{ (\s+//\ Cancel\ any\ command\ completion\ that\ was\ still\ ongoing) }{\n updateCommandMenu();\n$1}x or die "could not update command menu on input\n"; $inputbar =~ s{ (\s+//\ Support\ different\ browser\ quirks\s* var\ code\ =\ \$event\.keyCode\ \?\ \$event\.keyCode\ :\ \$event\.charCode;) }{$1 if (\$scope.handleCommandMenuKey(\$event, code, inputNode)) { return true; }}x or die "could not add command menu keyboard handling\n"; $count = ($inputbar =~ s{ (\s+\$rootScope\.insertAtCaret\s*=\s*function\(toInsert\)\s*\{) }{ \$rootScope.registerNeoDrop = function(readUrl, deleteCapability) { connection.sendMessage('/drops register ' + readUrl + ' ' + deleteCapability); };\n$1 }x); die "could not add NeoDrop manager registration callback\n" unless $count == 1; $count = ($inputbar =~ s{ \s*//\ Show\ toast\ with\ delete\ link \s*var\ toastImgur\s*=.*? \s*console\.log\('An\ image\ was\ uploaded\ to\ imgur,\ delete\ it\ with\ .*?; }{}sx); die "could not remove exposed Imgur deletion capability\n" unless $count == 1; write_file($inputbar_path, $inputbar); my $connection_source_path = "$source_dir/src/js/connection.js"; my $connection_source = read_file($connection_source_path); $count = ($connection_source =~ s{ (\s+var\ requestCompletion\ =\ function\(bufferId,\ position,\ data\)\ \{) }{ var requestInfolist = function(name, pointer, args) { return ngWebsockets.send( weeChat.Protocol.formatInfolist({name: name, pointer: pointer || 0, args: args || ''}) ).then(function(message) { if (!message.objects || !message.objects[0]) return []; return message.objects[0].content.map(function(fields) { var item = {}; fields.forEach(function(field) { Object.keys(field).forEach(function(key) { item[key] = field[key]; }); }); return item; }); }); }; var requestCommandHooks = function() { return requestInfolist('hook', 0, 'command').then(function(items) { return items.map(function(hook) { return { command: hook.command || '', args: hook.args_nls || hook.args || '', description: hook.description_nls || hook.description || '', priority: hook.priority || 0 }; }).filter(function(hook) { return hook.command; }); }); }; var sendMessageToBuffer = function(bufferId, message) { ngWebsockets.send(weeChat.Protocol.formatInput({buffer: '0x' + bufferId, data: message})); };$1 }x); die "could not add command hook request\n" unless $count == 1; $connection_source =~ s{ (\s+requestCompletion:\ requestCompletion) }{$1,\n requestCommandHooks: requestCommandHooks,\n requestInfolist: requestInfolist,\n sendMessageToBuffer: sendMessageToBuffer}x or die "could not export command hook request\n"; $connection_source =~ s{ (\s+\$rootScope\.connected\s*=\s*true;) }{$1 \$rootScope.reconnecting = false; \$rootScope.loadingLines = false; \$rootScope.errorMessage = false;}x or die "could not reset successful connection state\n"; write_file($connection_source_path, $connection_source); my $plugin_directive_path = "$source_dir/src/js/plugin-directive.js"; my $plugin_directive = read_file($plugin_directive_path); $plugin_directive =~ s{ \$scope\.plugin\.visible\s*=\s*!settings\.noembed\s*&&\s*!\$scope\.plugin\.nsfw; }{\$scope.plugin.visible = !\$scope.plugin.nsfw && (\$scope.plugin.name === 'NeoDrop media' || !settings.noembed);}x or die "could not enable automatic NeoDrop media previews\n"; write_file($plugin_directive_path, $plugin_directive); my $plugins_path = "$source_dir/src/js/plugins.js"; my $plugins = read_file($plugins_path); $plugins =~ s{(\s+return \{\s*plugins: \[)}{$drop_plugin$1neoDropMediaPlugin, neoDropTextPlugin, } or die "could not register NeoDrop preview plugin\n"; write_file($plugins_path, $plugins); my $main_path = "$source_dir/src/main.js"; my $main = read_file($main_path); $main =~ s{(import "\./js/glowingbear\.js";)}{$1 import "./js/context-menu.js"; import "./js/server-manager.js";} or die "could not import NeoBear UI modules\n"; $main .= q! if ('serviceWorker' in navigator) { window.addEventListener('load', function() { navigator.serviceWorker.register('serviceworker.js'); }); } !; write_file($main_path, $main); my $webpack_path = "$source_dir/webpack.config.js"; my $webpack = read_file($webpack_path); $webpack =~ s{ (output:\s*\{\s*path:\s*path\.resolve\(__dirname,\s*'build'\),) }{$1\n filename: 'main.neorelay.27.js',}x or die "could not version JavaScript bundle\n"; $webpack =~ s{"\*\*/\*\.png",}{"**/*.png",\n "**/*.jpg",} or die "could not enable JPEG asset copying\n"; $webpack =~ s{"\*\*/\*\.jpg",}{"**/*.jpg",\n "**/*.woff2",\n "assets/fonts/OFL.txt",} or die "could not enable OpenDyslexic asset copying\n"; write_file($webpack_path, $webpack); for my $manifest (qw(webapp.manifest.json manifest.json manifest.webapp)) { my $path = "$source_dir/src/$manifest"; next unless -f $path; my $content = read_file($path); $content =~ s{assets/img/glowing_bear_(?:60x60|90x90|128x128)\.png}{assets/neorelay-avatar-icon.png}g; $content =~ s{assets/img/favicon\.png}{assets/neorelay-avatar-icon.png}g; $content =~ s/"theme_color"\s*:\s*"#[0-9a-fA-F]+"/"theme_color": "#150a24"/g; $content =~ s/"background_color"\s*:\s*"#[0-9a-fA-F]+"/"background_color": "#07030d"/g; write_file($path, $content); } make_path("$source_dir/src/assets"); make_path("$source_dir/src/assets/fonts"); for my $font (qw( OpenDyslexic-Regular.woff2 OpenDyslexic-Italic.woff2 OpenDyslexic-Bold.woff2 OpenDyslexic-BoldItalic.woff2 OFL.txt )) { copy("$assets_dir/fonts/$font", "$source_dir/src/assets/fonts/$font") or die "copy OpenDyslexic asset $font: $!\n"; } copy("$assets_dir/avatar-login.jpg", "$source_dir/src/assets/neorelay-avatar-login.jpg") or die "copy login avatar: $!\n"; copy("$assets_dir/avatar-icon.png", "$source_dir/src/assets/neorelay-avatar-icon.png") or die "copy favicon: $!\n"; copy("$assets_dir/cyberpunk.css", "$source_dir/src/css/neorelay-cyberpunk.css") or die "copy cyberpunk stylesheet: $!\n"; copy("$assets_dir/neodrop.js", "$source_dir/src/js/imgur.js") or die "copy NeoDrop uploader: $!\n"; copy("$assets_dir/neodrop-crypto.js", "$source_dir/src/js/neodrop-crypto.js") or die "copy NeoDrop crypto: $!\n"; copy("$assets_dir/context-menu.js", "$source_dir/src/js/context-menu.js") or die "copy context menu: $!\n"; copy("$assets_dir/context-menu.html", "$source_dir/src/directives/context-menu.html") or die "copy context menu template: $!\n"; copy("$assets_dir/server-manager.js", "$source_dir/src/js/server-manager.js") or die "copy server manager: $!\n"; copy("$assets_dir/server-manager.html", "$source_dir/src/directives/server-manager.html") or die "copy server manager template: $!\n";