Skip to content

Commit 5ef5802

Browse files
committed
[FIX] web_dark_mode Fix device dependent
Previously, device dependent was strictly frontend and was set only if the color_scheme cookie had not already been set. This meant that it was useless unless you cleared your cookies. This fix implements two separete methods of detecting device preference: 1) Server side Adds request for Sec-CH-Prefers-Color-Scheme hint and uses it to serve up the correct asset bundle on the first load. This is mostly just for Chromium browsers. 2) Client side If Sec-CH-Prefers-Color-Scheme is None, client side js will check user.settings.dark_mode_device_dependent which, thanks to moving these prefs to res_users_settings is already loaded without a separate orm call, if true it will then check device preference and current cookie. If resetting the cookie is required, it will then trigger a reload. This is, of course, very bad, as it causes flicker and loads the assets twice, but thankfully it ony happens the first time you login or enable device dependent, or if you switch your device's system theme. This only applies to Firefox, Safari, or to Chromium browsers if the user has enabled anti fingerprinting settings. Sec-CH-Prefers-Color-Scheme docs are here: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-CH-Prefers-Color-Scheme
1 parent 5a4f288 commit 5ef5802

File tree

8 files changed

+241
-56
lines changed

8 files changed

+241
-56
lines changed

web_dark_mode/models/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# © 2022 Florian Kantelberg - initOS GmbH
22
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
33

4-
from . import ir_http, res_users
4+
from . import ir_http, res_users, res_users_settings

web_dark_mode/models/ir_http.py

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# © 2022 Florian Kantelberg - initOS GmbH
2+
# © 2026 Liam Noonan - Pyxiris
23
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
34

45
from odoo import models
@@ -9,22 +10,55 @@ class IrHttp(models.AbstractModel):
910
_inherit = "ir.http"
1011

1112
def color_scheme(self):
12-
scheme = request.httprequest.cookies.get("color_scheme")
13-
if scheme:
14-
return scheme
13+
target_scheme, existing_scheme = self._get_color_scheme()
14+
if target_scheme:
15+
return target_scheme
16+
elif existing_scheme:
17+
return existing_scheme
1518
else:
16-
return "light"
19+
return super().color_scheme()
1720

1821
@classmethod
19-
def _set_color_scheme(cls, response):
20-
scheme = request.httprequest.cookies.get("color_scheme")
22+
def _get_color_scheme(cls):
2123
user = request.env.user
22-
user_scheme = "dark" if getattr(user, "dark_mode", None) else "light"
23-
device_dependent = getattr(user, "dark_mode_device_dependent", None)
24-
if (not device_dependent) and scheme != user_scheme:
25-
response.set_cookie("color_scheme", user_scheme)
24+
existing_scheme_cookie = None
25+
target_scheme = None
26+
27+
if user and user._is_internal():
28+
# Existing
29+
existing_scheme_cookie = request.httprequest.cookies.get("color_scheme")
30+
browser_preference_header = request.httprequest.headers.get(
31+
"Sec-CH-Prefers-Color-Scheme"
32+
)
33+
browser_scheme = (
34+
browser_preference_header
35+
if browser_preference_header in ("dark", "light")
36+
else None
37+
)
38+
# User preference
39+
user_scheme = "dark" if getattr(user, "dark_mode", None) else "light"
40+
user_device_dependant_scheme = getattr(
41+
user, "dark_mode_device_dependent", None
42+
)
43+
44+
if user_device_dependant_scheme:
45+
if browser_scheme and existing_scheme_cookie != browser_scheme:
46+
target_scheme = browser_scheme
47+
48+
elif existing_scheme_cookie != user_scheme:
49+
target_scheme = user_scheme
50+
51+
return target_scheme, existing_scheme_cookie
52+
53+
@classmethod
54+
def _set_color_scheme(cls, response):
55+
target_scheme, _ = cls._get_color_scheme()
56+
if target_scheme:
57+
response.set_cookie("color_scheme", target_scheme)
2658

2759
@classmethod
2860
def _post_dispatch(cls, response):
2961
cls._set_color_scheme(response)
62+
response.headers.add("Vary", "Sec-CH-Prefers-Color-Scheme")
63+
response.headers.add("Accept-CH", "Sec-CH-Prefers-Color-Scheme")
3064
return super()._post_dispatch(response)

web_dark_mode/models/res_users.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# © 2022 Florian Kantelberg - initOS GmbH
2+
# © 2026 Liam Noonan - Pyxiris
23
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
34

45

@@ -8,8 +9,14 @@
89
class ResUsers(models.Model):
910
_inherit = "res.users"
1011

