Initial commit

This commit is contained in:
2025-03-28 08:06:29 +01:00
commit 887d2d3bc4
11 changed files with 1274 additions and 0 deletions

10
.clang-format Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
};