Initial commit
This commit is contained in:
10
.clang-format
Normal file
10
.clang-format
Normal file
@@ -0,0 +1,10 @@
|
||||
BasedOnStyle: LLVM
|
||||
ColumnLimit: 80
|
||||
IndentWidth: 4
|
||||
TabWidth: 4
|
||||
UseTab: ForContinuationAndIndentation
|
||||
BreakBeforeBraces: Linux
|
||||
SpaceAfterCStyleCast: true
|
||||
AlignAfterOpenBracket: DontAlign
|
||||
AlignOperands: DontAlign
|
||||
SpacesBeforeTrailingComments: 2
|
||||
75
.gitignore
vendored
Normal file
75
.gitignore
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
# This file is used to ignore files which are generated
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
*~
|
||||
*.autosave
|
||||
*.a
|
||||
*.core
|
||||
*.moc
|
||||
*.o
|
||||
*.obj
|
||||
*.orig
|
||||
*.rej
|
||||
*.so
|
||||
*.so.*
|
||||
*_pch.h.cpp
|
||||
*_resource.rc
|
||||
*.qm
|
||||
.#*
|
||||
*.*#
|
||||
core
|
||||
!core/
|
||||
tags
|
||||
.DS_Store
|
||||
.directory
|
||||
*.debug
|
||||
Makefile*
|
||||
*.prl
|
||||
*.app
|
||||
moc_*.cpp
|
||||
ui_*.h
|
||||
qrc_*.cpp
|
||||
Thumbs.db
|
||||
*.res
|
||||
*.rc
|
||||
/.qmake.cache
|
||||
/.qmake.stash
|
||||
|
||||
# qtcreator generated files
|
||||
*.pro.user*
|
||||
CMakeLists.txt.user*
|
||||
.qtcreator
|
||||
|
||||
# xemacs temporary files
|
||||
*.flc
|
||||
|
||||
# Vim temporary files
|
||||
.*.swp
|
||||
|
||||
# Visual Studio generated files
|
||||
*.ib_pdb_index
|
||||
*.idb
|
||||
*.ilk
|
||||
*.pdb
|
||||
*.sln
|
||||
*.suo
|
||||
*.vcproj
|
||||
*vcproj.*.*.user
|
||||
*.ncb
|
||||
*.sdf
|
||||
*.opensdf
|
||||
*.vcxproj
|
||||
*vcxproj.*
|
||||
|
||||
# MinGW generated files
|
||||
*.Debug
|
||||
*.Release
|
||||
|
||||
# Python byte code
|
||||
*.pyc
|
||||
|
||||
# Binaries
|
||||
# --------
|
||||
*.dll
|
||||
*.exe
|
||||
|
||||
172
CMakeLists.txt
Normal file
172
CMakeLists.txt
Normal file
@@ -0,0 +1,172 @@
|
||||
# CMake 3.19 has automatic Qt target finalisation at the end of the scope.
|
||||
# CMake 3.21 has RUNTIME_DEPENDENCY_SET.
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(north VERSION 1.0 DESCRIPTION "TOTP Authenticator" LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
add_compile_options("$<$<CXX_COMPILER_ID:MSVC>:/utf-8>")
|
||||
add_compile_options("$<$<CXX_COMPILER_ID:GNU>:-Wall;-Wextra>")
|
||||
add_compile_options("$<$<CXX_COMPILER_ID:Clang>:-Wall;-Wextra>")
|
||||
|
||||
# We know we need at least 6.7 because of Qt SVG features.
|
||||
find_package(Qt6 6.7 REQUIRED COMPONENTS Widgets Svg Xml)
|
||||
|
||||
qt_add_executable(gen-icon gen-icon.cpp)
|
||||
target_link_libraries(gen-icon PRIVATE Qt6::Svg Qt6::Xml)
|
||||
|
||||
add_custom_command(OUTPUT north.svg
|
||||
COMMAND gen-icon --kind generic > north.svg
|
||||
DEPENDS gen-icon
|
||||
COMMENT "Generating north.svg" VERBATIM)
|
||||
add_custom_command(OUTPUT north-small.svg
|
||||
COMMAND gen-icon --kind small > north-small.svg
|
||||
DEPENDS gen-icon
|
||||
COMMENT "Generating north-small.svg" VERBATIM)
|
||||
add_custom_command(OUTPUT north-symbolic.svg
|
||||
COMMAND gen-icon --kind symbolic > north-symbolic.svg
|
||||
DEPENDS gen-icon
|
||||
COMMENT "Generating north-symbolic.svg" VERBATIM)
|
||||
|
||||
set(systray_icon north-small.svg)
|
||||
set(extra_sources)
|
||||
|
||||
if (WIN32)
|
||||
find_program(ICOTOOL_EXECUTABLE icotool REQUIRED)
|
||||
add_custom_command(
|
||||
OUTPUT north.16.png north.32.png north.48.png north.256.png north.ico
|
||||
COMMAND gen-icon --png 16 > north.16.png
|
||||
COMMAND gen-icon --png 32 > north.32.png
|
||||
COMMAND gen-icon --png 48 > north.48.png
|
||||
COMMAND gen-icon --png 256 > north.256.png
|
||||
COMMAND ${ICOTOOL_EXECUTABLE} -c -o north.ico --raw north.256.png
|
||||
north.16.png north.32.png north.48.png
|
||||
DEPENDS gen-icon
|
||||
COMMENT "Generating north.ico" VERBATIM)
|
||||
|
||||
# QT_TARGET_* are undocumented and wouldn't set the dependency.
|
||||
set(resource_file "${PROJECT_BINARY_DIR}/north.rc")
|
||||
list(APPEND extra_sources "${resource_file}")
|
||||
add_custom_command(OUTPUT "${resource_file}"
|
||||
COMMAND ${CMAKE_COMMAND} -E echo "1 ICON \"north.ico\""
|
||||
> "${resource_file}" VERBATIM)
|
||||
set_property(SOURCE "${resource_file}"
|
||||
APPEND PROPERTY OBJECT_DEPENDS "${PROJECT_BINARY_DIR}/north.ico")
|
||||
elseif (APPLE)
|
||||
set(systray_icon north-symbolic.svg)
|
||||
list(APPEND extra_sources north.icns)
|
||||
|
||||
find_program(ICONUTIL_EXECUTABLE iconutil REQUIRED)
|
||||
add_custom_command(
|
||||
OUTPUT north.icns
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory north.iconset
|
||||
COMMAND gen-icon -k mac -p 16 > north.iconset/icon_16x16.png
|
||||
COMMAND gen-icon -k mac -p 32 > north.iconset/icon_16x16@2x.png
|
||||
COMMAND gen-icon -k mac -p 32 > north.iconset/icon_32x32.png
|
||||
COMMAND gen-icon -k mac -p 64 > north.iconset/icon_32x32@2x.png
|
||||
COMMAND gen-icon -k mac -p 64 > north.iconset/icon_64x64.png
|
||||
COMMAND gen-icon -k mac -p 128 > north.iconset/icon_64x64@2x.png
|
||||
COMMAND gen-icon -k mac -p 128 > north.iconset/icon_128x128.png
|
||||
COMMAND gen-icon -k mac -p 256 > north.iconset/icon_128x128@2x.png
|
||||
COMMAND gen-icon -k mac -p 256 > north.iconset/icon_256x256.png
|
||||
COMMAND gen-icon -k mac -p 512 > north.iconset/icon_256x256@2x.png
|
||||
COMMAND gen-icon -k mac -p 512 > north.iconset/icon_512x512.png
|
||||
COMMAND gen-icon -k mac -p 1024 > north.iconset/icon_512x512@2x.png
|
||||
COMMAND ${ICONUTIL_EXECUTABLE} -c icns -o north.icns north.iconset
|
||||
COMMAND ${CMAKE_COMMAND} -E remove_directory north.iconset
|
||||
DEPENDS gen-icon
|
||||
COMMENT "Generating north.icns" VERBATIM)
|
||||
set_source_files_properties(north.icns PROPERTIES
|
||||
MACOSX_PACKAGE_LOCATION Resources)
|
||||
else()
|
||||
# Perhaps it only makes sense to install these if normal renders suck,
|
||||
# and I use fucking Qt SVG here, a very poor renderer.
|
||||
set(icons north.svg)
|
||||
foreach (size 16 22 24 32 48 64 128 256)
|
||||
set(dir icons/hicolor/${size}x${size}/apps)
|
||||
set(path "${dir}/north.png")
|
||||
add_custom_command(OUTPUT "${path}"
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${dir}"
|
||||
COMMAND gen-icon --png ${size} > "${path}"
|
||||
DEPENDS gen-icon
|
||||
COMMENT "Generating ${size}x${size} north.png" VERBATIM)
|
||||
list(APPEND icons "${path}")
|
||||
endforeach()
|
||||
add_custom_target(icons ALL DEPENDS ${icons})
|
||||
|
||||
include(GNUInstallDirs)
|
||||
install(DIRECTORY "${PROJECT_BINARY_DIR}/icons"
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR})
|
||||
install(FILES "${PROJECT_BINARY_DIR}/north.svg"
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps)
|
||||
endif()
|
||||
|
||||
set_property(SOURCE ${systray_icon}
|
||||
APPEND PROPERTY QT_RESOURCE_ALIAS systray.svg)
|
||||
set_property(SOURCE edit-copy-symbolic.svg
|
||||
APPEND PROPERTY QT_RESOURCE_ALIAS edit-copy-symbolic.svg)
|
||||
|
||||
qt_add_executable(north north.cpp north.h ${extra_sources})
|
||||
qt_add_resources(north "rsrc" PREFIX / FILES
|
||||
${PROJECT_BINARY_DIR}/${systray_icon} edit-copy-symbolic.svg)
|
||||
target_link_libraries(north PRIVATE Qt6::Widgets)
|
||||
if (APPLE)
|
||||
target_link_libraries(north PRIVATE "-framework ApplicationServices")
|
||||
endif()
|
||||
|
||||
set_target_properties(north PROPERTIES
|
||||
MACOSX_BUNDLE_INFO_PLIST ${PROJECT_SOURCE_DIR}/Info.plist.in
|
||||
MACOSX_BUNDLE_GUI_IDENTIFIER name.janouch.north
|
||||
MACOSX_BUNDLE_ICON_FILE north.icns
|
||||
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
||||
MACOSX_BUNDLE_SHORT_VERSION_STRING
|
||||
${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
|
||||
MACOSX_BUNDLE TRUE
|
||||
WIN32_EXECUTABLE TRUE)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
install(TARGETS north
|
||||
RUNTIME_DEPENDENCY_SET dependencies
|
||||
BUNDLE DESTINATION .
|
||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
|
||||
|
||||
if (WIN32)
|
||||
install(RUNTIME_DEPENDENCY_SET dependencies DIRECTORIES $ENV{PATH}
|
||||
PRE_EXCLUDE_REGEXES "api-ms-win-.*"
|
||||
POST_EXCLUDE_REGEXES ".*[/\\]system32[/\\].*")
|
||||
endif()
|
||||
if (NOT WIN32 AND NOT APPLE)
|
||||
install(FILES LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR})
|
||||
install(FILES north.desktop
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
|
||||
elseif (QT6_IS_SHARED_LIBS_BUILD)
|
||||
# Note that under MSYS2, it suffices to install qt6-static,
|
||||
# and then prepend its subprefix to PATH before invoking cmake.
|
||||
# To keep size low, remember to strip the resulting binary.
|
||||
|
||||
# XXX: QTBUG-127075, which can be circumvented by manually running
|
||||
# macdeployqt on north.app before the install.
|
||||
qt_generate_deploy_app_script(TARGET north OUTPUT_SCRIPT deploy)
|
||||
install(SCRIPT "${deploy}")
|
||||
endif()
|
||||
|
||||
# CPack
|
||||
set(CPACK_PACKAGE_VENDOR "Premysl Eric Janouch")
|
||||
set(CPACK_PACKAGE_CONTACT "Přemysl Eric Janouch <p@janouch.name>")
|
||||
set(CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE")
|
||||
set(CPACK_GENERATOR "TGZ;ZIP")
|
||||
set(CPACK_PACKAGE_FILE_NAME
|
||||
"${PROJECT_NAME}-${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}")
|
||||
set(CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME} ${PROJECT_VERSION}")
|
||||
set(CPACK_SOURCE_GENERATOR "TGZ;ZIP")
|
||||
set(CPACK_SOURCE_IGNORE_FILES "/build;/CMakeLists.txt.user")
|
||||
set(CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}")
|
||||
|
||||
include(CPack)
|
||||
43
Info.plist.in
Normal file
43
Info.plist.in
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- From /lib/cmake/Qt6/macos/Info.plist.app.in -->
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>${MACOSX_BUNDLE_BUNDLE_NAME}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${MACOSX_BUNDLE_GUI_IDENTIFIER}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>${MACOSX_BUNDLE_EXECUTABLE_NAME}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${MACOSX_BUNDLE_BUNDLE_VERSION}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${MACOSX_BUNDLE_SHORT_VERSION_STRING}</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>${CMAKE_OSX_DEPLOYMENT_TARGET}</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>${MACOSX_BUNDLE_COPYRIGHT}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>${MACOSX_BUNDLE_ICON_FILE}</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleAllowMixedLocalizations</key>
|
||||
<true/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
|
||||
<!-- Useful-looking from /share/cmake/Modules/MacOSXBundleInfo.plist.in -->
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
|
||||
<!-- Custom additions -->
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
12
LICENSE
Normal file
12
LICENSE
Normal file
@@ -0,0 +1,12 @@
|
||||
Copyright (c) 2025, Přemysl Eric Janouch <p@janouch.name>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||
SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
|
||||
OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
51
README.adoc
Normal file
51
README.adoc
Normal file
@@ -0,0 +1,51 @@
|
||||
north
|
||||
=====
|
||||
|
||||
_north_ is a TOTP authenticator hyper-focused on simplicity of use.
|
||||
Give it a bunch of otpauth:// URLs and it will show you the codes when you
|
||||
click on it in your Linux/Windows/macOS system tray, allowing you to copy codes
|
||||
into the clipboard with two clicks.
|
||||
|
||||
Packages
|
||||
--------
|
||||
Regular releases are sporadic. git master should be stable enough.
|
||||
////
|
||||
You can get a package with the latest development version
|
||||
as a https://git.janouch.name/p/nixexprs[Nix derivation].
|
||||
|
||||
Windows/macOS binaries can be downloaded from
|
||||
https://git.janouch.name/p/north/releases[the Releases page on Gitea].
|
||||
////
|
||||
|
||||
Building
|
||||
--------
|
||||
Build dependencies: CMake, pkg-config, icoutils (Windows) +
|
||||
Runtime dependencies: Qt 6, Qt 6 SVG
|
||||
|
||||
$ git clone https://git.janouch.name/p/north.git
|
||||
$ cd north
|
||||
$ cmake -B build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Debug
|
||||
$ cd build
|
||||
$ make
|
||||
|
||||
To install the applications, you can do either the usual:
|
||||
|
||||
# make install
|
||||
|
||||
Or you can try telling CMake to make a package for you. For Debian it is:
|
||||
|
||||
$ cpack -G DEB
|
||||
# dpkg -i north-*.deb
|
||||
|
||||
Contributing and Support
|
||||
------------------------
|
||||
Use https://git.janouch.name/p/north to report bugs, request features,
|
||||
or submit pull requests. `git send-email` is tolerated. If you want to discuss
|
||||
the project, feel free to join me at ircs://irc.janouch.name, channel #dev.
|
||||
|
||||
Bitcoin donations are accepted at: 12r5uEWEgcHC46xd64tt3hHt9EUvYYDHe9
|
||||
|
||||
License
|
||||
-------
|
||||
This software is released under the terms of the 0BSD license, the text of which
|
||||
is included within the package along with the list of authors.
|
||||
2
edit-copy-symbolic.svg
Normal file
2
edit-copy-symbolic.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 0 3 c 0 -1.644531 1.355469 -3 3 -3 h 5 c 1.644531 0 3 1.355469 3 3 c 0 0.550781 -0.449219 1 -1 1 s -1 -0.449219 -1 -1 c 0 -0.570312 -0.429688 -1 -1 -1 h -5 c -0.570312 0 -1 0.429688 -1 1 v 5 c 0 0.570312 0.429688 1 1 1 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 c -1.644531 0 -3 -1.355469 -3 -3 z m 5 5 c 0 -1.644531 1.355469 -3 3 -3 h 5 c 1.644531 0 3 1.355469 3 3 v 5 c 0 1.644531 -1.355469 3 -3 3 h -5 c -1.644531 0 -3 -1.355469 -3 -3 z m 2 0 v 5 c 0 0.570312 0.429688 1 1 1 h 5 c 0.570312 0 1 -0.429688 1 -1 v -5 c 0 -0.570312 -0.429688 -1 -1 -1 h -5 c -0.570312 0 -1 0.429688 -1 1 z m 0 0" fill="#222222"/></svg>
|
||||
|
After Width: | Height: | Size: 759 B |
286
gen-icon.cpp
Normal file
286
gen-icon.cpp
Normal file
@@ -0,0 +1,286 @@
|
||||
// gen-icon.cpp: generate program icons for north in SVG and PNG formats
|
||||
//
|
||||
// Copyright (c) 2025, Přemysl Eric Janouch <p@janouch.name>
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
#include <QBuffer>
|
||||
#include <QColor>
|
||||
#include <QCommandLineParser>
|
||||
#include <QCoreApplication>
|
||||
#include <QDomDocument>
|
||||
#include <QFile>
|
||||
#include <QImage>
|
||||
#include <QMap>
|
||||
#include <QPainter>
|
||||
#include <QSvgRenderer>
|
||||
#include <QTextStream>
|
||||
#include <cmath>
|
||||
|
||||
#ifdef Q_OS_WIN32
|
||||
#include <io.h>
|
||||
#include <fcntl.h>
|
||||
#endif
|
||||
|
||||
static const QColor colorTop(0x80, 0xc0, 0xff);
|
||||
static const QColor colorBottom(0x58, 0x58, 0xc0);
|
||||
|
||||
static const QColor colorAccent = QColor::fromHsl(QColor(
|
||||
(colorTop.red() + colorBottom.red()) / 2,
|
||||
(colorTop.green() + colorBottom.green()) / 2,
|
||||
(colorTop.blue() + colorBottom.blue()) / 2).hue(), 0xff, 0xd0);
|
||||
|
||||
enum class IconKind { Generic, Small, Symbolic, Mac };
|
||||
|
||||
static const QMap<QString, IconKind> kindNames = {
|
||||
{"generic", IconKind::Generic},
|
||||
{"small", IconKind::Small},
|
||||
{"symbolic", IconKind::Symbolic},
|
||||
{"mac", IconKind::Mac},
|
||||
};
|
||||
|
||||
struct IconStyle {
|
||||
double scale, arrow_stroke_width;
|
||||
QString arrow_color, circle_color;
|
||||
double circle_opacity, circle_width;
|
||||
};
|
||||
|
||||
static const QMap<IconKind, IconStyle> iconStyles = {
|
||||
{IconKind::Generic, {16, .4, "#ffffff", colorAccent.name(), 1., .225}},
|
||||
{IconKind::Small, {16, .3, "#ffffff", colorAccent.name(), 1., .3}},
|
||||
{IconKind::Symbolic, {18, .4, "#000000", "#000000", 1., .225}},
|
||||
{IconKind::Mac, {12, .4, "#ffffff", "#ffffff", .625, .225}},
|
||||
};
|
||||
|
||||
QDomElement appendElement(QDomNode &parent, QDomDocument &doc,
|
||||
const QString &tagName, const QMap<QString, QString> &attrs = {})
|
||||
{
|
||||
QDomElement elem = doc.createElement(tagName);
|
||||
for (auto it = attrs.begin(); it != attrs.end(); ++it)
|
||||
elem.setAttribute(it.key(), it.value());
|
||||
parent.appendChild(elem);
|
||||
return elem;
|
||||
}
|
||||
|
||||
// Apple uses something that's close to a "quintic superellipse" in their icons,
|
||||
// but doesn't quite match. Either way, it looks better than rounded rectangles.
|
||||
QString makeSquirclePath(double x, double y, double width, double height)
|
||||
{
|
||||
auto superellipse = [](double value) {
|
||||
return std::copysign(std::pow(std::abs(value), 2. / 5.), value);
|
||||
};
|
||||
|
||||
QString path = "M";
|
||||
double centerX = x + width / 2.;
|
||||
double centerY = y + height / 2.;
|
||||
// We won't use the SVG version of this at all, so it doesn't matter
|
||||
// if we draw it using way too many points.
|
||||
for (double theta = 0.0; theta < 2. * M_PI; theta += M_PI / 1e4) {
|
||||
double px = superellipse(std::cos(theta)) * width / 2. + centerX;
|
||||
double py = superellipse(std::sin(theta)) * height / 2. + centerY;
|
||||
path += QString(" %1,%2").arg(px).arg(py);
|
||||
}
|
||||
path += " Z";
|
||||
return path;
|
||||
}
|
||||
|
||||
QDomDocument generateSVG(IconKind kind)
|
||||
{
|
||||
const IconStyle &style = iconStyles[kind];
|
||||
static const QString arrowPath =
|
||||
"M 0,-1.3 +0.55,0.4 +0.2,0.25 0,1.3 -0.2,0.25 -0.55,0.4 z";
|
||||
|
||||
QDomDocument doc;
|
||||
auto add = [&](QDomNode &parent, const QString &tagName,
|
||||
const QMap<QString, QString> &attrs = {}) {
|
||||
return appendElement(parent, doc, tagName, attrs);
|
||||
};
|
||||
auto svg = add(doc, "svg", {
|
||||
{"version", "1.1"}, {"xmlns", "http://www.w3.org/2000/svg"},
|
||||
{"width", "48"}, {"height", "48"}, {"viewBox", "0 0 48 48"},
|
||||
});
|
||||
|
||||
// Create a mask to cut out the arrow and some space around it.
|
||||
auto defs = add(svg, "defs");
|
||||
auto mask = add(defs, "mask",
|
||||
{{"id", "arrow-cutout"}});
|
||||
add(mask, "rect", {
|
||||
{"fill", "#fff"},
|
||||
{"x", "-1.5"}, {"width", "3"},
|
||||
{"y", "-1.5"}, {"height", "3"},
|
||||
});
|
||||
add(mask, "path", {
|
||||
{"d", arrowPath},
|
||||
{"stroke", "#000"},
|
||||
{"stroke-width", QString::number(style.arrow_stroke_width)},
|
||||
{"stroke-linejoin", "round"},
|
||||
});
|
||||
|
||||
// XXX: Maybe the normal icon should look more like the Mac icon,
|
||||
// (<circle cx="512" cy="512" r="475" ... /> and scale(15)),
|
||||
// at least to be used as the non-Mac program icon.
|
||||
if (kind == IconKind::Mac) {
|
||||
auto gradient = add(defs, "linearGradient", {
|
||||
{"id", "bg-gradient"},
|
||||
{"x1", "0%"}, {"x2", "0%"},
|
||||
{"y1", "0%"}, {"y2", "100%"},
|
||||
});
|
||||
// Qt SVG only supports RGB, not HSL or other stuff.
|
||||
add(gradient, "stop",
|
||||
{{"offset", "0%"}, {"stop-color", colorTop.name()}});
|
||||
add(gradient, "stop",
|
||||
{{"offset", "100%"}, {"stop-color", colorBottom.name()}});
|
||||
|
||||
auto shadow = add(defs, "filter", {
|
||||
{"id", "shadow"},
|
||||
{"x", "-50%"}, {"width", "200%"},
|
||||
{"y", "-50%"}, {"height", "200%"},
|
||||
{"color-interpolation-filters", "sRGB"},
|
||||
});
|
||||
add(shadow, "feFlood",
|
||||
{{"flood-color", "#000"}, {"flood-opacity", "0.5"}});
|
||||
add(shadow, "feComposite",
|
||||
{{"in2", "SourceAlpha"}, {"operator", "in"}});
|
||||
add(shadow, "feOffset",
|
||||
{{"dy", "12"}});
|
||||
// It seems like Core Graphics shadow size
|
||||
// has a 2:1 relationship to standard deviation.
|
||||
add(shadow, "feGaussianBlur",
|
||||
{{"stdDeviation", "14"}});
|
||||
add(shadow, "feBlend",
|
||||
{{"in", "SourceGraphic"}, {"mode", "over"}});
|
||||
|
||||
// macOS background put together.
|
||||
//
|
||||
// Qt SVG seems to lose or disregard feOffset decimal digits,
|
||||
// so we're actually forced to draw this shape on the 1024 scale.
|
||||
add(svg, "path", {
|
||||
{"d", makeSquirclePath(100, 100, 824, 824)},
|
||||
{"fill", "url(#bg-gradient)"},
|
||||
{"filter", "url(#shadow)"},
|
||||
{"transform", "scale(0.046875)"},
|
||||
});
|
||||
}
|
||||
|
||||
QDomElement g = add(svg, "g", {
|
||||
{"transform", QString("translate(24 24) scale(%1)").arg(style.scale)},
|
||||
});
|
||||
|
||||
// The generic and small styles include an outline around the symbolic icon.
|
||||
if (kind == IconKind::Generic || kind == IconKind::Small) {
|
||||
add(g, "circle",
|
||||
{{"cx", "0"}, {"cy", "0"}, {"r", "1.3"}});
|
||||
add(g, "path", {
|
||||
{"d", arrowPath},
|
||||
{"stroke", "#000"},
|
||||
{"stroke-width", QString::number(style.arrow_stroke_width)},
|
||||
{"stroke-linejoin", "round"},
|
||||
});
|
||||
}
|
||||
|
||||
// A circle with ticks behind the arrow, and the arrow within.
|
||||
add(g, "path", {
|
||||
{"d", "M -1,0 h +0.5 M 1,0 h -0.5 "
|
||||
"M -1,0 A 1,1 0 1 1 +1,0 A 1,1 0 1 1 -1,0"},
|
||||
{"mask", "url(#arrow-cutout)"},
|
||||
{"fill", "none"},
|
||||
{"opacity", QString::number(style.circle_opacity)},
|
||||
{"stroke", style.circle_color},
|
||||
{"stroke-width", QString::number(style.circle_width)},
|
||||
});
|
||||
add(g, "path",
|
||||
{{"d", arrowPath}, {"fill", style.arrow_color}});
|
||||
|
||||
// This is not OpenGL; drawing the arrow in two parts would look bad.
|
||||
if (kind == IconKind::Mac) {
|
||||
add(g, "path", {
|
||||
{"d", "M 0,-1.3 L +0.55,0.4 L +0.2,0.25 L 0,1.3 z"},
|
||||
{"fill", colorAccent.name()},
|
||||
{"opacity", "0.1"},
|
||||
});
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
app.setApplicationName(argv[0]);
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(
|
||||
"Generate an icon for north in SVG or PNG format");
|
||||
parser.addHelpOption();
|
||||
|
||||
QCommandLineOption pngOption({"p", "png"},
|
||||
"Render PNG of specified size (otherwise output SVG)",
|
||||
"size");
|
||||
parser.addOption(pngOption);
|
||||
|
||||
QCommandLineOption kindOption({"k", "kind"},
|
||||
"Icon kind: generic, small, symbolic, or mac (default: generic)",
|
||||
"kind", "generic");
|
||||
parser.addOption(kindOption);
|
||||
|
||||
parser.process(app);
|
||||
|
||||
QString kindStr = parser.value(kindOption);
|
||||
if (!parser.positionalArguments().isEmpty() ||
|
||||
!kindNames.contains(kindStr)) {
|
||||
parser.showMessageAndExit(QCommandLineParser::MessageType::Error,
|
||||
parser.helpText(), 2);
|
||||
}
|
||||
IconKind kind = kindNames[kindStr];
|
||||
|
||||
// With QSvgGenerator we wouldn't be able to draw shadows,
|
||||
// so we generate an SVG XML, and render it through QSvgRenderer.
|
||||
//
|
||||
// Another option would be adding the shadow externally
|
||||
// as a post-processing step, which we would like to avoid.
|
||||
QDomDocument doc = generateSVG(kind);
|
||||
if (!parser.isSet(pngOption)) {
|
||||
QTextStream(stdout) << doc.toString(2);
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool ok;
|
||||
int size = parser.value(pngOption).toInt(&ok);
|
||||
if (!ok || size <= 0) {
|
||||
QTextStream(stderr)
|
||||
<< "Error: Invalid size: " << parser.value(pngOption)
|
||||
<< Qt::endl;
|
||||
return 2;
|
||||
}
|
||||
|
||||
QSvgRenderer renderer(doc.toByteArray());
|
||||
if (!renderer.isValid()) {
|
||||
QTextStream(stderr) << "Error: Failed to load SVG" << Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
QImage image(size, size, QImage::Format_ARGB32);
|
||||
image.fill(Qt::transparent);
|
||||
|
||||
QPainter painter(&image);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
renderer.render(&painter);
|
||||
painter.end();
|
||||
|
||||
#ifdef Q_OS_WIN32
|
||||
_setmode(fileno(stdout), _O_BINARY);
|
||||
#endif
|
||||
|
||||
QFile file;
|
||||
if (!file.open(stdout, QIODevice::WriteOnly)) {
|
||||
QTextStream(stderr)
|
||||
<< "Error: Failed to open stdout: " << file.errorString()
|
||||
<< Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
if (!image.save(&file, "PNG") || !file.flush()) {
|
||||
QTextStream(stderr)
|
||||
<< "Error: Failed to write PNG: " << file.errorString()
|
||||
<< Qt::endl;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
548
north.cpp
Normal file
548
north.cpp
Normal file
@@ -0,0 +1,548 @@
|
||||
// north.cpp: simple TOTP authenticator application
|
||||
//
|
||||
// Copyright (c) 2025, Přemysl Eric Janouch <p@janouch.name>
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
#include <QCommandLineParser>
|
||||
#include <QDateTime>
|
||||
#include <QMessageAuthenticationCode>
|
||||
#include <QStandardPaths>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <QXmlStreamWriter>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QCloseEvent>
|
||||
#include <QDialog>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QLabel>
|
||||
#include <QGridLayout>
|
||||
#include <QMenu>
|
||||
#include <QMenuBar>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QProgressBar>
|
||||
#include <QStyle>
|
||||
#include <QSystemTrayIcon>
|
||||
#include <QTextEdit>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidgetAction>
|
||||
|
||||
#include "north.h"
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <ApplicationServices/ApplicationServices.h>
|
||||
#endif
|
||||
|
||||
static void setIsForeground([[maybe_unused]] bool isForeground)
|
||||
{
|
||||
#ifdef __APPLE__
|
||||
// Setting QT_MAC_DISABLE_FOREGROUND_APPLICATION_TRANSFORM should prevent
|
||||
// qt_mac_transformProccessToForegroundApplication from making us flash
|
||||
// in the dock as a foreground application, but in practice we need to
|
||||
// claim LSUIElement in Info.plist.
|
||||
|
||||
ProcessSerialNumber psn = {0, kCurrentProcess};
|
||||
TransformProcessType(&psn, isForeground
|
||||
? kProcessTransformToForegroundApplication
|
||||
: kProcessTransformToUIElementApplication);
|
||||
#endif
|
||||
}
|
||||
|
||||
static QString escape_xml(const QString &input)
|
||||
{
|
||||
QString output;
|
||||
QXmlStreamWriter writer(&output);
|
||||
writer.writeCharacters(input);
|
||||
return output;
|
||||
}
|
||||
|
||||
static QString escape_utf8(const std::string &utf8)
|
||||
{
|
||||
return escape_xml(QString::fromUtf8(utf8));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// RFC 4648/3548
|
||||
static std::optional<std::vector<uint8_t>> base32_decode(const QString &base32)
|
||||
{
|
||||
static const char lookup[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
std::vector<uint8_t> result;
|
||||
unsigned buffer = 0, bits = 0;
|
||||
bool padding = false;
|
||||
for (QChar c : base32) {
|
||||
ptrdiff_t val = strchr(lookup, c.toLatin1()) - lookup;
|
||||
if (padding && c != '=')
|
||||
return {};
|
||||
|
||||
if (c == '=')
|
||||
padding = true;
|
||||
else if (val < 0 || val >= 32)
|
||||
return {};
|
||||
|
||||
buffer = (buffer << 5) | val;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
result.push_back(buffer >> (bits - 8));
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
||||
// https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-02.html
|
||||
// Parse a TOTP URI, returning any error condition.
|
||||
QString TOTP::parse(const std::string &uri)
|
||||
{
|
||||
QUrl url(QString::fromStdString(uri), QUrl::ParsingMode::StrictMode);
|
||||
if (!url.isValid())
|
||||
return "Invalid URI: " + url.errorString();
|
||||
if (url.scheme() != "otpauth")
|
||||
return "Invalid URI scheme";
|
||||
if (url.host() != "totp")
|
||||
return "Only TOTP is supported";
|
||||
|
||||
auto path = url.path();
|
||||
if (path.startsWith("/"))
|
||||
path.removeFirst();
|
||||
|
||||
if (auto colon = path.indexOf(':'); colon != -1) {
|
||||
this->issuer = path.mid(0, colon).toStdString();
|
||||
path.slice(colon + 1);
|
||||
}
|
||||
|
||||
this->label = path.toStdString();
|
||||
|
||||
QUrlQuery query(url.query());
|
||||
if (!query.hasQueryItem("secret"))
|
||||
return "Missing TOTP secret";
|
||||
|
||||
auto secret = base32_decode(query.queryItemValue("secret"));
|
||||
if (!secret.has_value())
|
||||
return "Invalid TOTP secret";
|
||||
this->secret = *secret;
|
||||
|
||||
if (query.hasQueryItem("algorithm")) {
|
||||
auto algorithm = query.queryItemValue("algorithm");
|
||||
if (algorithm == "SHA1")
|
||||
this->algorithm = TOTP::Algorithm::SHA1;
|
||||
else if (algorithm == "SHA256")
|
||||
this->algorithm = TOTP::Algorithm::SHA256;
|
||||
else if (algorithm == "SHA512")
|
||||
this->algorithm = TOTP::Algorithm::SHA512;
|
||||
else
|
||||
return "Unknown algorithm: " + algorithm;
|
||||
}
|
||||
if (query.hasQueryItem("digits")) {
|
||||
auto digits = query.queryItemValue("digits");
|
||||
if (digits.size() != 1 || digits[0] < '6' || digits[0] > '8')
|
||||
return "Invalid digits";
|
||||
this->digits = digits[0].digitValue();
|
||||
}
|
||||
if (query.hasQueryItem("period")) {
|
||||
bool ok = {};
|
||||
auto period = query.queryItemValue("period").toInt(&ok);
|
||||
if (!ok || period <= 0)
|
||||
return "Invalid period";
|
||||
this->period = period;
|
||||
}
|
||||
if (query.hasQueryItem("issuer"))
|
||||
this->issuer = query.queryItemValue("issuer").toStdString();
|
||||
return "";
|
||||
}
|
||||
|
||||
QString TOTP::generate(quint64 timestamp) const
|
||||
{
|
||||
quint64 counter = timestamp / this->period;
|
||||
|
||||
QByteArray counterBytes(8, 0);
|
||||
for (int i = 7; i >= 0; --i) {
|
||||
counterBytes[i] = static_cast<char>(counter & 0xFF);
|
||||
counter >>= 8;
|
||||
}
|
||||
|
||||
QCryptographicHash::Algorithm algo = QCryptographicHash::Sha1;
|
||||
switch (this->algorithm) {
|
||||
case Algorithm::SHA1: algo = QCryptographicHash::Sha1; break;
|
||||
case Algorithm::SHA256: algo = QCryptographicHash::Sha256; break;
|
||||
case Algorithm::SHA512: algo = QCryptographicHash::Sha512; break;
|
||||
}
|
||||
|
||||
QByteArray hmac = QMessageAuthenticationCode::hash(
|
||||
counterBytes, QByteArray::fromRawData(
|
||||
(const char *)this->secret.data(), this->secret.size()), algo);
|
||||
|
||||
int offset = hmac.at(hmac.size() - 1) & 0x0F;
|
||||
quint32 binary =
|
||||
(hmac[offset] & 0x7F) << 24 |
|
||||
(hmac[offset + 1] & 0xFF) << 16 |
|
||||
(hmac[offset + 2] & 0xFF) << 8 |
|
||||
(hmac[offset + 3] & 0xFF);
|
||||
|
||||
auto code = binary % static_cast<quint32>(qPow(10, this->digits));
|
||||
return QString::number(code).rightJustified(this->digits, '0');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
void TOTPItem::update()
|
||||
{
|
||||
qint64 currentMs = QDateTime::currentMSecsSinceEpoch();
|
||||
qint64 currentTime = currentMs / 1000;
|
||||
qint64 periodMs = this->totp.period * 1000;
|
||||
qint64 msRemaining = periodMs - currentMs % periodMs;
|
||||
|
||||
this->progress->setValue(
|
||||
msRemaining * this->progress->maximum() / periodMs);
|
||||
|
||||
auto formattedCode = "<big>" + this->totp.generate(currentTime) + "</big>";
|
||||
if (this->code->text() != formattedCode)
|
||||
this->code->setText(formattedCode);
|
||||
}
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent, bool useTray)
|
||||
: QMainWindow(parent)
|
||||
{
|
||||
this->setWindowTitle("north authenticator");
|
||||
|
||||
loadSettings();
|
||||
|
||||
updateTimer = new QTimer(this);
|
||||
connect(updateTimer, &QTimer::timeout, this, &MainWindow::updateTOTPs);
|
||||
|
||||
systemTrayIcon = nullptr;
|
||||
if (useTray) {
|
||||
QIcon icon(":/systray.svg");
|
||||
#ifdef Q_OS_MACOS
|
||||
// Masking is only really supported on Qt/macOS,
|
||||
// and we need a different icon for it.
|
||||
icon.setIsMask(true);
|
||||
#endif
|
||||
systemTrayIcon = new QSystemTrayIcon(this);
|
||||
systemTrayIcon->setToolTip("north authenticator");
|
||||
systemTrayIcon->setIcon(icon);
|
||||
|
||||
auto *menu = new QMenu(this);
|
||||
|
||||
auto *widgetAction = new QWidgetAction(menu);
|
||||
totpWidget = buildTOTPWidget(menu);
|
||||
widgetAction->setDefaultWidget(totpWidget);
|
||||
menu->addAction(widgetAction);
|
||||
|
||||
menu->addSeparator();
|
||||
|
||||
auto *settingsAction = menu->addAction("&Settings...");
|
||||
connect(settingsAction, &QAction::triggered,
|
||||
this, &MainWindow::openSettings);
|
||||
auto *quitAction = menu->addAction("&Quit");
|
||||
connect(quitAction, &QAction::triggered,
|
||||
&QApplication::quit);
|
||||
|
||||
systemTrayIcon->setContextMenu(menu);
|
||||
#ifndef Q_OS_MACOS
|
||||
connect(systemTrayIcon, &QSystemTrayIcon::activated,
|
||||
this, [menu](QSystemTrayIcon::ActivationReason reason) {
|
||||
if (reason == QSystemTrayIcon::Trigger)
|
||||
menu->popup(QCursor::pos());
|
||||
});
|
||||
#endif
|
||||
systemTrayIcon->show();
|
||||
} else {
|
||||
totpWidget = buildTOTPWidget(this);
|
||||
this->setCentralWidget(totpWidget);
|
||||
|
||||
// Qt seems to automatically resolve menu bar differences on macOS.
|
||||
auto *menuBar = this->menuBar();
|
||||
auto *fileMenu = menuBar->addMenu("&File");
|
||||
|
||||
auto *settingsAction = fileMenu->addAction("&Settings...");
|
||||
connect(settingsAction, &QAction::triggered,
|
||||
this, &MainWindow::openSettings);
|
||||
|
||||
fileMenu->addSeparator();
|
||||
|
||||
auto *quitAction = fileMenu->addAction("&Quit");
|
||||
connect(quitAction, &QAction::triggered,
|
||||
&QApplication::quit);
|
||||
}
|
||||
}
|
||||
|
||||
QWidget *MainWindow::buildTOTPWidget(QWidget *parent)
|
||||
{
|
||||
auto *widget = new QWidget(parent);
|
||||
auto *layout = new QGridLayout(widget);
|
||||
layout->setSpacing(5);
|
||||
layout->setContentsMargins(10, 10, 10, 10);
|
||||
|
||||
if (this->uris.empty()) {
|
||||
auto *emptyLabel =
|
||||
new QLabel("There are no codes to generate.", widget);
|
||||
emptyLabel->setAlignment(Qt::AlignCenter);
|
||||
emptyLabel->setEnabled(false);
|
||||
layout->addWidget(emptyLabel, 0, 0);
|
||||
return widget;
|
||||
}
|
||||
|
||||
int group = 0;
|
||||
for (const auto &uri : this->uris) {
|
||||
TOTP totp;
|
||||
auto err = totp.parse(uri);
|
||||
if (!err.isEmpty()) {
|
||||
QMessageBox::critical(this, "URI parse error", err);
|
||||
continue;
|
||||
}
|
||||
|
||||
auto code = totp.generate(QDateTime::currentSecsSinceEpoch());
|
||||
|
||||
auto *issuer_label =
|
||||
new QLabel("<b>" + escape_utf8(totp.issuer) + "</b>", widget);
|
||||
auto *name_label =
|
||||
new QLabel(escape_utf8(totp.label), widget);
|
||||
auto *code_label =
|
||||
new QLabel("<big>" + code + "</big>", widget);
|
||||
|
||||
QIcon icon(":/edit-copy-symbolic.svg");
|
||||
icon.setIsMask(true);
|
||||
|
||||
auto *copy_button = new QPushButton(widget);
|
||||
copy_button->setIcon(icon);
|
||||
copy_button->setToolTip("Copy to clipboard");
|
||||
|
||||
auto *progress = new QProgressBar(widget);
|
||||
progress->setTextVisible(false);
|
||||
progress->setMaximum(totp.period * 10);
|
||||
|
||||
auto *button_container = new QWidget(widget);
|
||||
auto *button_layout = new QVBoxLayout(button_container);
|
||||
button_layout->setContentsMargins(5, 0, 0, 0);
|
||||
button_layout->addWidget(copy_button);
|
||||
|
||||
button_container->setSizePolicy(
|
||||
QSizePolicy::Fixed, QSizePolicy::Expanding);
|
||||
copy_button->setSizePolicy(
|
||||
QSizePolicy::Fixed, QSizePolicy::Expanding);
|
||||
|
||||
layout->addWidget(issuer_label, group * 3, 0);
|
||||
layout->addWidget(name_label, group * 3, 1);
|
||||
layout->addWidget(button_container, group * 3, 2, 2, 1);
|
||||
layout->addWidget(code_label, group * 3 + 1, 0, 1, 2);
|
||||
layout->addWidget(progress, group * 3 + 2, 0, 1, 3);
|
||||
|
||||
this->totps.emplace_back(std::move(totp), code_label, progress);
|
||||
|
||||
size_t i = this->totps.size() - 1;
|
||||
connect(copy_button, &QPushButton::clicked, [this, i] {
|
||||
QApplication::clipboard()->setText(this->totps[i].totp.generate(
|
||||
QDateTime::currentSecsSinceEpoch()));
|
||||
});
|
||||
|
||||
group++;
|
||||
}
|
||||
|
||||
// Prevent vertical stretching by adding a spacer that consumes extra space.
|
||||
layout->setRowStretch(group * 3, 1);
|
||||
|
||||
widget->installEventFilter(this);
|
||||
return widget;
|
||||
}
|
||||
|
||||
bool MainWindow::eventFilter(QObject *obj, QEvent *event)
|
||||
{
|
||||
if (obj == totpWidget) {
|
||||
if (event->type() == QEvent::Show) {
|
||||
updateTimer->start(100); // 10 FPS
|
||||
updateTOTPs();
|
||||
} else if (event->type() == QEvent::Hide) {
|
||||
updateTimer->stop();
|
||||
}
|
||||
}
|
||||
return QMainWindow::eventFilter(obj, event);
|
||||
}
|
||||
|
||||
void MainWindow::openSettings()
|
||||
{
|
||||
if (systemTrayIcon)
|
||||
setIsForeground(true);
|
||||
|
||||
auto *dialog = new QDialog(this);
|
||||
dialog->setWindowTitle("Settings");
|
||||
|
||||
auto *layout = new QVBoxLayout(dialog);
|
||||
|
||||
auto *label = new QLabel("Enter otpauth:// URLs, one per line:", dialog);
|
||||
layout->addWidget(label);
|
||||
|
||||
auto *textEdit = new QTextEdit(dialog);
|
||||
textEdit->setPlainText(QString::fromStdString(
|
||||
std::accumulate(uris.begin(), uris.end(), std::string(),
|
||||
[](const std::string &a, const std::string &b) {
|
||||
return a.empty() ? b : a + "\n" + b;
|
||||
})));
|
||||
layout->addWidget(textEdit);
|
||||
|
||||
auto *buttonBox = new QDialogButtonBox(
|
||||
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dialog);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, dialog, &QDialog::accept);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, dialog, &QDialog::reject);
|
||||
layout->addWidget(buttonBox);
|
||||
|
||||
dialog->resize(800, 400);
|
||||
dialog->move(dialog->screen()->geometry().center()
|
||||
- dialog->frameGeometry().center());
|
||||
|
||||
// We mainly need to do this on macOS,
|
||||
// otherwise the window most likely will stay in the background.
|
||||
dialog->show();
|
||||
dialog->raise();
|
||||
|
||||
auto result = dialog->exec();
|
||||
auto text = textEdit->toPlainText();
|
||||
delete dialog;
|
||||
|
||||
if (systemTrayIcon)
|
||||
setIsForeground(false);
|
||||
|
||||
if (result == QDialog::Accepted) {
|
||||
uris.clear();
|
||||
auto lines = text.split('\n');
|
||||
for (const auto &line : lines) {
|
||||
auto trimmed = line.trimmed();
|
||||
if (!trimmed.isEmpty())
|
||||
uris.push_back(trimmed.toStdString());
|
||||
}
|
||||
saveSettings();
|
||||
reloadTOTPs();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::updateTOTPs()
|
||||
{
|
||||
if (totpWidget)
|
||||
for (auto &item : this->totps)
|
||||
item.update();
|
||||
}
|
||||
|
||||
void MainWindow::loadSettings()
|
||||
{
|
||||
auto configPath = QStandardPaths::writableLocation(
|
||||
QStandardPaths::AppConfigLocation);
|
||||
auto filePath = QDir(configPath).filePath("north.json");
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.exists())
|
||||
return;
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
QMessageBox::warning(this, "Load error",
|
||||
"Could not open settings file: " + file.errorString());
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError error;
|
||||
auto doc = QJsonDocument::fromJson(file.readAll(), &error);
|
||||
if (error.error != QJsonParseError::NoError) {
|
||||
QMessageBox::warning(this, "Parse error",
|
||||
"Could not parse settings file: " + error.errorString());
|
||||
return;
|
||||
}
|
||||
|
||||
auto obj = doc.object();
|
||||
auto array = obj.value("urls").toArray();
|
||||
uris.clear();
|
||||
for (const auto &value : array) {
|
||||
if (value.isString())
|
||||
uris.push_back(value.toString().toStdString());
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::saveSettings()
|
||||
{
|
||||
auto configPath = QStandardPaths::writableLocation(
|
||||
QStandardPaths::AppConfigLocation);
|
||||
|
||||
QDir dir;
|
||||
if (!dir.mkpath(configPath)) {
|
||||
QMessageBox::warning(this, "Save error",
|
||||
"Could not create config directory");
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonArray array;
|
||||
for (const auto &uri : uris)
|
||||
array.append(QString::fromStdString(uri));
|
||||
|
||||
QJsonObject obj;
|
||||
obj["version"] = 1;
|
||||
obj["urls"] = array;
|
||||
|
||||
QJsonDocument doc(obj);
|
||||
auto filePath = QDir(configPath).filePath("north.json");
|
||||
QFile file(filePath);
|
||||
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
QMessageBox::warning(this, "Save error",
|
||||
"Could not open settings file for writing: " + file.errorString());
|
||||
return;
|
||||
}
|
||||
|
||||
file.write(doc.toJson());
|
||||
}
|
||||
|
||||
void MainWindow::reloadTOTPs()
|
||||
{
|
||||
totps.clear();
|
||||
delete totpWidget;
|
||||
|
||||
if (systemTrayIcon) {
|
||||
auto *menu = systemTrayIcon->contextMenu();
|
||||
if (auto *old = qobject_cast<QWidgetAction *>(menu->actions().first()))
|
||||
delete old;
|
||||
|
||||
auto *widgetAction = new QWidgetAction(menu);
|
||||
totpWidget = buildTOTPWidget(menu);
|
||||
widgetAction->setDefaultWidget(totpWidget);
|
||||
menu->insertAction(menu->actions().first(), widgetAction);
|
||||
} else {
|
||||
totpWidget = buildTOTPWidget(this);
|
||||
this->setCentralWidget(totpWidget);
|
||||
}
|
||||
|
||||
updateTOTPs();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
// This defaults to the program name, but nonetheless.
|
||||
QCoreApplication::setApplicationName("north");
|
||||
|
||||
QApplication a(argc, argv);
|
||||
|
||||
QCommandLineOption noTray{"no-tray", "Do not use the system tray"};
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription("TOTP authenticator");
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
parser.addOption(noTray);
|
||||
parser.process(a);
|
||||
|
||||
bool useTray = QSystemTrayIcon::isSystemTrayAvailable() &&
|
||||
!parser.isSet(noTray);
|
||||
|
||||
MainWindow w(nullptr, useTray);
|
||||
if (!useTray) {
|
||||
setIsForeground(true);
|
||||
w.show();
|
||||
}
|
||||
return a.exec();
|
||||
}
|
||||
9
north.desktop
Normal file
9
north.desktop
Normal file
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=north
|
||||
GenericName=TOTP Authenticator
|
||||
X-GNOME-FullName=north Authenticator
|
||||
Icon=north
|
||||
Exec=north
|
||||
StartupNotify=true
|
||||
Categories=Utility;Security;
|
||||
66
north.h
Normal file
66
north.h
Normal file
@@ -0,0 +1,66 @@
|
||||
// north.h: simple TOTP authenticator application
|
||||
//
|
||||
// Copyright (c) 2025, Přemysl Eric Janouch <p@janouch.name>
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QMainWindow>
|
||||
|
||||
class QLabel;
|
||||
class QProgressBar;
|
||||
class QSystemTrayIcon;
|
||||
class QTimer;
|
||||
|
||||
struct TOTP
|
||||
{
|
||||
// The issuer can be prepended to the label, and/or passed as a parameter.
|
||||
std::string issuer;
|
||||
std::string label;
|
||||
std::vector<uint8_t> secret;
|
||||
int period = 30;
|
||||
int digits = 6;
|
||||
enum class Algorithm { SHA1, SHA256, SHA512 } algorithm = Algorithm::SHA1;
|
||||
|
||||
QString parse(const std::string &uri);
|
||||
QString generate(quint64 timestamp) const;
|
||||
};
|
||||
|
||||
struct TOTPItem
|
||||
{
|
||||
TOTP totp;
|
||||
QLabel *code;
|
||||
QProgressBar *progress;
|
||||
|
||||
TOTPItem(TOTP totp, QLabel *code, QProgressBar *progress)
|
||||
: totp(totp), code(code), progress(progress) {}
|
||||
void update();
|
||||
};
|
||||
|
||||
class MainWindow : public QMainWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
std::vector<std::string> uris;
|
||||
std::vector<TOTPItem> totps;
|
||||
QSystemTrayIcon *systemTrayIcon;
|
||||
QTimer *updateTimer;
|
||||
QWidget *totpWidget;
|
||||
|
||||
public:
|
||||
MainWindow(QWidget *parent, bool useTray);
|
||||
~MainWindow() {}
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||
|
||||
private:
|
||||
QWidget *buildTOTPWidget(QWidget *parent);
|
||||
void loadSettings();
|
||||
void saveSettings();
|
||||
void reloadTOTPs();
|
||||
|
||||
private slots:
|
||||
void openSettings();
|
||||
void updateTOTPs();
|
||||
};
|
||||
Reference in New Issue
Block a user