diff --git a/Makefile b/Makefile
index 4180af5a7..e4f23e97f 100644
--- a/Makefile
+++ b/Makefile
@@ -37,6 +37,7 @@ dist/iD.js: \
js/id/id.js \
js/id/services/*.js \
js/id/util.js \
+ js/id/util/*.js \
js/id/geo.js \
js/id/geo/*.js \
js/id/actions.js \
diff --git a/index.html b/index.html
index 3b387de5e..f36b434f7 100644
--- a/index.html
+++ b/index.html
@@ -34,7 +34,10 @@
+
+
+
diff --git a/js/id/core/history.js b/js/id/core/history.js
index d99cdf014..98f27a6d7 100644
--- a/js/id/core/history.js
+++ b/js/id/core/history.js
@@ -2,7 +2,7 @@ iD.History = function(context) {
var stack, index, tree,
imageryUsed = ['Bing'],
dispatch = d3.dispatch('change', 'undone', 'redone'),
- lock = false;
+ lock = iD.util.SessionMutex('lock');
function perform(actions) {
actions = Array.prototype.slice.call(actions);
@@ -273,39 +273,37 @@ iD.History = function(context) {
},
save: function() {
- if (!lock) return history;
- context.storage(getKey('lock'), null);
- context.storage(getKey('saved_history'), this.toJSON() || null);
+ if (lock.locked()) context.storage(getKey('saved_history'), history.toJSON() || null);
return history;
},
clearSaved: function() {
- if (!lock) return;
- context.storage(getKey('saved_history'), null);
+ if (lock.locked()) context.storage(getKey('saved_history'), null);
+ return history;
},
lock: function() {
- if (context.storage(getKey('lock'))) return false;
- context.storage(getKey('lock'), true);
- lock = true;
- return lock;
+ return lock.lock();
+ },
+
+ unlock: function() {
+ lock.unlock();
},
// is iD not open in another window and it detects that
// there's a history stored in localStorage that's recoverable?
restorableChanges: function() {
- return lock && !!context.storage(getKey('saved_history'));
+ return lock.locked() && !!context.storage(getKey('saved_history'));
},
// load history from a version stored in localStorage
restore: function() {
- if (!lock) return;
+ if (!lock.locked()) return;
var json = context.storage(getKey('saved_history'));
- if (json) this.fromJSON(json);
+ if (json) history.fromJSON(json);
context.storage(getKey('saved_history', null));
-
},
_getKey: getKey
diff --git a/js/id/ui.js b/js/id/ui.js
index eb48b6c8b..7b7f01714 100644
--- a/js/id/ui.js
+++ b/js/id/ui.js
@@ -126,6 +126,10 @@ iD.ui = function(context) {
return context.save();
};
+ window.onunload = function() {
+ context.history().unlock();
+ };
+
d3.select(window).on('resize.editor', function() {
map.dimensions(m.dimensions());
});
diff --git a/js/id/util/session_mutex.js b/js/id/util/session_mutex.js
new file mode 100644
index 000000000..2554b0114
--- /dev/null
+++ b/js/id/util/session_mutex.js
@@ -0,0 +1,36 @@
+// A per-domain session mutex backed by a cookie and dead man's
+// switch. If the session crashes, the mutex will auto-release
+// after 5 seconds.
+
+iD.util.SessionMutex = function(name) {
+ var mutex = {},
+ intervalID;
+
+ function renew() {
+ var expires = new Date();
+ expires.setSeconds(expires.getSeconds() + 5);
+ document.cookie = name + '=1; expires=' + expires.toUTCString();
+ }
+
+ mutex.lock = function() {
+ if (intervalID) return true;
+ var cookie = document.cookie.replace(new RegExp("(?:(?:^|.*;)\\s*" + name + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1");
+ if (cookie) return false;
+ renew();
+ intervalID = window.setInterval(renew, 4000);
+ return true;
+ };
+
+ mutex.unlock = function() {
+ if (!intervalID) return;
+ document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
+ clearInterval(intervalID);
+ intervalID = null;
+ };
+
+ mutex.locked = function() {
+ return !!intervalID;
+ };
+
+ return mutex;
+};
diff --git a/test/index.html b/test/index.html
index 4632e1760..0dda115a0 100644
--- a/test/index.html
+++ b/test/index.html
@@ -189,6 +189,8 @@
+
+
@@ -265,7 +267,9 @@
+
+
diff --git a/test/index_packaged.html b/test/index_packaged.html
index 9fe8137b2..5ecd80579 100644
--- a/test/index_packaged.html
+++ b/test/index_packaged.html
@@ -87,7 +87,9 @@
+
+
diff --git a/test/spec/core/history.js b/test/spec/core/history.js
index 115ef2bdd..ad68efff4 100644
--- a/test/spec/core/history.js
+++ b/test/spec/core/history.js
@@ -244,52 +244,6 @@ describe("iD.History", function () {
});
});
- describe("#lock", function() {
- it("acquires lock if possible", function() {
- expect(history.lock()).to.be.true;
- expect(history.lock()).to.be.false;
- });
- });
-
- describe("#save", function() {
- it("doesn't do anything if it doesn't have the lock", function() {
- var key = history._getKey('saved_history');
- context.storage(key, null);
- history.save();
- expect(context.storage(key)).to.be.null;
- context.storage(key, 'something');
- expect(context.storage(key)).to.equal('something');
- history.save();
- context.storage(key, null);
- });
-
- it("saves to localStorage", function() {
- var node = iD.Node({ id: 'n' });
- history.lock();
- history.perform(iD.actions.AddEntity(node));
- history.save();
- var saved = JSON.parse(context.storage(history._getKey('saved_history')));
- expect(saved.stack[1].modified[0]).to.eql('nv0');
- });
- });
-
- describe("#restore", function() {
- it("saves and restores a created and deleted entities", function() {
- var node = iD.Node({ id: 'n' }),
- node2 = iD.Node({ id: 'n2' });
- history.lock();
- history.perform(iD.actions.AddEntity(node));
- history.perform(iD.actions.AddEntity(node2));
- history.perform(iD.actions.DeleteNode('n2'));
- history.save();
- history.reset();
- expect(history.graph().hasEntity('n')).to.be.undefined
- history.restore();
- expect(history.graph().entity('n').id).to.equal('n');
- expect(history.graph().hasEntity('n2')).to.be.undefined;
- });
- });
-
describe("#toJSON", function() {
it("generates v2 JSON", function() {
var node = iD.Node({id: 'n-1'});
diff --git a/test/spec/util/session_mutex.js b/test/spec/util/session_mutex.js
new file mode 100644
index 000000000..ee9aa3bea
--- /dev/null
+++ b/test/spec/util/session_mutex.js
@@ -0,0 +1,94 @@
+describe("iD.util.SessionMutex", function() {
+ var clock, a, b;
+
+ beforeEach(function () {
+ clock = sinon.useFakeTimers(Date.now());
+ });
+
+ afterEach(function() {
+ clock.restore();
+ if (a) a.unlock();
+ if (b) b.unlock();
+ });
+
+ describe("#lock", function() {
+ it("returns true when it gets a lock", function() {
+ a = iD.util.SessionMutex('name');
+ expect(a.lock()).to.equal(true);
+ });
+
+ it("returns true when already locked", function() {
+ a = iD.util.SessionMutex('name');
+ a.lock();
+ expect(a.lock()).to.equal(true);
+ });
+
+ it("returns false when the lock is held by another session", function() {
+ a = iD.util.SessionMutex('name');
+ a.lock();
+
+ b = iD.util.SessionMutex('name');
+ expect(b.lock()).to.equal(false);
+ });
+ });
+
+ describe("#locked", function() {
+ it("returns false by default", function() {
+ a = iD.util.SessionMutex('name');
+ expect(a.locked()).to.equal(false);
+ });
+
+ it("returns true when locked", function() {
+ a = iD.util.SessionMutex('name');
+ a.lock();
+ expect(a.locked()).to.equal(true);
+ });
+
+ it("returns false when unlocked", function() {
+ a = iD.util.SessionMutex('name');
+ a.lock();
+ a.unlock();
+ expect(a.locked()).to.equal(false);
+ });
+ });
+
+ describe("#unlock", function() {
+ it("unlocks the mutex", function() {
+ a = iD.util.SessionMutex('name');
+ a.lock();
+ a.unlock();
+
+ b = iD.util.SessionMutex('name');
+ expect(b.lock()).to.equal(true);
+ });
+
+ it("does nothing when the lock is held by another session", function() {
+ a = iD.util.SessionMutex('name');
+ a.lock();
+
+ b = iD.util.SessionMutex('name');
+ b.unlock();
+
+ expect(a.locked()).to.equal(true);
+ });
+
+ it("does nothing when not locked", function() {
+ a = iD.util.SessionMutex('name');
+ a.unlock();
+ expect(a.locked()).to.equal(false);
+ });
+ });
+
+ it("namespaces locks", function() {
+ a = iD.util.SessionMutex('a');
+ a.lock();
+
+ b = iD.util.SessionMutex('b');
+ expect(b.locked()).to.equal(false);
+ expect(b.lock()).to.equal(true);
+ });
+
+ it("automatically unlocks when a session crashes", function() {
+ // Tested manually.
+ });
+});