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))
}
}
}
-
You will need an Apple ID but since you have a MacBook, you already have one; you set this Apple ID up in Xcode > Preferences > Account and then select it as a “Team” in the project code signing options (see Deploying to a Device without an Apple Developer Account - Ionic Blog)
-
Where to change the app name that is displayed in iOS:
-
How to add an app icon easily, using “Icon Set Creator”: xcode - iOS how to set app icon and launch images - Stack Overflow
-
When you are done, you can either run this in the simulator or on your actual device. Connect your device to your MacBook, unlock it and I think it should automatically become available as a build target. You will have to trust the developer certificate on your iOS device in Settings > General > Device Management (also described here: Deploying to a Device without an Apple Developer Account - Ionic Blog )
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
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