Running userscripts on iOS: a workaround

Hi,

for a while now, I’ve been looking for a way to run userscripts on iOS without needing an Apple Developer account. I found a not so nice workaround today but I thought I’d share it and see if anyone finds this useful.


Some Background
(You can skip this part)
I just love the Anki mode – I know a lot of people have a different opinion but I can’t live without it. I made a modified version of the original script with buttons so that it can be used on mobile devices. At first, I was using it on a secondary Android device that I had to carry around just for this purpose, but at least it was working ([iOS] Mobile AlliCrab for WaniKani).

Then I got tired of carrying around my Android device and instead made a customized AlliCrab build that I deployed to my iPhone via Xcode ([iOS] Mobile AlliCrab for WaniKani). However, my Apple Developer license ran out and 1.)

Now, I just got back into WaniKani, my Apple Developer account has run out and 1.) I didn’t feel like renewing it and 2.) I had always wanted to find a way that would let everyone without a developer license add their own scripts.
The current state is far from perfect and there is none of the fancy functionality that AlliCrab has but after a while of butchering @cplaverty’s great app while trying to make it work another way, I just gave up and started something super simple from scratch.


Restrictions
(aka: the bad news)

  • You will have to install Xcode which means you need a MacBook
  • If you don’t have a Apple Developer account, you will have to rebuild the application every week (this is at least what I’ve read online, I just started using this today so I have no practical experience)
  • Not sure about which OS version is required; I’m using the most recent macOS and iOS
  • This currently works only for reviews, there is some problem on the lessons page and for today, I ran out of time that I can invest into this. If people are interested in this topic, I’m sure this could be fixed.

How To
I’m assuming that every developer will be able to get it working based on this description. If people are interested in this, I could try and make a Xcode project available and provide some better explanations if necessary. But for today, I wanted to start small.

That being said, here we go:

  • Install Xcode
  • Create a new project, type: “Single View App”
  • Right click on the root folder and select “New Group” and call it e.g. “UserScripts”; the group name does not seem to matter. (You could in theory skip this step but just the thought of having everything in one folder makes me nervous.)
  • Right click on this group and select “Add Files to ”. Add all the JS files you want to use. The result should look something like this:

Edit ViewController.swift to look like this. Add your own scripts to enabledScripts:


import UIKit
import WebKit
import JavaScriptCore

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let contentController = WKUserContentController()
      
       //add or remove your own scripts here; make sure to not include the ".js" part of the file name
        let enabledScripts = [
                              "Wanikani_Open_Framework",
                              "Wanikani_Ankimode_with_Buttons",
                              "Wanikani_Reorder_Ultimate_2",
                              "Wanikani_Celebrate_Level_Up",
                              "WaniKani_Always_Show_Item_Info",
                              "Wanikani_Ultimate_Timeline",
                              "WaniKani_Stroke_Order",
                              "Wanikani_Review_SRS_Level_Indicator",
                              
                              //lesson mode currently broken; might have to inject scripts in a smarter way based on window.location.href?
                                //"Wanikani_Lesson_Filter",
                                //"WaniKani_Lesson_Quiz_Auto_Complete"
                              ]

        enabledScripts.forEach { item in
            print(item)
            
            guard let scriptPath = Bundle.main.path(forResource: item, ofType: "js"),
            let scriptSource = try? String(contentsOfFile: scriptPath) else { return }
            let userScript = WKUserScript(source: scriptSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
            contentController.addUserScript(userScript)
            
        }
        
        
        let config = WKWebViewConfiguration()
        config.userContentController = contentController
        let webView = WKWebView(frame: .zero, configuration: config)
        view.addSubview(webView)
        
        let layoutGuide = view.safeAreaLayoutGuide
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor).isActive = true
        webView.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor).isActive = true
        webView.topAnchor.constraint(equalTo: layoutGuide.topAnchor).isActive = true
        webView.bottomAnchor.constraint(equalTo: layoutGuide.bottomAnchor).isActive = true
        
        if let url = URL(string: "https://www.wanikani.com/review/session") {
            webView.load(URLRequest(url: url))
        }
    }
}

The app should then show up on your device:

Note: I have never really used Xcode or Swift so for anyone who is more familiar with this please don’t roll your eyes too hard at me :slight_smile:


Some of the scripts that I added

Wanikani_Open_Framework.js
No changes necessary. So best to get the most recent version from Wanikani Open Framework

Wanikani_Reorder_Ultimate_2.js
My post will get too long if I paste this here so I’m just describing my changes:

