From d2543eb499f2e7f1b51f1a15c02b2e56b04574fc Mon Sep 17 00:00:00 2001 From: lartsch Date: Fri, 18 Nov 2022 08:20:22 -0500 Subject: [PATCH] restructure folders, use promises for storage, restructure code --- README.md | 13 +- inject.js | 254 --------------------------- popup.js | 89 ---------- {icon => src/icon}/48.png | Bin src/inject.js | 248 ++++++++++++++++++++++++++ {lib => src/lib}/jquery-3.6.1.min.js | 0 manifest.json => src/manifest.json | 0 popup.html => src/popup.html | 2 +- src/popup.js | 81 +++++++++ 9 files changed, 339 insertions(+), 348 deletions(-) delete mode 100644 inject.js delete mode 100644 popup.js rename {icon => src/icon}/48.png (100%) create mode 100644 src/inject.js rename {lib => src/lib}/jquery-3.6.1.min.js (100%) rename manifest.json => src/manifest.json (100%) rename popup.html => src/popup.html (93%) create mode 100644 src/popup.js diff --git a/README.md b/README.md index f27f694..92e5f19 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # FediFollow-Chrome -A Chrome extension that simplifies following Fediverse/Mastodon users on other instances than your own by automatically redirecting you to your instance when pressing a follow button. The redirection is shortly indicated in the follow button itself and additional modals are blocked. Should work for other Chromium browsers too, as well as Kiwi browser on Android. It was tested with about 20 instances and supports different instance layouts/flavours. +A Chrome extension that simplifies following Mastodon users on other instances than your own by automatically redirecting you to your instance when pressing a follow button. The redirection is shortly indicated in the follow button itself and additional modals are blocked. Should work for other Chromium browsers too, as well as Kiwi browser on Android. It was tested with about 20 instances and supports different instance layouts/flavours. I made this since I could only find a working extension for Firefox, that does the same (Simplified Federation). @@ -22,11 +22,14 @@ Right now, you need to install it using developer mode. The extension is already ``` - Required: Hit "Submit" to update your settings -> **Note** -> 1. The whitelist mode can be useful if you do not want the extension to run basic checks on every site (since it needs to determine if it is a Mastodon site). Not sure if the blacklist feature is good for anything but I still included it. -> 2. It can have several reasons why a particular instance might not work: +> **Additional usage notes** +> 1. Currently supports different flavours of Mastodon 4 +> 2. If the redirect is not working, you most likely are not logged in on your home instance +> 3. The whitelist mode can be useful if you do not want the extension to run basic checks on every site (since it needs to determine if it is a Mastodon site). Not sure if the blacklist feature is good for anything but I still included it. +> 4. It can have several reasons why a particular instance might not work: > - There are instances that use custom layouts/flavours (additional identifiers need to be added to extension) > - Instance chose to hide the follow button when not logged in (not supported yet) +> - It's not a Mastodon instance (not supported yet) > - Element identifiers might change over time (extension needs to be updated) > > So please be aware, that this extension can fail in some cases. Feel free to submit pull requests / issues. @@ -36,7 +39,9 @@ Right now, you need to install it using developer mode. The extension is already ![Redirect Indication](https://github.com/lartsch/FediFollow-Chrome/blob/main/img/screenshot2.PNG?raw=true) ## Todos / Planned features +- Add support for Firefox - Add support for post interactions +- Add support for other implementations (Plemora, GNU Social, ...) - Publish to Chrome Webstore (in progress, currently in review by Google) - Find additional layouts/flavours to add identifiers for - Support for profiles views with follow button disabled diff --git a/inject.js b/inject.js deleted file mode 100644 index 904a58c..0000000 --- a/inject.js +++ /dev/null @@ -1,254 +0,0 @@ -// prep -const buttonPaths = ["div.account__header button.logo-button","div.public-account-header a.logo-button"]; -const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/; -const handleRegex = /^(?:https?:\/\/(www\.)?.*\..*?\/)(?@\w+(?:@\w+\.\w+)?)(?:\/?.*|\z)$/; -const enableConsoleLog = true; -const logPrepend = "[FediFollow]"; -const maxElementWaitFactor = 200; // x 100ms for total time - -var lastUrl = window.location.href; -var instance; -var showAlert; -var mode; -var whitelist; -var blacklist; -var target; - -// wrapper to prepend to log messages -function log(text) { - if (enableConsoleLog) { - console.log(logPrepend + ' ' + text) - } -} - -// function to wait for given elements to appear - first found element gets returned (but as of now the selectors are for different layouts anyways) -var waitForEl = function(counter, selectors, callback) { - var match = false; - // check all of the selectors - for (const selector of selectors) { - // if found - if ($(selector).length) { - // set match = true to prevent repetition hand over the found element - match = true; - callback(selector); - } - } - // repeat if no match was found and we did not exceed the wait factor yet - if (!match && counter < maxElementWaitFactor) { - setTimeout(function() { - // increase counter - waitForEl(counter + 1, selectors, callback); - }, 100); - } -}; - -// extract handle from any mastodon url -var extractHandle = function(url, callback) { - // regex with named match group - var match = url.match(handleRegex); - match = match.groups.handle - // check if match is valid - ats = (match.match(/@/g) || []).length; - if (!(match == null) && ats >= 1 && ats <= 2) { - // return the named match group - callback(match); - } -} - -// test if the current site should be processed or not -// this will also be the function for whitelist/blacklist feature -function checkSite() { - // is this site on our home instance? - if (document.domain == instance) { - log("Current site is your home instance."); - return false; - } - if (mode == "whitelist") { - if ($.inArray(document.domain, whitelist) < 0) { - log("Current site is not in whitelist."); - return false; - } - } else { - if ($.inArray(document.domain, blacklist) > -1) { - log("Current site is in blacklist."); - return false; - } - } - // check if the current site looks like Mastodon - $(document).ready(function() { - if (!($("head").text().includes("mastodon") || $("head").text().includes("Mastodon") || $("div#mastodon").length)) { - log("Could not find a reference that this is a Mastodon site.") - return false; - } - }); - return true; -} - -// main function to listen for the follow button pressed and open a new tab with the home instance -function processSite() { - // check if we have a handle in the url - if (window.location.href.includes("@")) { - // grab the user handle - extractHandle(window.location.href, function(handle) { - // if we got one... - if (handle) { - // wait until follow button appears (document is already ready, but most content is loaded afterwards) - waitForEl(0, buttonPaths, function(found) { - if (found) { - // setup the button click listener - $(found).click(function(e) { - // prevent default action and other handlers - e.preventDefault(); - e.stopImmediatePropagation(); - // check the alert setting and show it if set - if (showAlert) { - alert("Redirecting to "+instance); - } - // backup the button text - var originaltext = $(found).text(); - // replace the button text to indicate redirection - $(found).text("Redirecting..."); - // timeout 1000ms to make it possible to notice the redirection indication - setTimeout(function() { - // if more than 1 @, we have a domain in the handle - if ((handle.match(/@/g) || []).length > 1) { - // but if its our own... - if (handle.includes(instance)) { - // ...then we need to remove it - handle = "@"+ handle.split("@")[1]; - } - // request string - var request = 'https://'+instance+'/'+handle; - } else { - // with only 1 @, we have a local handle and need to append the domain - var request = 'https://'+instance+'/'+handle+'@'+document.domain; - } - // open the window - var win = window.open(request, target); - log("Redirected to " + request) - // focus the new tab if open was successfull - if (win) { - win.focus(); - } else { - // otherwise notify user... - log('Could not open new window. Please allow popups for this website.'); - } - // restore original button text - $(found).text(originaltext); - }, 1000); - }); - } else { - log("Could not find any follow button."); - } - }); - } else { - log("Could not find a handle."); - } - }); - } else { - log("No handle in this URL."); - } -} - -// for some reason, locationchange event did not work for me so lets use this ugly thing... -function urlChangeLoop() { - // run every 100ms, can probably be reduced - setTimeout(function() { - // compare last to current url - if (!(lastUrl == window.location.href)) { - // update lastUrl and run main script - lastUrl = window.location.href; - processSite(); - } - // repeat - urlChangeLoop(); - }, 300); -} - -function processDomainList(newLineList) { - // split by new line - var arrayFromList = newLineList.split(/\r?\n/); - // array to put checked domains into - var cleanedArray = []; - for (var domain of arrayFromList) { - // remove whitespace - domain = domain.trim(); - if (domainRegex.test(domain)) { - cleanedArray.push(domain) - } else { - log("Removed invalid domain " + domain + " from blacklist/whitelist.") - } - } - // return newly created set (remvoes duplicates) - return [...new Set(cleanedArray)];; -} - -function checkSettings() { - // if the home instance is undefined/null/empty - if (instance == null || !instance) { - log("Mastodon home instance is not set."); - return false; - } - // if the value looks like a domain... - if (!(domainRegex.test(instance))) { - log("Instance setting is not a valid domain name."); - return false; - } - // set default if no value - if ($.inArray(mode, ["blacklist","whitelist"]) < 0) { - mode = "blacklist"; - } - if ($.inArray(target, ["_blank","_self"]) < 0) { - target = "_blank"; - } - if (mode == "whitelist") { - // if in whitelist mode and the cleaned whitelist is empty, return false - whitelist = processDomainList(whitelist); - if (whitelist.length < 1) { - log("Whitelist is empty or invalid.") - return false; - } - } else { - // also process the blacklist if in blacklist mode, but an empty blacklist is OK so we do not return false - blacklist = processDomainList(blacklist); - } - return true; -} - -function run() { - // get the extension setting for the users' Mastadon home instance - chrome.storage.local.get(['fedifollow_homeinstance'], function(fetchedData) { - instance = fetchedData.fedifollow_homeinstance.trim(); - // and alert setting - chrome.storage.local.get(['fedifollow_alert'], function(fetchedData) { - showAlert = fetchedData.fedifollow_alert; - // mode - chrome.storage.local.get(['fedifollow_mode'], function(fetchedData) { - mode = fetchedData.fedifollow_mode; - // whitelist - chrome.storage.local.get(['fedifollow_whitelist'], function(fetchedData) { - whitelist = fetchedData.fedifollow_whitelist; - // blacklist - chrome.storage.local.get(['fedifollow_blacklist'], function(fetchedData) { - blacklist = fetchedData.fedifollow_blacklist; - chrome.storage.local.get(['fedifollow_target'], function(fetchedData) { - target = fetchedData.fedifollow_target; - if (checkSettings()) { - // check if the current URL should be processed - if (checkSite()) { - // ... run the actual script (once for the start and then in a loop depending on url changes) - processSite(); - urlChangeLoop(); - } else { - log("Will not process this URL.") - } - } - }); - }); - }); - }); - }); - }); -} - -run(); diff --git a/popup.js b/popup.js deleted file mode 100644 index ade29a8..0000000 --- a/popup.js +++ /dev/null @@ -1,89 +0,0 @@ -// prep -var currentVal_instance; -var currentVal_alert; -var currentVal_mode; -var currentVal_whitelist; -var currentVal_blacklist; -var currentVal_target; - -// this performs loading the settings into the popup, reacting to changes and saving changes -function popupTasks() { - $(document).ready(function() { - // set all default/configured values and show fields accordingly - $("input#homeinstance").val(currentVal_instance); - $("textarea#blacklist_content").val(currentVal_blacklist); - $("textarea#whitelist_content").val(currentVal_whitelist); - if (currentVal_alert){ - $("input#alert").prop('checked', true); - } else { - $("input#alert").prop('checked', false); - } - // we only set the mode if we have an actual value. otherwise the default should be left. - if (currentVal_mode) { - $("select#mode").val(currentVal_mode); - } - if (currentVal_target) { - $("select#target").val(currentVal_target); - } - // both containers are hidden by default - if ($("select#mode").val() == "whitelist") { - $("div#whitelist_input").show(); - } else { - $("div#blacklist_input").show(); - } - // check changes of the select to update whitelist/blacklist input - $("select#mode").change(function() { - if ($("select#mode").val() == "whitelist") { - $("div#blacklist_input").hide(); - $("div#whitelist_input").show(); - } else { - $("div#whitelist_input").hide(); - $("div#blacklist_input").show(); - } - }); - // perform storage actions on form submit - $("form#fedifollow-settings").on('submit', function(e){ - e.preventDefault(); - var newVal_instance = $("input#homeinstance").val(); - var newVal_alert = $("input#alert").is(':checked') - var newVal_mode = $("select#mode").val(); - var newVal_whitelist = $("textarea#whitelist_content").val(); - var newVal_blacklist = $("textarea#blacklist_content").val(); - var newVal_target = $("select#target").val(); - chrome.storage.local.set({fedifollow_homeinstance: newVal_instance}, function() { - chrome.storage.local.set({fedifollow_alert: newVal_alert}, function() { - chrome.storage.local.set({fedifollow_mode: newVal_mode}, function() { - chrome.storage.local.set({fedifollow_whitelist: newVal_whitelist}, function() { - chrome.storage.local.set({fedifollow_blacklist: newVal_blacklist}, function() { - chrome.storage.local.set({fedifollow_target: newVal_target}, function() { - $("span#indicator").show(); - }); - }); - }); - }); - }); - }); - }); - }); -} - -// read all settings, then run popupTasks() -chrome.storage.local.get(['fedifollow_homeinstance'], function(fetchedData) { - currentVal_instance = fetchedData.fedifollow_homeinstance; - chrome.storage.local.get(['fedifollow_alert'], function(fetchedData) { - currentVal_alert = fetchedData.fedifollow_alert; - chrome.storage.local.get(['fedifollow_mode'], function(fetchedData) { - currentVal_mode = fetchedData.fedifollow_mode; - chrome.storage.local.get(['fedifollow_whitelist'], function(fetchedData) { - currentVal_whitelist = fetchedData.fedifollow_whitelist; - chrome.storage.local.get(['fedifollow_blacklist'], function(fetchedData) { - currentVal_blacklist = fetchedData.fedifollow_blacklist; - chrome.storage.local.get(['fedifollow_target'], function(fetchedData) { - currentVal_target = fetchedData.fedifollow_target; - popupTasks() - }); - }); - }); - }); - }); -}); diff --git a/icon/48.png b/src/icon/48.png similarity index 100% rename from icon/48.png rename to src/icon/48.png diff --git a/src/inject.js b/src/inject.js new file mode 100644 index 0000000..b9c844d --- /dev/null +++ b/src/inject.js @@ -0,0 +1,248 @@ +// prep +const buttonPaths = ["div.account__header button.logo-button","div.public-account-header a.logo-button"]; +const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/; +const handleRegex = /^(?:https?:\/\/(www\.)?.*\..*?\/)(?@\w+(?:@\w+\.\w+)?)(?:\/?.*|\z)$/; +const enableConsoleLog = true; +const logPrepend = "[FediFollow]"; +const maxElementWaitFactor = 200; // x 100ms for total time + +var lastUrl = window.location.href; + +// settings keys with defauls +var settings = { + fedifollow_homeinstance: null, + fedifollow_alert: false, + fedifollow_mode: "blacklist", + fedifollow_whitelist: null, + fedifollow_blacklist: null, + fedifollow_target: "_blank" +} + +// wrappers to prepend to log messages +function log(text) { + if (enableConsoleLog) { + console.log(logPrepend + ' ' + text) + } +} +function logerr(error) { + if (enableConsoleLog) { + console.error(logPrepend + ' Error: ' + error) + } +} + +// function to wait for given elements to appear - first found element gets returned (but as of now the selectors are for different layouts anyways) +var waitForEl = function(counter, selectors, callback) { + var match = false; + // check all of the selectors + for (const selector of selectors) { + // if found + if ($(selector).length) { + // set match = true to prevent repetition hand over the found element + match = true; + callback(selector); + } + } + // repeat if no match was found and we did not exceed the wait factor yet + if (!match && counter < maxElementWaitFactor) { + setTimeout(function() { + // increase counter + waitForEl(counter + 1, selectors, callback); + }, 100); + } +}; + +// extract handle from any mastodon url +var extractHandle = function(url, callback) { + // regex with named match group + var match = url.match(handleRegex); + match = match.groups.handle + // check if match is valid + ats = (match.match(/@/g) || []).length; + if (!(match == null) && ats >= 1 && ats <= 2) { + // return the named match group + callback(match); + } +} + +function processDomainList(newLineList) { + // split by new line + var arrayFromList = newLineList.split(/\r?\n/); + // array to put checked domains into + var cleanedArray = []; + for (var domain of arrayFromList) { + // remove whitespace + domain = domain.trim(); + if (domainRegex.test(domain)) { + cleanedArray.push(domain) + } else { + log("Removed invalid domain " + domain + " from blacklist/whitelist.") + } + } + // return newly created set (remvoes duplicates) + return [...new Set(cleanedArray)];; +} + +function runWithSettings(settings) { + + function checkSettings() { + // if the home instance is undefined/null/empty + if (settings.fedifollow_homeinstance == null || !settings.fedifollow_homeinstance) { + log("Mastodon home instance is not set."); + return false; + } + // if the value looks like a domain... + if (!(domainRegex.test(settings.fedifollow_homeinstance))) { + log("Instance setting is not a valid domain name."); + return false; + } + // set default if wrong value + if ($.inArray(settings.fedifollow_mode, ["blacklist","whitelist"]) < 0) { + settings.fedifollow_mode = "blacklist"; + } + if ($.inArray(settings.fedifollow_target, ["_blank","_self"]) < 0) { + settings.fedifollow_target = "_blank"; + } + if (settings.fedifollow_mode == "whitelist") { + // if in whitelist mode and the cleaned whitelist is empty, return false + settings.fedifollow_whitelist = processDomainList(settings.fedifollow_whitelist); + if (settings.fedifollow_whitelist.length < 1) { + log("Whitelist is empty or invalid.") + return false; + } + } else { + // also process the blacklist if in blacklist mode, but an empty blacklist is OK so we do not return false + settings.fedifollow_blacklist = processDomainList(settings.fedifollow_blacklist); + } + return true; + } + + // main function to listen for the follow button pressed and open a new tab with the home instance + function processSite() { + // check if we have a handle in the url + if (window.location.href.includes("@")) { + // grab the user handle + extractHandle(window.location.href, function(handle) { + // if we got one... + if (handle) { + // wait until follow button appears (document is already ready, but most content is loaded afterwards) + waitForEl(0, buttonPaths, function(found) { + if (found) { + // setup the button click listener + $(found).click(function(e) { + // prevent default action and other handlers + e.preventDefault(); + e.stopImmediatePropagation(); + // check the alert setting and show it if set + if (settings.fedifollow_alert) { + alert("Redirecting to "+settings.fedifollow_homeinstance); + } + // backup the button text + var originaltext = $(found).text(); + // replace the button text to indicate redirection + $(found).text("Redirecting..."); + // timeout 1000ms to make it possible to notice the redirection indication + setTimeout(function() { + // if more than 1 @, we have a domain in the handle + if ((handle.match(/@/g) || []).length > 1) { + // but if its our own... + if (handle.includes(settings.fedifollow_homeinstance)) { + // ...then we need to remove it + handle = "@"+ handle.split("@")[1]; + } + // request string + var request = 'https://'+settings.fedifollow_homeinstance+'/'+handle; + } else { + // with only 1 @, we have a local handle and need to append the domain + var request = 'https://'+settings.fedifollow_homeinstance+'/'+handle+'@'+document.domain; + } + // open the window + var win = window.open(request, settings.fedifollow_target); + log("Redirected to " + request) + // focus the new tab if open was successfull + if (win) { + win.focus(); + } else { + // otherwise notify user... + log('Could not open new window. Please allow popups for this website.'); + } + // restore original button text + $(found).text(originaltext); + }, 1000); + }); + } else { + log("Could not find any follow button."); + } + }); + } else { + log("Could not find a handle."); + } + }); + } else { + log("No handle in this URL."); + } + } + + // test if the current site should be processed or not + // this will also be the function for whitelist/blacklist feature + function checkSite() { + // is this site on our home instance? + if (document.domain == settings.fedifollow_homeinstance) { + log("Current site is your home instance."); + return false; + } + if (settings.fedifollow_mode == "whitelist") { + if ($.inArray(document.domain, settings.fedifollow_whitelist) < 0) { + log("Current site is not in whitelist."); + return false; + } + } else { + if ($.inArray(document.domain, settings.fedifollow_blacklist) > -1) { + log("Current site is in blacklist."); + return false; + } + } + // check if the current site looks like Mastodon + $(document).ready(function() { + if (!($("head").text().includes("mastodon") || $("head").text().includes("Mastodon") || $("div#mastodon").length)) { + log("Could not find a reference that this is a Mastodon site.") + return false; + } + }); + return true; + } + + // for some reason, locationchange event did not work for me so lets use this ugly thing... since it calls processSite, it needs to be in runWithSettings as well + function urlChangeLoop() { + // run every 100ms, can probably be reduced + setTimeout(function() { + // compare last to current url + if (!(lastUrl == window.location.href)) { + // update lastUrl and run main script + lastUrl = window.location.href; + processSite(); + } + // repeat + urlChangeLoop(); + }, 300); + } + + // check and process settings + if (checkSettings()) { + // check if the current URL should be processed + if (checkSite()) { + // ... run the actual script (once for the start and then in a loop depending on url changes) + processSite(); + urlChangeLoop(); + } else { + log("Will not process this URL.") + } + } + +} + +function loadSettings() { + const waitForSettings = (chrome || browser).storage.local.get(settings); + waitForSettings.then(runWithSettings, logerr); +} + +loadSettings(); diff --git a/lib/jquery-3.6.1.min.js b/src/lib/jquery-3.6.1.min.js similarity index 100% rename from lib/jquery-3.6.1.min.js rename to src/lib/jquery-3.6.1.min.js diff --git a/manifest.json b/src/manifest.json similarity index 100% rename from manifest.json rename to src/manifest.json diff --git a/popup.html b/src/popup.html similarity index 93% rename from popup.html rename to src/popup.html index ebef58e..ca36e7a 100644 --- a/popup.html +++ b/src/popup.html @@ -11,7 +11,7 @@
-
+