11-
dark_mode = fields.Boolean()
12-
dark_mode_device_dependent = fields.Boolean("Device Dependent Dark Mode")
12+
dark_mode = fields.Boolean(
13+
related="res_users_settings_id.dark_mode", readonly=False
14+
)
15+
dark_mode_device_dependent = fields.Boolean(
16+
related="res_users_settings_id.dark_mode_device_dependent",
17+
readonly=False,
18+
string="Device Dependent Dark Mode",
19+
)
1320

1421
@property
1522
def SELF_READABLE_FIELDS(self):
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# © 2022 Florian Kantelberg - initOS GmbH
2+
# © 2026 Liam Noonan - Pyxiris
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
5+
from odoo import fields, models
6+
7+
8+
class ResUsersSettings(models.Model):
9+
_inherit = "res.users.settings"
10+
11+
# These fields should be here in order to be accessible via in js
12+
# as user.settings.dark_mode, etc.
13+
dark_mode = fields.Boolean()
14+
dark_mode_device_dependent = fields.Boolean()

web_dark_mode/static/src/js/switch_item.esm.js

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,23 @@ export const colorSchemeService = {
2323
dependencies: ["orm", "ui"],
2424

2525
async start(env, {orm, ui}) {
26-
registry.category("user_menuitems").add("darkmode", darkModeSwitchItem);
27-
28-
if (!cookie.get("color_scheme")) {
29-
const match_media = window.matchMedia("(prefers-color-scheme: dark)");
30-
const dark_mode = match_media.matches;
31-
cookie.set("color_scheme", dark_mode ? "dark" : "light");
32-
if (dark_mode) browser.location.reload();
26+
// This is only for browsers like Firefox and Safari that do not support
27+
// Sec-CH-Prefers-Color-Scheme. Browsers that do support it will have already
28+
// set the correct cookie value on first request due to server side handling
29+
// in ir.http.
30+
if (user.settings.dark_mode_device_dependent === true) {
31+
const device_preference = window.matchMedia("(prefers-color-scheme: dark)")
32+
.matches
33+
? "dark"
34+
: "light";
35+
if (cookie.get("color_scheme") !== device_preference) {
36+
cookie.set("color_scheme", device_preference);
37+
// This will cause a bit of flicker as odoo loads the wrong assets
38+
// before it can determine the browser system color scheme.
39+
browser.location.reload();
40+
}
41+
} else {
42+
registry.category("user_menuitems").add("darkmode", darkModeSwitchItem);
3343
}
3444

3545
return {

web_dark_mode/tests/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# © 2022 Florian Kantelberg - initOS GmbH
22
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
33

4-
from . import test_dark_mode
4+
from . import test_ir_http

web_dark_mode/tests/test_dark_mode.py

Lines changed: 0 additions & 35 deletions
This file was deleted.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# © 2022 Florian Kantelberg - initOS GmbH
2+
# © 2026 Liam Noonan - Pyxiris
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
5+
from odoo.tests import HttpCase, new_test_user, tagged
6+
7+
HOST = "127.0.0.1"
8+
9+
10+
@tagged("post_install", "-at_install")
11+
class TestColorScheme(HttpCase):
12+
def setUp(self):
13+
super().setUp()
14+
self.test_portal_user = new_test_user(
15+
self.env, "test_portal_user", groups="base.group_portal"
16+
)
17+
self.test_internal_user = new_test_user(
18+
self.env, "test_internal_user", groups="base.group_user"
19+
)
20+
self.test_internal_user.write(
21+
{
22+
"dark_mode": False,
23+
"dark_mode_device_dependent": False,
24+
}
25+
)
26+
27+
# Non internal user -> skip logic, do nothing
28+
def test_01_non_internal_user_ignored(self):
29+
self.authenticate(self.test_portal_user.login, self.test_portal_user.login)
30+
response = self.url_open("/my")
31+
cookie_header = response.headers.get("Set-Cookie", "")
32+
self.assertNotIn(
33+
"color_scheme",
34+
cookie_header,
35+
"Color scheme logic should not run for non-internal users",
36+
)
37+
38+
# No user preference, no cookie -> set light
39+
def test_02_no_user_settings_no_cookie(self):
40+
self.opener.cookies.clear()
41+
self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
42+
response = self.url_open("/odoo")
43+
cookie_header = response.headers.get("Set-Cookie", "")
44+
self.assertIn("color_scheme=light", cookie_header)
45+
self.assertEqual(self.opener.cookies.get("color_scheme"), "light")
46+
47+
# No user preference, light cookie -> do nothing
48+
def test_03_no_user_settings_light_cookie(self):
49+
self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
50+
self.opener.cookies.set("color_scheme", "light", domain=HOST, path="/")
51+
response = self.url_open("/odoo")
52+
cookie_header = response.headers.get("Set-Cookie", "")
53+
self.assertNotIn(
54+
"color_scheme",
55+
cookie_header,
56+
"The server should not set the cookie if already exists and is correct",
57+
)
58+
self.assertEqual(self.opener.cookies.get("color_scheme"), "light")
59+
60+
# User dark, cookie light -> set dark
61+
def test_04_user_dark_cookie_light(self):
62+
self.test_internal_user.write(
63+
{
64+
"dark_mode": True,
65+
"dark_mode_device_dependent": False,
66+
}
67+
)
68+
self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
69+
self.opener.cookies.set("color_scheme", "light", domain=HOST, path="/")
70+
response = self.url_open("/odoo")
71+
cookie_header = response.headers.get("Set-Cookie", "")
72+
self.assertIn("color_scheme=dark", cookie_header)
73+
self.assertEqual(self.opener.cookies.get("color_scheme"), "dark")
74+
75+
# User dark, cookie dark -> do nothing
76+
def test_05_user_dark_cookie_dark(self):
77+
self.test_internal_user.write(
78+
{
79+
"dark_mode": True,
80+
"dark_mode_device_dependent": False,
81+
}
82+
)
83+
self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
84+
self.opener.cookies.set("color_scheme", "dark", domain=HOST, path="/")
85+
response = self.url_open("/odoo")
86+
cookie_header = response.headers.get("Set-Cookie", "")
87+
self.assertNotIn(
88+
"color_scheme",
89+
cookie_header,
90+
"The server should not set the cookie if already exists and is correct",
91+
)
92+
self.assertEqual(self.opener.cookies.get("color_scheme"), "dark")
93+
94+
# User dev dep + dark, browser none, cookie none -> do nothing
95+
def test_06_user_dev_dep_browser_none_cookie_none(self):
96+
self.test_internal_user.write(
97+
{
98+
"dark_mode": True,
99+
"dark_mode_device_dependent": True,
100+
}
101+
)
102+
self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
103+
headers = {"Sec-CH-Prefers-Color-Scheme": None}
104+
response = self.url_open("/odoo", headers=headers)
105+
cookie_header = response.headers.get("Set-Cookie", "")
106+
# This also makes sure that device dependent is overruling regular dark mode
107+
self.assertNotIn(
108+
"color_scheme",
109+
cookie_header,
110+
"The server should not set the cookie as it will be set by client side js",
111+
)
112+
113+
# User dev dep, browser light, cookie light -> do nothing
114+
def test_07_user_dev_dep_browser_light_cookie_light(self):
115+
self.test_internal_user.write(
116+
{
117+
"dark_mode": True,
118+
"dark_mode_device_dependent": True,
119+
}
120+
)
121+
self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
122+
self.opener.cookies.set("color_scheme", "light", domain=HOST, path="/")
123+
headers = {"Sec-CH-Prefers-Color-Scheme": "light"}
124+
response = self.url_open("/odoo", headers=headers)
125+
cookie_header = response.headers.get("Set-Cookie", "")
126+
self.assertNotIn(
127+
"color_scheme",
128+
cookie_header,
129+
"The server should not set the cookie if already exists and is correct",
130+
)
131+
self.assertEqual(self.opener.cookies.get("color_scheme"), "light")
132+
133+
# User dev dep, browser dark, cookie light -> set dark
134+
def test_08_user_dev_dep_browser_dark_cookie_light(self):
135+
self.test_internal_user.write(
136+
{
137+
"dark_mode": False,
138+
"dark_mode_device_dependent": True,
139+
}
140+
)
141+
self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
142+
self.opener.cookies.set("color_scheme", "light", domain=HOST, path="/")
143+
headers = {"Sec-CH-Prefers-Color-Scheme": "dark"}
144+
response = self.url_open("/odoo", headers=headers)
145+
cookie_header = response.headers.get("Set-Cookie", "")
146+
self.assertIn("color_scheme=dark", cookie_header)
147+
self.assertEqual(self.opener.cookies.get("color_scheme"), "dark")
148+
149+
def test_09_vary_headers(self):
150+
self.authenticate(self.test_internal_user.login, self.test_internal_user.login)
151+
response = self.url_open("/odoo")
152+
self.assertIn("Sec-CH-Prefers-Color-Scheme", response.headers.get("Vary", ""))
153+
self.assertIn(
154+
"Sec-CH-Prefers-Color-Scheme", response.headers.get("Accept-CH", "")
155+
)

0 commit comments

Comments
 (0)