Had to comment out GM_info here:

        ui: {
            create: function() {
                setup.update.apply();
                $('head').append('<style>'+ui_css+'</style>');
                utilities.log("Creating UI...");
                var info = $('#supplement-info, #information').first();
                info.after(ui_html);
 
                //START: irrelephant modification for iOS
                // $('#version').text("v" + GM_info.script.version);
                //END: irrelephant modification for iOS

I prefer the script being collapsed per default to take up less space:

                $('.icon-minus, .icon-plus').click(function() {
                    $('.ui, .ui-small').toggleClass('hidden');
                });
 
                 //START: irrelephant modification for iOS
                 // save some space by collapsing elements per default
                $(".icon-minus").click();
                 //END: irrelephant modification for iOS
...

Wanikani_Ankimode_with_Buttons.js

// ==UserScript==
// @name         Wanikani Anki Mode (irrelephant mod with buttons)
// @namespace    irrelephant
// @version      1.6.1
// @description  Anki mode for Wanikani; modified to show Anki buttons below character & answer field so that your hand doesn't hide that information. Uses two states for the button: either one large "Show Answer" button or two "Know"/"Don't Know" buttons so that you don't have to move your finger anywhere in case you got an answer correct.
// @author       irrelephant
// @match        https://www.wanikani.com/review/session*
// @match        http://www.wanikani.com/review/session*
// @grant        none
// @license      GPL version 3 or any later version; http://www.gnu.org/copyleft/gpl.html
// ==/UserScript==

//BASED ON:
//Wanikani Anki Mode by Mempo and modifications by necul, see https://community.wanikani.com/t/userscripts-on-android-browser/19113
//Original author: Oleg Grishin <og402@nyu.edu>

console.log('/// Start of Wanikani Anki Mode');


// Save the original evaluator
var originalChecker = answerChecker.evaluate;

var checkerYes = function (itemType, correctValue) {
    return {accurate : !0, passed: !0};
}

var checkerNo = function (itemType, correctValue) {
    return {accurate : !0, passed: 0};
}

var activated = false;
var answerShown = false;

//AUTOSTART
var autostart = false;


MutationObserver = window.MutationObserver || window.WebKitMutationObserver;

var observer = new MutationObserver(function(mutations, observer) {
    $("#user-response").blur();
});

 var WKANKIMODE_toggle = function () {

    if (activated) {
        if(autostart){
            //DISABLE ANKI MODE
            $("#WKANKIMODE_anki").text("Anki Mode Off");
            $("#answer-form form button").prop("disabled", false);
            $("#user-response").off("focus");
            $("#user-response").focus();

            answerChecker.evaluate = originalChecker;
            observer.disconnect();

            localStorage.setItem("WKANKI_autostart", false);
            activated = false;
            autostart = false;
            console.log("back to #1");


        }else{
            //ENABLE AUTOSTART
            activated = true;
            autostart = true;
            localStorage.setItem("WKANKI_autostart", true);

            $("#WKANKIMODE_anki").text("Anki Mode Auto Start");

            // start observer to force blur
            observer.observe(document.getElementById("answer-form"), {
                childList: true,
                subtree: true,
                attributes: true,
                characterData: false
            });
        }





    } else {
        //ENABLE ANKI MODE
        $("#WKANKIMODE_anki").text("Anki Mode On");
        $("#answer-form form button").prop("disabled", true);
        $("#user-response").on("focus", function () {
            $("#user-response").blur();
        });
        activated = true;
        autostart = false;
        // start observer to force blur
        observer.observe(document.getElementById("answer-form"), {
            childList: true,
            subtree: true,
            attributes: true,
            characterData: false
        });
    }

}


var WKANKIMODE_hideAnswerButtons = function(){
    $(".WKANKIMODE_button.correct").hide();
    $(".WKANKIMODE_button.incorrect").hide();
    $(".WKANKIMODE_button.show").show();
}

var WKANKIMODE_showAnswerButtons = function(){
    $(".WKANKIMODE_button.correct").show();
    $(".WKANKIMODE_button.incorrect").show();
    $(".WKANKIMODE_button.show").hide();
}

 var WKANKIMODE_showAnswer = function () {
    if (!$("#answer-form form fieldset").hasClass("correct") &&
        !$("#answer-form form fieldset").hasClass("incorrect") &&
        !answerShown ) {
        var currentItem = $.jStorage.get("currentItem");
        var questionType = $.jStorage.get("questionType");
        if (questionType === "meaning") {
            var answer = currentItem.en.join(", ");
            if (currentItem.syn.length) {
                answer += " (" + currentItem.syn.join(", ") + ")";
            }
            $("#user-response").val(answer);
        } else { //READING QUESTION
            var i = 0;
            var answer = "";
            if (currentItem.voc) {
                answer += currentItem.kana[0];

            } else if (currentItem.emph == 'kunyomi') {
                answer += currentItem.kun[0];
            } else if (currentItem.emph == 'nanori') {
                answer += currentItem.nanori[0];
            } else {
                answer += currentItem.on[0];
            }
            $("#user-response").val(answer);
        }
        answerShown = true;
        WKANKIMODE_showAnswerButtons();
    }
};

 var WKANKIMODE_answerYes = function () {
    if (answerShown) {
        answerChecker.evaluate = checkerYes;
        $("#answer-form form button").click();
        answerShown = false;
        answerChecker.evaluate = originalChecker;
        return;
    }

    // if answer is shown, press '1' one more time to go to next
    if ($("#answer-form form fieldset").hasClass("correct") ||
        $("#answer-form form fieldset").hasClass("incorrect") ) {
        $("#answer-form form button").click();
        WKANKIMODE_hideAnswerButtons();
    }

};

var WKANKIMODE_answerNo = function () {
    if (answerShown) {
        answerChecker.evaluate = checkerNo;
        $("#answer-form form button").click();
        answerShown = false;
        answerChecker.evaluate = originalChecker;
        return;
    }

    if ($("#answer-form form fieldset").hasClass("correct") ||
        $("#answer-form form fieldset").hasClass("incorrect") ) {
        $("#answer-form form button").click();
        WKANKIMODE_hideAnswerButtons();
    }

};


    /*jshint multistr: true */
    var css = "\
        #WKANKIMODE_anki { \
            display:none; \
            background-color: #000099; \
            margin: 0 5px; \
        } \
        #WKANKIMODE_yes { \
            background-color: #009900; \
            margin: 0 0 0 5px; \
        } \
        #WKANKIMODE_no { \
            background-color: #990000; \
        } \
        .WKANKIMODE_button { \
            width: 100%; \
            display: inline-block; \
            text-align:center; \
            font-size: 0.8125em; \
            color: #FFFFFF; \
            cursor: pointer; \
            padding: 15px 0; \
            margin-bottom: 5px; \
        } \
         .WKANKIMODE_buttons { \
            display: inline-block; \
            width:100%; \
        } \
        .WKANKIMODE_buttons .incorrect { \
            background-color: #990000; \
        } \
    .WKANKIMODE_buttons .correct { \
            background-color: #009900; \
        } \
    .WKANKIMODE_buttons .show { \
        background-color: #000099; \
        width:100%; margin-bottom: 55px; \
        } \
    #WKANKIMODE_anki.hidden { \
        display: none; \
        } \
@media only screen \
and (min-device-width : 768px) \
and (max-device-width : 1024px)  { \
   .WKANKIMODE_button { \
        padding: 50px 0; \
    } \
} \
";



function addStyle(aCss) {
  var head, style;
  head = document.getElementsByTagName('head')[0];
  if (head) {
    style = document.createElement('style');
    style.setAttribute('type', 'text/css');
    style.textContent = aCss;
    head.appendChild(style);
    return style;
  }
  return null;
}

var addButtons = function () {
    //CHECK AUTOSTART
    autostart = localStorage.getItem('WKANKI_autostart')==="true"?true:false;

    $("<div />", {
                id : "WKANKIMODE_anki",
                title : "Anki Mode",
    })
    .text("Anki Mode Off")
    .addClass("WKANKIMODE_button")
    .on("click", WKANKIMODE_toggle)
    .prependTo("footer");


    $("<div />", {
                id : "WKANKIMODE_buttons"
    })
    .addClass("WKANKIMODE_buttons")
    .appendTo("#answer-form");

     $("<div />", {
                id : "WKANKIMODE_anki_incorrect",
                title : "Shortcut: L",
    })
    .text("Don't know")
    .addClass("WKANKIMODE_button incorrect")
    .on("click", WKANKIMODE_answerNo)
    .prependTo("#WKANKIMODE_buttons");

        $("<div />", {
                id : "WKANKIMODE_anki_show",
                title : "Shortcut: Space",
    })
    .text("Show Answer")
    .addClass("WKANKIMODE_button show")
    .on("click", WKANKIMODE_showAnswer)
    .prependTo("#WKANKIMODE_buttons");

$("<div />", {
                id : "WKANKIMODE_anki_correct",
                title : "Shortcut: K",
    })
    .text("Know")
    .addClass("WKANKIMODE_button correct")
    .on("click", WKANKIMODE_answerYes)
    .prependTo("#WKANKIMODE_buttons");

    // TO-DO
    // add physical buttons to press yes/no/show answer

    // var yesButton = "<div id='WKANKIMODE_yes' class='WKANKIMODE_button' title='Correct' onclick='WKANKIMODE_correct();'>Correct</div>";
    // var noButton = "<div id='WKANKIMODE_no' class='WKANKIMODE_button' title='Incorrect' onclick='WKANKIMODE_incorrect();'>Incorrect</div>";

    // $("footer").prepend($(noButton).hide());
    // $("footer").prepend($(yesButton).hide());

};

var autostartFeature = function() {
    console.log("///////////// AUTOSTART: " + autostart);
    if(autostart){
        $("#WKANKIMODE_anki").text("Anki Mode Auto Start");
        $("#answer-form form button").prop("disabled", true);
        $("#user-response").on("focus", function () {
            $("#user-response").blur();
        });
        activated = true;
        // start observer to force blur
        observer.observe(document.getElementById("answer-form"), {
            childList: true,
            subtree: true,
            attributes: true,
            characterData: false
        });
    }
}

var bindHotkeys = function () {
    $(document).on("keydown.reviewScreen", function (event)
        {
            if ($("#reviews").is(":visible") && !$("*:focus").is("textarea, input"))
            {
                switch (event.keyCode) {
                    //key: Space
                    case 32:
                        event.stopPropagation();
                        event.preventDefault();

                        if (activated)
                            WKANKIMODE_showAnswer();

                        return;
                        break;
                    //key: "K" (like "oKAY, I got this")
                    case 75:
                        event.stopPropagation();
                        event.preventDefault();

                        if (activated)
                            WKANKIMODE_answerYes();

                        return;
                        break;
                    //key: "L" like "loser" (sorry, you are not a loser! It's all for the sake of the mnemonics)
                    case 76:

                        event.stopPropagation();
                        event.preventDefault();

                        if (activated)
                            WKANKIMODE_answerNo();

                        return;
                        break;
                }
            }
        });
};



console.log("window.location.href is "+window.location.href);
if(window.location.href.indexOf("review") > -1) {
    console.log("initializing anki mode");
    addStyle(css);
    addButtons();
    //anki mode is always on => easiest on iOS and saves space
    autostartFeature();
    bindHotkeys();
    WKANKIMODE_hideAnswerButtons();
}else{
    console.log("Not on reviews page => not initializing anki mode");
}



Debugging
If you want to see console.log statements:

  • On your MacBook: Safari > Preferences > Advanced: Enable “Show Develop menu in menu bar”
  • Start the app via Xcode in the simulator
  • In the Safari Develop menu you will see something like "Simulator - iPhone… " and can select the wanikani session there to connect the debugger and see the console output

I hope this is helpful for someone else.

I just did this today so I don’t know how annoying it will be to rebuild this once per week. But I’d do anything for the Anki mode :wink:

7 Likes

(I’m not an iOS user)

Not to disrespect all your hard work, but are you aware of this app? o:

Idk if it has Anki mode in it, but you only mentioned AlliCrab, that’s why I’m wondering :slight_smile:

2 Likes

Unfortunately it does not have an Anki mode. I made a request for it a while back in that thread and if it ever happens, I’m switching to it. Most people seem to be a fan of typing which I can completely understand but it is just not right for me.

1 Like

You can also use Cydia Impactor to resign apps, which works on Windows/macOS/Linux. I used to use it back when I jailbroke my devices but it can be used to install and sign other apps too

I now regret deleting my Hackintosh install :</small>

1 Like

What is the Anki mode?

You can see it in the screenshot: you will have buttons to say if you know or don’t know the answer, like you would in Anki.

Obviously there is the temptation to cheat and it will be slightly worse for memorization but you can speed up things significantly this way. And lots of people are using Anki successfully so my assumption is that it’s not that bad (and I‘d rather do my reviews this way than not at all).

I’ve been waiting a couple years now for anki mode on iOS… this looks like a pain to keep working, but I really appreciate you trying. I too would love to see it included in Tsurukame! Being able to do one-handed reviews on the train would be fantastic.

@davidsansome

1 Like

Exactly, I just finished my 100 reviews on the train and without the Anki mode I probably would not have even bothered.

I think it is not too bad to run it once a week but the setup was of course annoying. I could make a cleanep up Xcode project available to make this easier but that will take a bit more extra time and I just didn’t want to do it yet if no one is interested.