diff --git a/src/popup.js b/src/popup.js new file mode 100644 index 0000000..ee3fe3d --- /dev/null +++ b/src/popup.js @@ -0,0 +1,81 @@ + +// settings keys with defauls +var settings = { + fedifollow_homeinstance: null, + fedifollow_alert: false, + fedifollow_mode: "blacklist", + fedifollow_whitelist: null, + fedifollow_blacklist: null, + fedifollow_target: "_blank" +} + +function onError(error){ + console.error(`[FediFollow] Error: ${error}`) +} + +// this performs loading the settings into the popup, reacting to changes and saving changes +function popupTasks(settings) { + + function showConfirmation() { + $("span#indicator").show(); + setTimeout(function() { + $("span#indicator").hide(); + }, 1500); + } + + function updateSettings(){ + // update settings values + settings.fedifollow_homeinstance = $("input#homeinstance").val().trim(); + settings.fedifollow_alert = $("input#alert").is(':checked'); + settings.fedifollow_mode = $("select#mode").val(); + settings.fedifollow_whitelist = $("textarea#whitelist_content").val(); + settings.fedifollow_blacklist = $("textarea#blacklist_content").val(); + settings.fedifollow_target = $("select#target").val(); + // write to storage + const waitForSaved = (chrome || browser).storage.local.set(settings); + // show saved indicator after successful save + waitForSaved.then(showConfirmation(), onError); + } + + function restoreForm() { + // set all default/configured values and show fields accordingly + $("input#homeinstance").val(settings.fedifollow_homeinstance); + $("textarea#blacklist_content").val(settings.fedifollow_blacklist); + $("textarea#whitelist_content").val(settings.fedifollow_whitelist); + $("select#mode").val(settings.fedifollow_mode); + $("select#target").val(settings.fedifollow_target); + $("input#alert").prop('checked', settings.fedifollow_alert); + // both containers are hidden by default + if ($("select#mode").val() == "whitelist") { + $("div#whitelist_input").show(); + } else { + $("div#blacklist_input").show(); + } + // check changes of the select to update whitelist/blacklist input + $("select#mode").change(function() { + if ($("select#mode").val() == "whitelist") { + $("div#blacklist_input").hide(); + $("div#whitelist_input").show(); + } else { + $("div#whitelist_input").hide(); + $("div#blacklist_input").show(); + } + }); + } + + $(document).ready(function() { + // restore the form values + restoreForm(); + // perform storage actions on form submit + $("form#fedifollow-settings").on('submit', function(e){ + // prevent default + e.preventDefault(); + // update settings + updateSettings(); + }); + }); + +} + +const waitForSettings = (chrome || browser).storage.local.get(settings); +waitForSettings.then(popupTasks, onError); \ No newline at end of file