diff --git a/package-lock.json b/package-lock.json index 440e8382c6..4fd215f0e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3290,6 +3290,11 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, + "country-list": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/country-list/-/country-list-2.2.0.tgz", + "integrity": "sha512-AS21pllCp72LmUztqXPVKXK3TRyap1XlohGLqN04cXH2rFB9vo7SnH5sMnqltZPnu9ie/x1se3UuCZJbGXErfw==" + }, "crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -6628,6 +6633,11 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, + "iso-639-1": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-2.1.0.tgz", + "integrity": "sha512-8CTinLimb9ncAJ11wpCETWZ51qsQ3LS4vMHF2wxRRtR3+b7bvIxUlXOGYIdq0413+baWnbyG5dBluVcezOG/LQ==" + }, "isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", diff --git a/package.json b/package.json index 657f344cd6..0fa2412191 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "callsites": "^3.1.0", "compression": "^1.7.4", "config": "^3.2.2", + "country-list": "^2.2.0", "csv-parse": "^4.4.7", "dnssd": "^0.4.1", "express": "^4.17.1", @@ -54,6 +55,7 @@ "glob-to-regexp": "^0.4.1", "http-proxy": "^1.18.0", "ip-regex": "^4.1.0", + "iso-639-1": "^2.1.0", "jsonwebtoken": "^8.5.1", "mkdirp": "^0.5.1", "ncp": "^2.0.0", diff --git a/src/controllers/settings_controller.js b/src/controllers/settings_controller.js index b9f4e6acb2..21e37c723f 100644 --- a/src/controllers/settings_controller.js +++ b/src/controllers/settings_controller.js @@ -14,11 +14,15 @@ const CertificateManager = require('../certificate-manager'); const config = require('config'); +const Constants = require('../constants'); const fetch = require('node-fetch'); +const fs = require('fs'); +const ISO6391 = require('iso-639-1'); const jwtMiddleware = require('../jwt-middleware'); const mDNSserver = require('../mdns-server'); -const Platform = require('../platform'); +const path = require('path'); const pkg = require('../../package.json'); +const Platform = require('../platform'); const PromiseRouter = require('express-promise-router'); const Settings = require('../models/settings'); const TunnelService = require('../ssltunnel'); @@ -441,4 +445,144 @@ SettingsController.get('/network/addresses', auth, (request, response) => { } }); +SettingsController.get('/localization/country', auth, (request, response) => { + let valid = []; + if (Platform.implemented('getValidWirelessCountries')) { + valid = Platform.getValidWirelessCountries(); + } + + let current = ''; + if (Platform.implemented('getWirelessCountry')) { + current = Platform.getWirelessCountry(); + } + + const setImplemented = Platform.implemented('setWirelessCountry'); + response.json({valid, current, setImplemented}); +}); + +SettingsController.put('/localization/country', auth, (request, response) => { + if (!request.body || !request.body.hasOwnProperty('country')) { + response.status(400).send('Missing country property'); + return; + } + + if (Platform.implemented('setWirelessCountry')) { + if (Platform.setWirelessCountry(request.body.country)) { + response.status(200).json({}); + } else { + response.status(500).send('Failed to update country'); + } + } else { + response.status(500).send('Setting country not implemented'); + } +}); + +SettingsController.get('/localization/timezone', auth, (request, response) => { + let valid = []; + if (Platform.implemented('getValidTimezones')) { + valid = Platform.getValidTimezones(); + } + + let current = ''; + if (Platform.implemented('getTimezone')) { + current = Platform.getTimezone(); + } + + const setImplemented = Platform.implemented('setTimezone'); + response.json({valid, current, setImplemented}); +}); + +SettingsController.put('/localization/timezone', auth, (request, response) => { + if (!request.body || !request.body.hasOwnProperty('zone')) { + response.status(400).send('Missing zone property'); + return; + } + + if (Platform.implemented('setTimezone')) { + if (Platform.setTimezone(request.body.zone)) { + response.status(200).json({}); + } else { + response.status(500).send('Failed to update timezone'); + } + } else { + response.status(500).send('Setting timezone not implemented'); + } +}); + +SettingsController.get( + '/localization/language', + auth, + async (request, response) => { + const fluentDir = path.join(Constants.BUILD_STATIC_PATH, 'fluent'); + const valid = []; + try { + for (const dirname of fs.readdirSync(fluentDir)) { + const [language, country] = dirname.split('-'); + + valid.push({ + code: dirname, + name: `${ISO6391.getName(language)}${country ? ` (${country})` : ''}`, + }); + } + + valid.sort((a, b) => a.name.localeCompare(b.name)); + } catch (_) { + response.status(500).send('Failed to retrieve list of languages'); + return; + } + + try { + const current = await Settings.get('localization.language'); + response.json({valid, current}); + } catch (_) { + response.status(500).send('Failed to get current language'); + } + } +); + +SettingsController.put( + '/localization/language', + auth, + async (request, response) => { + if (!request.body || !request.body.hasOwnProperty('language')) { + response.status(400).send('Missing language property'); + return; + } + + try { + await Settings.set('localization.language', request.body.language); + response.json({}); + } catch (_) { + response.status(500).send('Failed to set language'); + } + } +); + +SettingsController.get('/localization/units', auth, async (request, response) => { + let temperature; + + try { + temperature = await Settings.get('localization.units.temperature'); + } catch (e) { + // pass + } + + response.json({ + temperature: temperature || 'celsius', + }); +}); + +SettingsController.put('/localization/units', auth, async (request, response) => { + for (const [key, value] of Object.entries(request.body)) { + try { + await Settings.set(`localization.units.${key}`, value); + } catch (_) { + response.status(500).send('Failed to set unit'); + return; + } + } + + response.json({}); +}); + module.exports = SettingsController; diff --git a/src/platform.js b/src/platform.js index 6bc92acbd5..c664780d67 100644 --- a/src/platform.js +++ b/src/platform.js @@ -176,6 +176,12 @@ const wrappedMethods = [ 'restartSystem', 'scanWirelessNetworks', 'update', + 'getValidTimezones', + 'getTimezone', + 'setTimezone', + 'getValidWirelessCountries', + 'getWirelessCountry', + 'setWirelessCountry', ]; for (const method of wrappedMethods) { diff --git a/src/platforms/linux-openwrt.js b/src/platforms/linux-openwrt.js index 593f90be7e..d92e9c12f3 100644 --- a/src/platforms/linux-openwrt.js +++ b/src/platforms/linux-openwrt.js @@ -10,6 +10,7 @@ const child_process = require('child_process'); const config = require('config'); +const countryList = require('country-list'); const fs = require('fs'); const ipRegex = require('ip-regex'); const os = require('os'); @@ -1306,6 +1307,168 @@ function update() { }); } +/** + * Get a list of all valid timezones for the system. + * + * @returns {string[]} List of timezones. + */ +function getValidTimezones() { + const tzdata = '/usr/lib/lua/luci/sys/zoneinfo/tzdata.lua'; + if (!fs.existsSync(tzdata)) { + return []; + } + + try { + const data = fs.readFileSync(tzdata, 'utf8'); + const zones = data.split('\n') + .filter((l) => l.startsWith('\t{')) + .map((l) => l.split('\'')[1]) + .sort(); + + return zones; + } catch (e) { + console.error('Failed to read zone file:', e); + } + + return []; +} + +/** + * Get the current timezone. + * + * @returns {string} Name of timezone. + */ +function getTimezone() { + const label = 'getTimezone'; + const result = uciGet(label, 'system.@system[0].zonename'); + if (!result.success) { + DEBUG && console.log(`${label}: getTimezone returning ''`); + return ''; + } + + DEBUG && console.log(`${label}: getTimezone returning '${result.value}'`); + return result.value; +} + +/** + * Set the current timezone. + * + * @param {string} zone - The timezone to set + * @returns {boolean} Boolean indicating success of the command. + */ +function setTimezone(zone) { + const label = 'setTimezone'; + + const tzdata = '/usr/lib/lua/luci/sys/zoneinfo/tzdata.lua'; + if (!fs.existsSync(tzdata)) { + return []; + } + + let zones; + try { + const data = fs.readFileSync(tzdata, 'utf8'); + zones = data.split('\n') + .filter((l) => l.startsWith('\t{')) + .map((l) => { + const fields = l.split('\''); + return [fields[1], fields[3]]; + }); + } catch (e) { + console.error('Failed to read zone file:', e); + return false; + } + + const data = zones.find((z) => z[0] === zone); + if (!data) { + return false; + } + + if (!uciSet(label, 'system.@system[0].zonename', data[0])) { + return false; + } + + if (!uciSet(label, 'system.@system[0].timezone', data[1])) { + return false; + } + + if (!uciCommit(label, 'system')) { + return false; + } + + const proc = spawnSync(label, '/etc/init.d/system', ['reload']); + return proc.status === 0; +} + +/** + * Get a list of all valid wi-fi countries for the system. + * + * @returns {string[]} list of countries. + */ +function getValidWirelessCountries() { + const label = 'getValidWirelessCountries'; + const proc = spawnSync(label, 'iwinfo', ['wlan0', 'countrylist']); + + if (proc.status !== 0) { + return []; + } + + return proc.stdout.split('\n') + .map((l) => l.split(/\s+/g)[1]) + .filter((l) => typeof l === 'string' && l !== '00') + .map((l) => countryList.getName(l)) + .sort(); +} + +/** + * Get the wi-fi country code. + * + * @returns {string} Country. + */ +function getWirelessCountry() { + const label = 'getWirelessCountry'; + const proc = spawnSync(label, 'iwinfo', ['wlan0', 'countrylist']); + + if (proc.status !== 0) { + return ''; + } + + const selected = proc.stdout.split('\n').find((l) => l.startsWith('*')); + if (!selected) { + return ''; + } + + const code = selected.split(/\s+/g)[1]; + return countryList.getName(code) || ''; +} + +/** + * Set the wi-fi country code. + * + * @param {string} country - The country code to set + * @returns {boolean} Boolean indicating success of the command. + */ +function setWirelessCountry(country) { + const label = 'setWirelessCountry'; + let proc = spawnSync(label, 'iwinfo', ['wlan0', 'countrylist']); + + if (proc.status !== 0) { + return false; + } + + const countries = proc.stdout.split('\n') + .map((l) => l.split(/\s+/g)[1]) + .filter((l) => typeof l === 'string' && l !== '00') + .map((l) => [l, countryList.getName(l)]); + + const data = countries.find((c) => c[1] === country); + if (!data) { + return false; + } + + proc = spawnSync(label, 'iw', ['reg', 'set', data[0]]); + return proc.status === 0; +} + module.exports = { getPlatformArchitecture, getCaptivePortalStatus, @@ -1331,4 +1494,10 @@ module.exports = { restartSystem, scanWirelessNetworks, update, + getValidTimezones, + getTimezone, + setTimezone, + getValidWirelessCountries, + getWirelessCountry, + setWirelessCountry, }; diff --git a/src/platforms/linux-raspbian.js b/src/platforms/linux-raspbian.js index 5abc63ec6d..d9f1eb7d0a 100644 --- a/src/platforms/linux-raspbian.js +++ b/src/platforms/linux-raspbian.js @@ -800,6 +800,170 @@ function update() { }); } +/** + * Get a list of all valid timezones for the system. + * + * @returns {string[]} List of timezones. + */ +function getValidTimezones() { + const tzdata = '/usr/share/zoneinfo/zone.tab'; + if (!fs.existsSync(tzdata)) { + return []; + } + + try { + const data = fs.readFileSync(tzdata, 'utf8'); + const zones = data.split('\n') + .filter((l) => !l.startsWith('#') && l.length > 0) + .map((l) => l.split(/\s+/g)[2]) + .sort(); + + return zones; + } catch (e) { + console.error('Failed to read zone file:', e); + } + + return []; +} + +/** + * Get the current timezone. + * + * @returns {string} Name of timezone. + */ +function getTimezone() { + const tzdata = '/etc/timezone'; + if (!fs.existsSync(tzdata)) { + return ''; + } + + try { + const data = fs.readFileSync(tzdata, 'utf8'); + return data.trim(); + } catch (e) { + console.error('Failed to read timezone:', e); + } + + return ''; +} + +/** + * Set the current timezone. + * + * @param {string} zone - The timezone to set + * @returns {boolean} Boolean indicating success of the command. + */ +function setTimezone(zone) { + const proc = child_process.spawnSync( + 'sudo', + ['raspi-config', 'nonint', 'do_change_timezone', zone] + ); + return proc.status === 0; +} + +/** + * Get a list of all valid wi-fi countries for the system. + * + * @returns {string[]} List of countries. + */ +function getValidWirelessCountries() { + const fname = '/usr/share/zoneinfo/iso3166.tab'; + if (!fs.existsSync(fname)) { + return []; + } + + try { + const data = fs.readFileSync(fname, 'utf8'); + const zones = data.split('\n') + .filter((l) => !l.startsWith('#') && l.length > 0) + .map((l) => l.split('\t')[1]) + .sort(); + + return zones; + } catch (e) { + console.error('Failed to read zone file:', e); + } + + return []; +} + +/** + * Get the wi-fi country code. + * + * @returns {string} Country. + */ +function getWirelessCountry() { + const proc = child_process.spawnSync( + 'sudo', + ['raspi-config', 'nonint', 'get_wifi_country'], + {encoding: 'utf8'} + ); + + if (proc.status !== 0) { + return ''; + } + + const code = proc.stdout.trim(); + + const fname = '/usr/share/zoneinfo/iso3166.tab'; + if (!fs.existsSync(fname)) { + return ''; + } + + let countries; + try { + const data = fs.readFileSync(fname, 'utf8'); + countries = data.split('\n') + .filter((l) => !l.startsWith('#') && l.length > 0) + .map((l) => l.split('\t')); + } catch (e) { + console.error('Failed to read country file:', e); + return ''; + } + + const data = countries.find((c) => c[0] === code); + if (!data) { + return ''; + } + + return data[1]; +} + +/** + * Set the wi-fi country code. + * + * @param {string} country - The country to set + * @returns {boolean} Boolean indicating success of the command. + */ +function setWirelessCountry(country) { + const fname = '/usr/share/zoneinfo/iso3166.tab'; + if (!fs.existsSync(fname)) { + return false; + } + + let countries; + try { + const data = fs.readFileSync(fname, 'utf8'); + countries = data.split('\n') + .filter((l) => !l.startsWith('#') && l.length > 0) + .map((l) => l.split('\t')); + } catch (e) { + console.error('Failed to read country file:', e); + return false; + } + + const data = countries.find((c) => c[1] === country); + if (!data) { + return false; + } + + const proc = child_process.spawnSync( + 'sudo', + ['raspi-config', 'nonint', 'do_wifi_country', data[0]] + ); + return proc.status === 0; +} + module.exports = { getDhcpServerStatus, setDhcpServerStatus, @@ -819,4 +983,10 @@ module.exports = { restartSystem, scanWirelessNetworks, update, + getValidTimezones, + getTimezone, + setTimezone, + getValidWirelessCountries, + getWirelessCountry, + setWirelessCountry, }; diff --git a/static/css/settings.css b/static/css/settings.css index 80e746fe69..ee1b0b370b 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -132,6 +132,10 @@ background-image: url('../optimized-images/experiments-icon.png'); } +#localization-settings-link { + background-image: url('../optimized-images/localization-icon.svg'); +} + #update-settings-link { background-image: url('../optimized-images/update-icon.svg'); } @@ -206,6 +210,7 @@ #addon-settings ul, #domain-settings ul, #experiment-settings ul, +#localization-settings ul, #update-settings ul, #authorization-settings ul, #developer-settings ul { @@ -236,6 +241,7 @@ #addon-settings ul li:last-child, #domain-settings ul li:last-child, #experiment-settings ul li:last-child, +#localization-settings ul li:last-child, #update-settings ul li:last-child { margin-bottom: 3rem; } @@ -268,6 +274,7 @@ .developer-checkbox-item, .experiment-item, +.localization-item, .update-item, .authorization-item, .domain-item { @@ -586,6 +593,32 @@ background-color: #305067; } +.localization-item { + padding: 1rem 2rem; + text-align: center; +} + +.localization-item-header { + padding-bottom: 2rem; +} + +.localization-item-content { + width: 100%; + display: grid; + grid-gap: 0 1rem; + grid-template-columns: repeat(3, 1fr); +} + +.localization-item-content > span { + grid-column: 1 / 2; + padding-top: 1.6rem; + text-align: right; +} + +.localization-item-content > select { + grid-column: 2 / 4; +} + .update-item { padding: 2rem; } @@ -689,6 +722,7 @@ display: none; } +#localization-settings > .settings-container, #update-settings > .settings-container { font-size: 1.8rem; } @@ -839,6 +873,13 @@ margin: 1rem; } +.localization-select { + display: block; + width: calc(100% - 1rem); + max-width: 30rem; + margin: 1rem; +} + .user-settings-save { display: block; margin: 1rem auto; @@ -898,6 +939,7 @@ display: none !important; } +.localization-select, .network-select { display: inline-block; text-align: left; diff --git a/static/fluent/en-US/main.ftl b/static/fluent/en-US/main.ftl index 7e3adac1ed..d12f035c14 100644 --- a/static/fluent/en-US/main.ftl +++ b/static/fluent/en-US/main.ftl @@ -30,6 +30,7 @@ settings-network = Network settings-users = Users settings-add-ons = Add-ons settings-adapters = Adapters +settings-localization = Localization settings-updates = Updates settings-authorizations = Authorizations settings-experiments = Experiments @@ -112,6 +113,16 @@ authorization-settings-no-authorizations = No authorizations. experiment-settings-smart-assistant = Smart Assistant experiment-settings-logs = Logs +## Location Settings +localization-settings-language-region = Language & Region +localization-settings-country = Country +localization-settings-timezone = Timezone +localization-settings-language = Language +localization-settings-units = Units +localization-settings-units-temperature = Temperature +localization-settings-units-temperature-celsius = Celsius (°C) +localization-settings-units-temperature-fahrenheit = Fahrenheit (°F) + ## Update Settings update-settings-update-now = Update Now update-available = New version available @@ -381,6 +392,7 @@ addons = Add-ons addon-config = Configure Add-on addon-discovery = Discover New Add-ons experiments = Experiments +localization = Localization updates = Updates authorizations = Authorizations developer = Developer diff --git a/static/images/localization-icon.svg b/static/images/localization-icon.svg new file mode 100644 index 0000000000..bd0f2e3336 --- /dev/null +++ b/static/images/localization-icon.svg @@ -0,0 +1,35 @@ + + + + + + + image/svg+xml + + + + + + + + + diff --git a/static/index.html b/static/index.html index 98697ff58d..1405cf2747 100644 --- a/static/index.html +++ b/static/index.html @@ -71,6 +71,7 @@
  • +
  • @@ -405,6 +406,31 @@ +